Skip to content

Commit 819ab2d

Browse files
committed
render display metadata
icons usage
1 parent cf24967 commit 819ab2d

13 files changed

Lines changed: 289 additions & 21 deletions

File tree

StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/Link/PaymentDetails.swift

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,19 +24,22 @@ final class ConsumerPaymentDetails: Decodable {
2424
let billingAddress: BillingAddress?
2525
let billingEmailAddress: String?
2626
let nickname: String?
27+
let display: DisplayMetadata?
2728
var isDefault: Bool
2829

2930
init(stripeID: String,
3031
details: Details,
3132
billingAddress: BillingAddress?,
3233
billingEmailAddress: String?,
3334
nickname: String?,
35+
display: DisplayMetadata? = nil,
3436
isDefault: Bool) {
3537
self.stripeID = stripeID
3638
self.details = details
3739
self.billingAddress = billingAddress
3840
self.billingEmailAddress = billingEmailAddress
3941
self.nickname = nickname
42+
self.display = display
4043
self.isDefault = isDefault
4144
}
4245

@@ -45,6 +48,7 @@ final class ConsumerPaymentDetails: Decodable {
4548
case billingAddress = "billing_address"
4649
case billingEmailAddress = "billing_email_address"
4750
case nickname
51+
case display
4852
case isDefault
4953
}
5054

@@ -59,6 +63,7 @@ final class ConsumerPaymentDetails: Decodable {
5963
} else {
6064
self.nickname = nil
6165
}
66+
self.display = try? container.decode(DisplayMetadata.self, forKey: .display)
6267
// The payment details are included in the dictionary, so we pass the whole dict to Details
6368
self.details = try decoder.singleValueContainer().decode(Details.self)
6469
self.isDefault = try container.decode(Bool.self, forKey: .isDefault)
@@ -112,7 +117,8 @@ extension ConsumerPaymentDetails {
112117
// These are US bank accounts, so only check for US country code
113118
return allowedCountries.contains("US")
114119
case .unparsable:
115-
return false
120+
// Unknown types don't have country info; allow them if display metadata is present.
121+
return display != nil
116122
}
117123
}
118124

@@ -134,13 +140,29 @@ extension ConsumerPaymentDetails {
134140
case bankAccount = "BANK_ACCOUNT"
135141
}
136142

143+
struct DisplayMetadata: Decodable {
144+
let label: String
145+
let sublabel: String?
146+
147+
let icon: Icon?
148+
struct Icon: Decodable {
149+
let main: URL?
150+
enum CodingKeys: String, CodingKey {
151+
case main = "default"
152+
}
153+
}
154+
155+
init(icon: Icon? = nil, label: String, sublabel: String? = nil) {
156+
self.icon = icon
157+
self.label = label
158+
self.sublabel = sublabel
159+
}
160+
}
161+
137162
// swiftlint:disable:next enum_safe_decodable
138163
enum Details: Decodable {
139164
case card(card: Card)
140165
case bankAccount(bankAccount: BankAccount)
141-
142-
// TODO(jkelle): We'll add the `display` metadata
143-
// [Proposal](https://docs.google.com/document/d/1x834BjHYro9-bDoAVaqgHm7LDPDwzpk4z_5BvxYwwtU)
144166
case unparsable(rawValue: String)
145167

146168
private enum CodingKeys: String, CodingKey {
@@ -364,7 +386,7 @@ extension ConsumerPaymentDetails {
364386
case .bankAccount(let bank):
365387
return bank.displayName(with: nickname)
366388
case .unparsable:
367-
return ""
389+
return display?.label ?? ""
368390
}
369391
}
370392

@@ -378,7 +400,9 @@ extension ConsumerPaymentDetails {
378400
case .bankAccount(let bankAccount):
379401
return bankAccount.displayName(with: nickname)
380402
case .unparsable:
381-
return nil
403+
guard let display else { return nil }
404+
let components = [display.label, display.sublabel].compactMap { $0 }
405+
return components.joined(separator: " ")
382406
}
383407
}
384408

@@ -412,7 +436,9 @@ extension ConsumerPaymentDetails {
412436
digits
413437
)
414438
case .unparsable:
415-
return ""
439+
guard let display else { return "" }
440+
let components = [display.label, display.sublabel].compactMap { $0 }
441+
return components.joined(separator: " ")
416442
}
417443
}
418444

StripePaymentSheet/StripePaymentSheet/Source/Internal/API Bindings/v1-elements-sessions/PaymentMethodWithLinkDetails.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,8 @@ class PaymentMethodWithLinkDetails: NSObject, STPAPIResponseDecodable {
5151
}
5252
}
5353

