-
Notifications
You must be signed in to change notification settings - Fork 102
Expand file tree
/
Copy pathCardComponent.swift
More file actions
280 lines (240 loc) · 8.14 KB
/
CardComponent.swift
File metadata and controls
280 lines (240 loc) · 8.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
// MARK: - CardComponent
import BitwardenKit
import BitwardenResources
import Foundation
/// An enumeration defining various components associated with credit and debit cards.
///
enum CardComponent {
/// Represents months of the year, commonly used in card expiration dates.
///
enum Month: Int {
/// 01 - January
case jan = 1
/// 02 - February
case feb
/// 03 - March
case mar
/// 04 - April
case apr
/// 05 - May
case may
/// 06 - June
case jun
/// 07 - July
case jul
/// 08 - August
case aug
/// 09 - September
case sep
/// 10 - October
case oct
/// 11- November
case nov
/// 12 - December
case dec
/// A formatted number string for the month.
var formattedValue: String {
let formatter = NumberFormatter()
formatter.minimumIntegerDigits = 2
return formatter.string(from: NSNumber(value: rawValue)) ?? "00"
}
/// A localized string for the month name.
var localized: String {
switch self {
case .jan:
Localizations.january
case .feb:
Localizations.february
case .mar:
Localizations.march
case .apr:
Localizations.april
case .may:
Localizations.may
case .jun:
Localizations.june
case .jul:
Localizations.july
case .aug:
Localizations.august
case .sep:
Localizations.september
case .oct:
Localizations.october
case .nov:
Localizations.november
case .dec:
Localizations.december
}
}
}
/// Represents various credit card brands.
///
enum Brand: String {
/// Visa
case visa = "Visa"
/// Mastercard
case mastercard = "Mastercard" // swiftlint:disable:this inclusive_language
/// American Express
case americanExpress = "Amex"
/// Discover
case discover = "Discover"
/// Diners Club
case dinersClub = "Diners Club"
/// JCB
case jcb = "JCB"
/// Maestro
case maestro = "Maestro"
/// UnionPay
case unionPay = "UnionPay"
/// RuPay
case ruPay = "RuPay"
/// Other brands not explicitly listed here
case other = "Other"
}
}
extension CardComponent.Brand {
/// Infers the card brand from the leading digits of a card number.
///
/// - Parameter number: The card number (digits only, no spaces).
/// - Returns: The detected brand, or `.other` if unrecognized.
static func detect(from number: String) -> CardComponent.Brand { // swiftlint:disable:this cyclomatic_complexity
guard !number.isEmpty else { return .other }
if number.hasPrefix("4") { return .visa }
if number.hasPrefix("34") || number.hasPrefix("37") { return .americanExpress }
if let prefix2 = Int(number.prefix(2)) {
if (51 ... 55).contains(prefix2) { return .mastercard }
if prefix2 == 36 || prefix2 == 38 { return .dinersClub }
if prefix2 == 35 { return .jcb }
}
if let prefix4 = Int(number.prefix(4)) {
if prefix4 == 6011 { return .discover }
if [5018, 5020, 5038, 6304, 6759].contains(prefix4) { return .maestro }
if (3528 ... 3589).contains(prefix4) { return .jcb }
if (2221 ... 2720).contains(prefix4) { return .mastercard }
if (3000 ... 3059).contains(prefix4) { return .dinersClub }
}
if let prefix6 = Int(number.prefix(6)) {
if (622_126 ... 622_925).contains(prefix6) { return .discover }
}
if let prefix2 = Int(number.prefix(2)) {
if prefix2 == 62 { return .unionPay }
if prefix2 == 60 { return .ruPay }
if (64 ... 65).contains(prefix2) { return .discover }
if (56 ... 58).contains(prefix2) { return .maestro }
}
return .other
}
}
extension CardComponent.Brand: CaseIterable {}
extension CardComponent.Brand: Menuable {
/// default state title for title type
static var defaultValueLocalizedName: String {
"--\(Localizations.select)--"
}
/// Provides a localized string representation of the card brand.
/// For the 'other' case, it returns a localized string for 'Other'.
var localizedName: String {
guard case .other = self else {
if case .americanExpress = self {
return "American Express"
}
return rawValue
}
return Localizations.other
}
}
extension CardComponent.Brand {
/// Gets the icon corresponding to each card brand.
var icon: SharedImageAsset {
switch self {
case .americanExpress:
SharedAsset.Icons.Cards.amex
case .visa:
SharedAsset.Icons.Cards.visa
case .mastercard:
SharedAsset.Icons.Cards.mastercard
case .discover:
SharedAsset.Icons.Cards.discover
case .dinersClub:
SharedAsset.Icons.Cards.dinersClub
case .jcb:
SharedAsset.Icons.Cards.jcb
case .maestro:
SharedAsset.Icons.Cards.maestro
case .unionPay:
SharedAsset.Icons.Cards.unionPay
case .ruPay:
SharedAsset.Icons.Cards.ruPay
case .other:
SharedAsset.Icons.card24
}
}
}
extension CardComponent.Brand {
/// Returns the digit-group block sizes to use when formatting this brand for display.
///
/// - Parameter digitCount: The number of digits in the card number being formatted.
/// Used for length-sensitive brands (Maestro, UnionPay).
/// - Returns: An array of block widths, where each element is the number of digits in one
/// space-separated group.
///
func formattingBlocks(for digitCount: Int) -> [Int] {
switch self {
case .americanExpress:
[4, 6, 5]
case .dinersClub:
[4, 6, 4]
case .maestro:
switch digitCount {
case 13: [4, 4, 5]
case 15: [4, 6, 5]
case 19: [4, 4, 4, 4, 3]
default: [4, 4, 4, 4]
}
case .unionPay:
digitCount == 19 ? [6, 13] : [4, 4, 4, 4]
default:
[4, 4, 4, 4]
}
}
/// Formats a card number string with brand-appropriate digit grouping.
///
/// Partial numbers (e.g. while the user is typing) are handled greedily: blocks are filled
/// left-to-right until the digits run out. Any digits beyond the defined blocks are appended
/// as a trailing group to prevent silent data loss.
///
/// Returns the input unchanged if it contains non-digit characters.
///
/// - Parameter number: The raw card number containing only digit characters.
/// - Returns: A display string with spaces between digit groups.
///
func formattedCardNumber(_ number: String) -> String {
guard !number.isEmpty, number.allSatisfy(\.isNumber) else { return number }
let digits = Array(number)
let blocks = formattingBlocks(for: digits.count)
var result = ""
var position = 0
for (iPos, size) in blocks.enumerated() {
guard position < digits.count else { break }
if iPos > 0 { result += " " }
let end = min(position + size, digits.count)
result += String(digits[position ..< end])
position = end
}
if position < digits.count {
result += " " + String(digits[position...])
}
return result
}
}
extension CardComponent.Month: CaseIterable {}
extension CardComponent.Month: Menuable {
/// default state title for title type
static var defaultValueLocalizedName: String {
"--\(Localizations.select)--"
}
var localizedName: String {
"\(formattedValue) - \(localized)"
}
}