54-
if let linkDetails, linkDetails.type.isUnparsed {
55-
// TODO(jkelle): We'll be able to render these with the `display` metadata
56-
// coming in https://docs.google.com/document/d/1x834BjHYro9-bDoAVaqgHm7LDPDwzpk4z_5BvxYwwtU/
54+
if let linkDetails, linkDetails.type.isUnparsed, linkDetails.display == nil {
55+
// This is a Link payment method with an unknown type and no display metadata. We can't render it.
5756
return nil
5857
}
5958

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Components/PaymentMethodPicker/LinkPaymentMethodPicker-CellContentView.swift

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ extension LinkPaymentMethodPicker {
2727
cardBrandView.setCardBrand(STPCard.brand(from: card.brand))
2828
bankIconView.isHidden = true
2929
cardBrandView.isHidden = false
30+
genericIconView.isHidden = true
3031
primaryLabel.text = paymentMethod?.paymentSheetLabel
3132
let hasDisplayName = card.displayName(with: paymentMethod?.nickname) != nil
3233
secondaryLabel.text = hasDisplayName ? card.secondaryName : nil
@@ -35,12 +36,34 @@ extension LinkPaymentMethodPicker {
3536
bankIconView.image = makeBankIcon(for: bankAccount.iconCode)
3637
cardBrandView.isHidden = true
3738
bankIconView.isHidden = false
39+
genericIconView.isHidden = true
3840
primaryLabel.text = bankAccount.displayName(with: paymentMethod?.nickname)
3941
secondaryLabel.text = "•••• \(bankAccount.last4)"
4042
secondaryLabel.isHidden = false
41-
case .none, .unparsable:
43+
case .unparsable:
44+
guard let display = paymentMethod?.display else {
45+
cardBrandView.isHidden = true
46+
bankIconView.isHidden = true
47+
genericIconView.isHidden = true
48+
primaryLabel.text = nil
49+
secondaryLabel.text = nil
50+
secondaryLabel.isHidden = true
51+
break
52+
}
4253
cardBrandView.isHidden = true
4354
bankIconView.isHidden = true
55+
genericIconView.isHidden = false
56+
genericIconView.image = createGenericPaymentMethodIcon()
57+
if let iconUrl = display.icon?.main {
58+
loadRemoteIcon(from: iconUrl)
59+
}
60+
primaryLabel.text = display.label
61+
secondaryLabel.text = display.sublabel
62+
secondaryLabel.isHidden = display.sublabel == nil
63+
case .none:
64+
cardBrandView.isHidden = true
65+
bankIconView.isHidden = true
66+
genericIconView.isHidden = true
4467
primaryLabel.text = nil
4568
secondaryLabel.text = nil
4669
secondaryLabel.isHidden = true
@@ -54,6 +77,12 @@ extension LinkPaymentMethodPicker {
5477
return iconView
5578
}()
5679

80+
private lazy var genericIconView: UIImageView = {
81+
let iconView = UIImageView()
82+
iconView.contentMode = .scaleAspectFit
83+
return iconView
84+
}()
85+
5786
private lazy var cardBrandView: CardBrandView = CardBrandView(centerHorizontally: true)
5887

5988
private let primaryLabel: UILabel = {
@@ -75,9 +104,11 @@ extension LinkPaymentMethodPicker {
75104
let view = UIView()
76105
bankIconView.translatesAutoresizingMaskIntoConstraints = false
77106
cardBrandView.translatesAutoresizingMaskIntoConstraints = false
107+
genericIconView.translatesAutoresizingMaskIntoConstraints = false
78108

79109
view.addSubview(bankIconView)
80110
view.addSubview(cardBrandView)
111+
view.addSubview(genericIconView)
81112

82113
let cardBrandSize = cardBrandView.size(for: Constants.iconSize)
83114
let width = max(Constants.iconSize.width, cardBrandSize.width)
@@ -105,6 +136,13 @@ extension LinkPaymentMethodPicker {
105136

106137
cardBrandView.widthAnchor.constraint(equalToConstant: cardBrandSize.width),
107138
cardBrandView.heightAnchor.constraint(equalToConstant: cardBrandSize.height),
139+
140+
genericIconView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor),
141+
genericIconView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor),
142+
genericIconView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor),
143+
genericIconView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor),
144+
genericIconView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
145+
genericIconView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
108146
])
109147

110148
return view
@@ -173,13 +211,64 @@ extension LinkPaymentMethodPicker {
173211
}
174212
}
175213

214+
private func createGenericPaymentMethodIcon() -> UIImage {
215+
let icon = PaymentSheetImageLibrary.linkBankIcon()
216+
let iconColor: UIColor = .linkIconPrimary
217+
let backgroundColor: UIColor = .linkSurfaceTertiary
218+
219+
let iconSize: CGSize = .init(width: 16, height: 16)
220+
let backgroundSize: CGSize = .init(width: 24, height: 24)
221+
let cornerRadius: CGFloat = 3.0
222+
223+
let renderer = UIGraphicsImageRenderer(size: backgroundSize)
224+
return renderer.image { _ in
225+
let rect = CGRect(origin: .zero, size: backgroundSize)
226+
let path = UIBezierPath(roundedRect: rect, cornerRadius: cornerRadius)
227+
228+
backgroundColor.setFill()
229+
path.fill()
230+
231+
let iconRect = CGRect(
232+
x: (backgroundSize.width - iconSize.width) / 2,
233+
y: (backgroundSize.height - iconSize.height) / 2,
234+
width: iconSize.width,
235+
height: iconSize.height
236+
)
237+
icon.withTintColor(iconColor).draw(in: iconRect)
238+
}
239+
}
240+
241+
private func loadRemoteIcon(from url: URL) {
242+
let placeholder = createGenericPaymentMethodIcon()
243+
genericIconView.image = DownloadManager.sharedManager.downloadImage(
244+
url: url,
245+
placeholder: placeholder,
246+
updateHandler: { [weak self] image in
247+
DispatchQueue.main.async {
248+
self?.genericIconView.image = image
249+
}
250+
}
251+
)
252+
}
253+
176254
private func refreshBankIconIfNeeded() {
177255
guard case .bankAccount(let bankAccount) = paymentMethod?.details else {
178256
return
179257
}
180258
bankIconView.image = makeBankIcon(for: bankAccount.iconCode)
181259
}
182260

261+
private func refreshGenericIconIfNeeded() {
262+
guard case .unparsable = paymentMethod?.details else {
263+
return
264+
}
265+
if let iconUrl = paymentMethod?.display?.icon?.main {
266+
loadRemoteIcon(from: iconUrl)
267+
} else {
268+
genericIconView.image = createGenericPaymentMethodIcon()
269+
}
270+
}
271+
183272
// UIImages need to be manually updated when the system theme changes.
184273
#if !os(visionOS)
185274
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -188,6 +277,7 @@ extension LinkPaymentMethodPicker {
188277
return
189278
}
190279
refreshBankIconIfNeeded()
280+
refreshGenericIconIfNeeded()
191281
}
192282
#endif
193283
}

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController-WalletViewController.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,10 @@ extension PayWithLinkViewController.WalletViewController {
472472
"Title for a button that when tapped removes a linked bank account."
473473
)
474474
case .unparsable:
475-
return nil
475+
return STPLocalizedString(
476+
"Remove payment method",
477+
"Title for a button that when tapped removes a payment method."
478+
)
476479
}
477480
}()
478481

StripePaymentSheet/StripePaymentSheet/Source/Internal/Link/Controllers/PayWithLinkViewController.swift

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,16 +114,14 @@ final class PayWithLinkViewController: BottomSheetViewController {
114114
/// Returns the supported payment details types for the current Link account, filtered by the supportedPaymentMethodTypes.
115115
/// Returns [.card] as fallback if no types are supported after filtering.
116116
func getSupportedPaymentDetailsTypes(linkAccount: PaymentSheetLinkAccount) -> Set<ParsedEnum<ConsumerPaymentDetails.DetailsType>> {
117-
let allSupportedPaymentDetailsTypes = linkAccount.supportedPaymentDetailsTypes(for: elementsSession)
117+
var allSupportedPaymentDetailsTypes = linkAccount.supportedPaymentDetailsTypes(for: elementsSession)
118118

119-
// TODO(jkelle): Modify this line once we want to render PMs we don't have explicit support for (#6432).
120-
// Remove the `allCases` default for `nil` filter types.
121-
// https://docs.google.com/document/d/1x834BjHYro9-bDoAVaqgHm7LDPDwzpk4z_5BvxYwwtU
122-
let supportedPaymentDetailsTypes = supportedPaymentMethodTypes?.detailsTypes ?? Set(ConsumerPaymentDetails.DetailsType.allCases.map(ParsedEnum.init))
123-
let filteredSupportedPaymentDetailsTypes = allSupportedPaymentDetailsTypes.intersection(supportedPaymentDetailsTypes)
119+
if let supportedPaymentDetailsTypes = supportedPaymentMethodTypes?.detailsTypes {
120+
allSupportedPaymentDetailsTypes = allSupportedPaymentDetailsTypes.intersection(supportedPaymentDetailsTypes)
121+
}
124122

125-
if !filteredSupportedPaymentDetailsTypes.isEmpty {
126-
return filteredSupportedPaymentDetailsTypes
123+
if !allSupportedPaymentDetailsTypes.isEmpty {
124+
return allSupportedPaymentDetailsTypes
127125
} else {
128126
// Card is the default payment method type when no other type is available.
129127
return [ParsedEnum(.card)]

0 commit comments

Comments
 (0)