@simasfin/smmf-sdk 0.0.0-stage → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -2
- package/android/build.gradle +51 -0
- package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.aar +0 -0
- package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.pom +52 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/id/co/onepass/expo/OnePassSignatureExpoView.kt +25 -0
- package/android/src/main/java/id/co/onepass/expo/OnepassExpoModule.kt +134 -0
- package/app.plugin.js +1 -0
- package/build/OnePassSignatureView.d.ts +19 -0
- package/build/OnePassSignatureView.d.ts.map +1 -0
- package/build/OnePassSignatureView.js +15 -0
- package/build/OnePassSignatureView.js.map +1 -0
- package/build/index.d.ts +21 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +10 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/OnePassSDK/DesignSystem.swift +139 -0
- package/ios/OnePassSDK/IconView.swift +54 -0
- package/ios/OnePassSDK/IconsaxPath.swift +216 -0
- package/ios/OnePassSDK/Liveness/FaceAnalyzer.swift +114 -0
- package/ios/OnePassSDK/Liveness/FaceLandmarkMetrics.swift +28 -0
- package/ios/OnePassSDK/Liveness/FaceSnapshotMapper.swift +37 -0
- package/ios/OnePassSDK/Liveness/LivenessGate.swift +155 -0
- package/ios/OnePassSDK/Liveness/LivenessImagePreparer.swift +44 -0
- package/ios/OnePassSDK/Liveness/LivenessViewController.swift +724 -0
- package/ios/OnePassSDK/Ocr/CoverFitCrop.swift +37 -0
- package/ios/OnePassSDK/Ocr/KtpFrameOverlayView.swift +98 -0
- package/ios/OnePassSDK/Ocr/KtpImagePreparer.swift +90 -0
- package/ios/OnePassSDK/Ocr/KtpOcrViewController.swift +703 -0
- package/ios/OnePassSDK/Ocr/KtpPhotoInspector.swift +93 -0
- package/ios/OnePassSDK/Ocr/KtpQualityGate.swift +185 -0
- package/ios/OnePassSDK/OnePass.swift +28 -0
- package/ios/OnePassSDK/Resources/hindari_pemakaian.png +0 -0
- package/ios/OnePassSDK/Resources/inter_bold.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_medium.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_regular.ttf +0 -0
- package/ios/OnePassSDK/Resources/inter_semibold.ttf +0 -0
- package/ios/OnePassSDK/Resources/ktp_illustration.webp +0 -0
- package/ios/OnePassSDK/Resources/logo_smmf.png +0 -0
- package/ios/OnePassSDK/Resources/placeholder_head.png +0 -0
- package/ios/OnePassSDK/Signature/SignaturePadView.swift +144 -0
- package/ios/OnePassSignatureExpoView.swift +22 -0
- package/ios/OnepassExpo.podspec +23 -0
- package/ios/OnepassExpoModule.swift +77 -0
- package/licenses/Iconsax-MIT.txt +26 -0
- package/licenses/Inter-OFL-1.1.txt +93 -0
- package/package.json +49 -4
- package/plugin/src/inject.js +31 -0
- package/plugin/withOnePass.js +32 -0
- package/scripts/sync-native.js +84 -0
- package/src/OnePassSignatureView.tsx +34 -0
- package/src/index.ts +44 -0
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import UIKit
|
|
3
|
+
|
|
4
|
+
public enum KtpOcrStatusCode {
|
|
5
|
+
case success
|
|
6
|
+
case failed
|
|
7
|
+
case cancelled
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
public struct KtpOcrResult {
|
|
11
|
+
public let status: KtpOcrStatusCode
|
|
12
|
+
public let imageBase64: String?
|
|
13
|
+
public let errorMessage: String?
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
public struct KtpOcrInput {
|
|
17
|
+
public let primaryColorHex: String?
|
|
18
|
+
|
|
19
|
+
public init(primaryColorHex: String? = nil) {
|
|
20
|
+
self.primaryColorHex = primaryColorHex
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
public final class KtpOcrViewController: UIViewController {
|
|
25
|
+
|
|
26
|
+
private enum Metric {
|
|
27
|
+
static let buttonHeight: CGFloat = 38
|
|
28
|
+
static let buttonRadius: CGFloat = 8
|
|
29
|
+
static let shutterSize: CGFloat = 80
|
|
30
|
+
static let flashSize: CGFloat = 44
|
|
31
|
+
static let contentPadding: CGFloat = 20
|
|
32
|
+
static let photoWidthFraction: CGFloat = 0.85
|
|
33
|
+
static let photoRadius: CGFloat = 12
|
|
34
|
+
static let gateFailuresBeforeOverride = 2
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private let input: KtpOcrInput
|
|
38
|
+
private let completion: (KtpOcrResult) -> Void
|
|
39
|
+
|
|
40
|
+
var capturedJpeg: Data?
|
|
41
|
+
private var torchOn = false
|
|
42
|
+
private var qualityIssue: KtpQualityResult?
|
|
43
|
+
private var gateFailures = 0
|
|
44
|
+
private let inspector = KtpPhotoInspector()
|
|
45
|
+
|
|
46
|
+
private let session = AVCaptureSession()
|
|
47
|
+
private let photoOutput = AVCapturePhotoOutput()
|
|
48
|
+
private var captureDevice: AVCaptureDevice?
|
|
49
|
+
private var previewLayer: AVCaptureVideoPreviewLayer?
|
|
50
|
+
private var cameraContainer: UIView?
|
|
51
|
+
private var overlay: KtpFrameOverlayView?
|
|
52
|
+
private var flashIcon: IconView?
|
|
53
|
+
|
|
54
|
+
private var primaryColor: UIColor {
|
|
55
|
+
if let hex = input.primaryColorHex { return UIColor(hex: hex) }
|
|
56
|
+
return OnePassColor.primary600
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public init(
|
|
60
|
+
input: KtpOcrInput,
|
|
61
|
+
completion: @escaping (KtpOcrResult) -> Void
|
|
62
|
+
) {
|
|
63
|
+
self.input = input
|
|
64
|
+
self.completion = completion
|
|
65
|
+
super.init(nibName: nil, bundle: nil)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@available(*, unavailable)
|
|
69
|
+
required init?(coder: NSCoder) { fatalError("tidak dipakai") }
|
|
70
|
+
|
|
71
|
+
public override func viewDidLoad() {
|
|
72
|
+
super.viewDidLoad()
|
|
73
|
+
OnePassFont.register()
|
|
74
|
+
view.backgroundColor = .white
|
|
75
|
+
showIntroScreen()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
public override func viewDidLayoutSubviews() {
|
|
79
|
+
super.viewDidLayoutSubviews()
|
|
80
|
+
if let cameraContainer { previewLayer?.frame = cameraContainer.bounds }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public override func viewDidAppear(_ animated: Bool) {
|
|
84
|
+
super.viewDidAppear(animated)
|
|
85
|
+
NotificationCenter.default.removeObserver(self)
|
|
86
|
+
NotificationCenter.default.addObserver(
|
|
87
|
+
self,
|
|
88
|
+
selector: #selector(handleInterruptionEnded),
|
|
89
|
+
name: AVCaptureSession.interruptionEndedNotification,
|
|
90
|
+
object: session
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public override func viewWillDisappear(_ animated: Bool) {
|
|
95
|
+
super.viewWillDisappear(animated)
|
|
96
|
+
NotificationCenter.default.removeObserver(self)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@objc private func handleInterruptionEnded() {
|
|
100
|
+
guard cameraContainer != nil, !session.isRunning else { return }
|
|
101
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
102
|
+
self?.session.startRunning()
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private func finish(
|
|
107
|
+
_ status: KtpOcrStatusCode,
|
|
108
|
+
imageBase64: String? = nil,
|
|
109
|
+
message: String? = nil
|
|
110
|
+
) {
|
|
111
|
+
stopCamera()
|
|
112
|
+
completion(KtpOcrResult(status: status, imageBase64: imageBase64, errorMessage: message))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private func resetRoot() -> UIView {
|
|
116
|
+
view.subviews.forEach { $0.removeFromSuperview() }
|
|
117
|
+
let container = UIView()
|
|
118
|
+
container.translatesAutoresizingMaskIntoConstraints = false
|
|
119
|
+
view.addSubview(container)
|
|
120
|
+
NSLayoutConstraint.activate([
|
|
121
|
+
container.topAnchor.constraint(equalTo: view.topAnchor),
|
|
122
|
+
container.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
123
|
+
container.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
124
|
+
container.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
125
|
+
])
|
|
126
|
+
return container
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func showIntroScreen() {
|
|
130
|
+
stopCamera()
|
|
131
|
+
capturedJpeg = nil
|
|
132
|
+
view.backgroundColor = .white
|
|
133
|
+
let container = resetRoot()
|
|
134
|
+
|
|
135
|
+
let illustration = UIImageView(image: .onePass("ktp_illustration"))
|
|
136
|
+
illustration.contentMode = .scaleAspectFit
|
|
137
|
+
illustration.translatesAutoresizingMaskIntoConstraints = false
|
|
138
|
+
|
|
139
|
+
let title = UILabel()
|
|
140
|
+
title.text = "Foto KTP Asli Kamu"
|
|
141
|
+
title.font = OnePassFont.bold(14)
|
|
142
|
+
title.textColor = .black
|
|
143
|
+
|
|
144
|
+
let checklistTitle = UILabel()
|
|
145
|
+
checklistTitle.text = "Pastikan Foto:"
|
|
146
|
+
checklistTitle.font = OnePassFont.medium(14)
|
|
147
|
+
checklistTitle.textColor = OnePassColor.tertiary700
|
|
148
|
+
|
|
149
|
+
let checklist = UIStackView(
|
|
150
|
+
arrangedSubviews: [
|
|
151
|
+
"KTP Asli (bukan dari layar HP)",
|
|
152
|
+
"Tidak Terpotong",
|
|
153
|
+
"Terang dan Terbaca",
|
|
154
|
+
].map(checklistRow)
|
|
155
|
+
)
|
|
156
|
+
checklist.axis = .vertical
|
|
157
|
+
|
|
158
|
+
let content = UIStackView(arrangedSubviews: [title, checklistTitle, checklist, infoBanner()])
|
|
159
|
+
content.axis = .vertical
|
|
160
|
+
content.alignment = .fill
|
|
161
|
+
content.spacing = 0
|
|
162
|
+
content.setCustomSpacing(12, after: title)
|
|
163
|
+
content.setCustomSpacing(4, after: checklistTitle)
|
|
164
|
+
content.setCustomSpacing(16, after: checklist)
|
|
165
|
+
content.translatesAutoresizingMaskIntoConstraints = false
|
|
166
|
+
|
|
167
|
+
let footer = UIView()
|
|
168
|
+
footer.backgroundColor = .white
|
|
169
|
+
footer.translatesAutoresizingMaskIntoConstraints = false
|
|
170
|
+
let action = OnePassButton(
|
|
171
|
+
title: "Ambil Foto",
|
|
172
|
+
style: .solid(primaryColor),
|
|
173
|
+
cornerRadius: Metric.buttonRadius,
|
|
174
|
+
font: OnePassFont.semibold(14)
|
|
175
|
+
)
|
|
176
|
+
action.translatesAutoresizingMaskIntoConstraints = false
|
|
177
|
+
action.addTarget(self, action: #selector(handleTakePhoto), for: .touchUpInside)
|
|
178
|
+
footer.addSubview(action)
|
|
179
|
+
|
|
180
|
+
[illustration, content, footer].forEach(container.addSubview)
|
|
181
|
+
|
|
182
|
+
NSLayoutConstraint.activate([
|
|
183
|
+
illustration.topAnchor.constraint(equalTo: container.safeAreaLayoutGuide.topAnchor),
|
|
184
|
+
illustration.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
185
|
+
illustration.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
186
|
+
|
|
187
|
+
content.topAnchor.constraint(
|
|
188
|
+
equalTo: illustration.bottomAnchor,
|
|
189
|
+
constant: Metric.contentPadding
|
|
190
|
+
),
|
|
191
|
+
content.leadingAnchor.constraint(
|
|
192
|
+
equalTo: container.leadingAnchor,
|
|
193
|
+
constant: Metric.contentPadding
|
|
194
|
+
),
|
|
195
|
+
content.trailingAnchor.constraint(
|
|
196
|
+
equalTo: container.trailingAnchor,
|
|
197
|
+
constant: -Metric.contentPadding
|
|
198
|
+
),
|
|
199
|
+
|
|
200
|
+
footer.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
201
|
+
footer.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
202
|
+
footer.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
203
|
+
action.topAnchor.constraint(equalTo: footer.topAnchor, constant: 16),
|
|
204
|
+
action.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: 16),
|
|
205
|
+
action.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -16),
|
|
206
|
+
action.bottomAnchor.constraint(
|
|
207
|
+
equalTo: footer.safeAreaLayoutGuide.bottomAnchor,
|
|
208
|
+
constant: -16
|
|
209
|
+
),
|
|
210
|
+
action.heightAnchor.constraint(equalToConstant: Metric.buttonHeight),
|
|
211
|
+
])
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private func checklistRow(_ text: String) -> UIView {
|
|
215
|
+
let row = UIStackView()
|
|
216
|
+
row.axis = .horizontal
|
|
217
|
+
row.alignment = .center
|
|
218
|
+
row.spacing = 8
|
|
219
|
+
|
|
220
|
+
let check = UILabel()
|
|
221
|
+
check.text = "✓"
|
|
222
|
+
check.font = OnePassFont.regular(14)
|
|
223
|
+
check.textColor = OnePassColor.tertiary700
|
|
224
|
+
|
|
225
|
+
let label = UILabel()
|
|
226
|
+
label.text = text
|
|
227
|
+
label.font = OnePassFont.medium(12)
|
|
228
|
+
label.textColor = OnePassColor.tertiary700
|
|
229
|
+
|
|
230
|
+
row.addArrangedSubview(check)
|
|
231
|
+
row.addArrangedSubview(label)
|
|
232
|
+
return row
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private func infoBanner() -> UIView {
|
|
236
|
+
let banner = UIView()
|
|
237
|
+
banner.backgroundColor = OnePassColor.warning50
|
|
238
|
+
banner.layer.cornerRadius = 8
|
|
239
|
+
banner.layer.borderWidth = 1
|
|
240
|
+
banner.layer.borderColor = OnePassColor.warning500.cgColor
|
|
241
|
+
banner.translatesAutoresizingMaskIntoConstraints = false
|
|
242
|
+
|
|
243
|
+
let iconCircle = UIView()
|
|
244
|
+
iconCircle.backgroundColor = OnePassColor.warning500
|
|
245
|
+
iconCircle.layer.cornerRadius = 10
|
|
246
|
+
iconCircle.translatesAutoresizingMaskIntoConstraints = false
|
|
247
|
+
let icon = IconView(segments: IconsaxPath.danger, size: 12, color: .white)
|
|
248
|
+
iconCircle.addSubview(icon)
|
|
249
|
+
|
|
250
|
+
let text = NSMutableAttributedString(
|
|
251
|
+
string: "Foto KTP yang tidak sesuai atau tidak jelas akan\n",
|
|
252
|
+
attributes: [
|
|
253
|
+
.font: OnePassFont.regular(12),
|
|
254
|
+
.foregroundColor: OnePassColor.tertiary500,
|
|
255
|
+
]
|
|
256
|
+
)
|
|
257
|
+
text.append(
|
|
258
|
+
NSAttributedString(
|
|
259
|
+
string: "DITOLAK",
|
|
260
|
+
attributes: [
|
|
261
|
+
.font: OnePassFont.bold(12),
|
|
262
|
+
.foregroundColor: OnePassColor.tertiary900,
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
text.append(
|
|
267
|
+
NSAttributedString(
|
|
268
|
+
string: ", Jadi pastikan foto KTP yang kamu\nunggah terlihat jelas, tidak buram, dan mudah\ndibaca agar proses pengajuan berjalan lancar.",
|
|
269
|
+
attributes: [
|
|
270
|
+
.font: OnePassFont.regular(12),
|
|
271
|
+
.foregroundColor: OnePassColor.tertiary500,
|
|
272
|
+
]
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
let label = UILabel()
|
|
276
|
+
label.attributedText = text
|
|
277
|
+
label.numberOfLines = 0
|
|
278
|
+
label.translatesAutoresizingMaskIntoConstraints = false
|
|
279
|
+
|
|
280
|
+
banner.addSubview(iconCircle)
|
|
281
|
+
banner.addSubview(label)
|
|
282
|
+
NSLayoutConstraint.activate([
|
|
283
|
+
iconCircle.leadingAnchor.constraint(equalTo: banner.leadingAnchor, constant: 12),
|
|
284
|
+
iconCircle.topAnchor.constraint(equalTo: banner.topAnchor, constant: 8),
|
|
285
|
+
iconCircle.widthAnchor.constraint(equalToConstant: 20),
|
|
286
|
+
iconCircle.heightAnchor.constraint(equalToConstant: 20),
|
|
287
|
+
icon.centerXAnchor.constraint(equalTo: iconCircle.centerXAnchor),
|
|
288
|
+
icon.centerYAnchor.constraint(equalTo: iconCircle.centerYAnchor),
|
|
289
|
+
|
|
290
|
+
label.leadingAnchor.constraint(equalTo: iconCircle.trailingAnchor, constant: 12),
|
|
291
|
+
label.trailingAnchor.constraint(equalTo: banner.trailingAnchor, constant: -12),
|
|
292
|
+
label.topAnchor.constraint(equalTo: banner.topAnchor, constant: 8),
|
|
293
|
+
label.bottomAnchor.constraint(equalTo: banner.bottomAnchor, constant: -8),
|
|
294
|
+
])
|
|
295
|
+
return banner
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
@objc private func handleTakePhoto() {
|
|
299
|
+
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
300
|
+
case .authorized:
|
|
301
|
+
showCaptureScreen()
|
|
302
|
+
case .notDetermined:
|
|
303
|
+
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
304
|
+
DispatchQueue.main.async {
|
|
305
|
+
if granted { self?.showCaptureScreen() } else { self?.showIntroScreen() }
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
showIntroScreen()
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private func instructionStack() -> UIStackView {
|
|
314
|
+
let title = UILabel()
|
|
315
|
+
title.text = "Posisikan KTP anda di dalam Kotak"
|
|
316
|
+
title.font = OnePassFont.medium(16)
|
|
317
|
+
title.textColor = OnePassColor.primary25
|
|
318
|
+
title.textAlignment = .center
|
|
319
|
+
title.numberOfLines = 0
|
|
320
|
+
|
|
321
|
+
let hint = UILabel()
|
|
322
|
+
hint.text = "Pastikan KTP kamu bisa dibaca dengan jelas"
|
|
323
|
+
hint.font = OnePassFont.regular(10)
|
|
324
|
+
hint.textColor = OnePassColor.tertiary50
|
|
325
|
+
hint.textAlignment = .center
|
|
326
|
+
hint.numberOfLines = 0
|
|
327
|
+
|
|
328
|
+
let stack = UIStackView(arrangedSubviews: [title, hint])
|
|
329
|
+
stack.axis = .vertical
|
|
330
|
+
stack.spacing = 4
|
|
331
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
332
|
+
return stack
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
func showCaptureScreen() {
|
|
336
|
+
capturedJpeg = nil
|
|
337
|
+
qualityIssue = nil
|
|
338
|
+
view.backgroundColor = OnePassColor.baseBlack
|
|
339
|
+
let container = resetRoot()
|
|
340
|
+
|
|
341
|
+
let cameraArea = UIView()
|
|
342
|
+
cameraArea.backgroundColor = .black
|
|
343
|
+
cameraArea.translatesAutoresizingMaskIntoConstraints = false
|
|
344
|
+
cameraContainer = cameraArea
|
|
345
|
+
|
|
346
|
+
let frameOverlay = KtpFrameOverlayView()
|
|
347
|
+
frameOverlay.translatesAutoresizingMaskIntoConstraints = false
|
|
348
|
+
overlay = frameOverlay
|
|
349
|
+
cameraArea.addSubview(frameOverlay)
|
|
350
|
+
|
|
351
|
+
let instructions = instructionStack()
|
|
352
|
+
cameraArea.addSubview(instructions)
|
|
353
|
+
|
|
354
|
+
let closeButton = UIButton(type: .system)
|
|
355
|
+
closeButton.translatesAutoresizingMaskIntoConstraints = false
|
|
356
|
+
closeButton.addTarget(self, action: #selector(handleClose), for: .touchUpInside)
|
|
357
|
+
closeButton.accessibilityLabel = "Tutup"
|
|
358
|
+
let closeIcon = IconView(segments: IconsaxPath.closeSquare, size: 32, color: .white)
|
|
359
|
+
closeIcon.isUserInteractionEnabled = false
|
|
360
|
+
closeButton.addSubview(closeIcon)
|
|
361
|
+
cameraArea.addSubview(closeButton)
|
|
362
|
+
|
|
363
|
+
let controls = UIView()
|
|
364
|
+
controls.backgroundColor = .white
|
|
365
|
+
controls.translatesAutoresizingMaskIntoConstraints = false
|
|
366
|
+
|
|
367
|
+
let flashButton = UIButton(type: .system)
|
|
368
|
+
flashButton.backgroundColor = OnePassColor.neutral100
|
|
369
|
+
flashButton.layer.cornerRadius = Metric.flashSize / 2
|
|
370
|
+
flashButton.translatesAutoresizingMaskIntoConstraints = false
|
|
371
|
+
flashButton.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)
|
|
372
|
+
flashButton.accessibilityLabel = "Nyalakan atau matikan lampu kamera"
|
|
373
|
+
let flash = IconView(segments: IconsaxPath.flashSlash, size: 20, color: OnePassColor.tertiary500)
|
|
374
|
+
flash.isUserInteractionEnabled = false
|
|
375
|
+
flashButton.addSubview(flash)
|
|
376
|
+
flashIcon = flash
|
|
377
|
+
|
|
378
|
+
let shutter = UIButton(type: .system)
|
|
379
|
+
shutter.backgroundColor = OnePassColor.primary500
|
|
380
|
+
shutter.layer.cornerRadius = Metric.shutterSize / 2
|
|
381
|
+
shutter.translatesAutoresizingMaskIntoConstraints = false
|
|
382
|
+
shutter.addTarget(self, action: #selector(captureKtp), for: .touchUpInside)
|
|
383
|
+
shutter.accessibilityLabel = "Ambil foto KTP"
|
|
384
|
+
let cameraIcon = IconView(segments: IconsaxPath.camera, size: 28, color: .white)
|
|
385
|
+
cameraIcon.isUserInteractionEnabled = false
|
|
386
|
+
shutter.addSubview(cameraIcon)
|
|
387
|
+
|
|
388
|
+
let spacer = UIView()
|
|
389
|
+
spacer.translatesAutoresizingMaskIntoConstraints = false
|
|
390
|
+
|
|
391
|
+
[flashButton, shutter, spacer].forEach(controls.addSubview)
|
|
392
|
+
[cameraArea, controls].forEach(container.addSubview)
|
|
393
|
+
|
|
394
|
+
NSLayoutConstraint.activate([
|
|
395
|
+
cameraArea.topAnchor.constraint(equalTo: container.topAnchor),
|
|
396
|
+
cameraArea.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
397
|
+
cameraArea.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
398
|
+
cameraArea.bottomAnchor.constraint(equalTo: controls.topAnchor),
|
|
399
|
+
|
|
400
|
+
frameOverlay.topAnchor.constraint(equalTo: cameraArea.topAnchor),
|
|
401
|
+
frameOverlay.leadingAnchor.constraint(equalTo: cameraArea.leadingAnchor),
|
|
402
|
+
frameOverlay.trailingAnchor.constraint(equalTo: cameraArea.trailingAnchor),
|
|
403
|
+
frameOverlay.bottomAnchor.constraint(equalTo: cameraArea.bottomAnchor),
|
|
404
|
+
|
|
405
|
+
instructions.topAnchor.constraint(equalTo: cameraArea.topAnchor, constant: 120),
|
|
406
|
+
instructions.leadingAnchor.constraint(equalTo: cameraArea.leadingAnchor, constant: 16),
|
|
407
|
+
instructions.trailingAnchor.constraint(equalTo: cameraArea.trailingAnchor, constant: -16),
|
|
408
|
+
|
|
409
|
+
closeButton.topAnchor.constraint(equalTo: cameraArea.topAnchor, constant: 60),
|
|
410
|
+
closeButton.leadingAnchor.constraint(equalTo: cameraArea.leadingAnchor, constant: 20),
|
|
411
|
+
closeButton.widthAnchor.constraint(equalToConstant: 32),
|
|
412
|
+
closeButton.heightAnchor.constraint(equalToConstant: 32),
|
|
413
|
+
closeIcon.centerXAnchor.constraint(equalTo: closeButton.centerXAnchor),
|
|
414
|
+
closeIcon.centerYAnchor.constraint(equalTo: closeButton.centerYAnchor),
|
|
415
|
+
|
|
416
|
+
controls.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
417
|
+
controls.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
418
|
+
controls.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
419
|
+
|
|
420
|
+
flashButton.leadingAnchor.constraint(equalTo: controls.leadingAnchor, constant: 32),
|
|
421
|
+
flashButton.widthAnchor.constraint(equalToConstant: Metric.flashSize),
|
|
422
|
+
flashButton.heightAnchor.constraint(equalToConstant: Metric.flashSize),
|
|
423
|
+
flashButton.centerYAnchor.constraint(equalTo: shutter.centerYAnchor),
|
|
424
|
+
flash.centerXAnchor.constraint(equalTo: flashButton.centerXAnchor),
|
|
425
|
+
flash.centerYAnchor.constraint(equalTo: flashButton.centerYAnchor),
|
|
426
|
+
|
|
427
|
+
shutter.centerXAnchor.constraint(equalTo: controls.centerXAnchor),
|
|
428
|
+
shutter.topAnchor.constraint(equalTo: controls.topAnchor, constant: 40),
|
|
429
|
+
shutter.widthAnchor.constraint(equalToConstant: Metric.shutterSize),
|
|
430
|
+
shutter.heightAnchor.constraint(equalToConstant: Metric.shutterSize),
|
|
431
|
+
shutter.bottomAnchor.constraint(
|
|
432
|
+
equalTo: controls.safeAreaLayoutGuide.bottomAnchor,
|
|
433
|
+
constant: -40
|
|
434
|
+
),
|
|
435
|
+
cameraIcon.centerXAnchor.constraint(equalTo: shutter.centerXAnchor),
|
|
436
|
+
cameraIcon.centerYAnchor.constraint(equalTo: shutter.centerYAnchor),
|
|
437
|
+
|
|
438
|
+
spacer.trailingAnchor.constraint(equalTo: controls.trailingAnchor, constant: -32),
|
|
439
|
+
spacer.centerYAnchor.constraint(equalTo: shutter.centerYAnchor),
|
|
440
|
+
spacer.widthAnchor.constraint(equalToConstant: Metric.flashSize),
|
|
441
|
+
spacer.heightAnchor.constraint(equalToConstant: Metric.flashSize),
|
|
442
|
+
])
|
|
443
|
+
|
|
444
|
+
startCamera(in: cameraArea)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
@objc private func handleClose() {
|
|
448
|
+
finish(.cancelled)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private func startCamera(in container: UIView) {
|
|
452
|
+
guard
|
|
453
|
+
let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
|
|
454
|
+
let deviceInput = try? AVCaptureDeviceInput(device: device)
|
|
455
|
+
else {
|
|
456
|
+
finish(.failed, message: "Kamera belakang tidak tersedia di perangkat ini")
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
captureDevice = device
|
|
460
|
+
session.beginConfiguration()
|
|
461
|
+
session.sessionPreset = .photo
|
|
462
|
+
session.inputs.forEach(session.removeInput)
|
|
463
|
+
session.outputs.forEach(session.removeOutput)
|
|
464
|
+
if session.canAddInput(deviceInput) { session.addInput(deviceInput) }
|
|
465
|
+
if session.canAddOutput(photoOutput) { session.addOutput(photoOutput) }
|
|
466
|
+
session.commitConfiguration()
|
|
467
|
+
|
|
468
|
+
let layer = AVCaptureVideoPreviewLayer(session: session)
|
|
469
|
+
layer.videoGravity = .resizeAspectFill
|
|
470
|
+
layer.frame = container.bounds
|
|
471
|
+
container.layer.insertSublayer(layer, at: 0)
|
|
472
|
+
previewLayer = layer
|
|
473
|
+
|
|
474
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
475
|
+
self?.session.startRunning()
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private func stopCamera() {
|
|
480
|
+
if session.isRunning { session.stopRunning() }
|
|
481
|
+
if torchOn { setTorch(false) }
|
|
482
|
+
previewLayer?.removeFromSuperlayer()
|
|
483
|
+
previewLayer = nil
|
|
484
|
+
cameraContainer = nil
|
|
485
|
+
overlay = nil
|
|
486
|
+
flashIcon = nil
|
|
487
|
+
captureDevice = nil
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
@objc private func toggleTorch() {
|
|
491
|
+
setTorch(!torchOn)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private func setTorch(_ on: Bool) {
|
|
495
|
+
guard let device = captureDevice, device.hasTorch else { return }
|
|
496
|
+
try? device.lockForConfiguration()
|
|
497
|
+
device.torchMode = on ? .on : .off
|
|
498
|
+
device.unlockForConfiguration()
|
|
499
|
+
torchOn = on
|
|
500
|
+
flashIcon?.color = on ? OnePassColor.warning400 : OnePassColor.tertiary500
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
@objc private func captureKtp() {
|
|
504
|
+
photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
func showPreviewScreen(image: UIImage?, errorMessage: String?, inspecting: Bool = false) {
|
|
508
|
+
view.backgroundColor = OnePassColor.baseBlack
|
|
509
|
+
let container = resetRoot()
|
|
510
|
+
|
|
511
|
+
let photoArea = UIView()
|
|
512
|
+
photoArea.backgroundColor = OnePassColor.baseBlack
|
|
513
|
+
photoArea.translatesAutoresizingMaskIntoConstraints = false
|
|
514
|
+
|
|
515
|
+
if let image {
|
|
516
|
+
let photo = UIImageView(image: image)
|
|
517
|
+
photo.contentMode = .scaleAspectFill
|
|
518
|
+
photo.clipsToBounds = true
|
|
519
|
+
photo.layer.cornerRadius = Metric.photoRadius
|
|
520
|
+
photo.translatesAutoresizingMaskIntoConstraints = false
|
|
521
|
+
photoArea.addSubview(photo)
|
|
522
|
+
NSLayoutConstraint.activate([
|
|
523
|
+
photo.centerXAnchor.constraint(equalTo: photoArea.centerXAnchor),
|
|
524
|
+
photo.centerYAnchor.constraint(equalTo: photoArea.centerYAnchor),
|
|
525
|
+
photo.widthAnchor.constraint(
|
|
526
|
+
equalTo: photoArea.widthAnchor,
|
|
527
|
+
multiplier: Metric.photoWidthFraction
|
|
528
|
+
),
|
|
529
|
+
photo.heightAnchor.constraint(
|
|
530
|
+
equalTo: photo.widthAnchor,
|
|
531
|
+
multiplier: 1 / KtpFrameOverlayView.frameRatio
|
|
532
|
+
),
|
|
533
|
+
])
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
let instructions = instructionStack()
|
|
537
|
+
photoArea.addSubview(instructions)
|
|
538
|
+
|
|
539
|
+
let review = UIView()
|
|
540
|
+
review.backgroundColor = .white
|
|
541
|
+
review.translatesAutoresizingMaskIntoConstraints = false
|
|
542
|
+
|
|
543
|
+
let stack = UIStackView()
|
|
544
|
+
stack.axis = .vertical
|
|
545
|
+
stack.spacing = 12
|
|
546
|
+
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
547
|
+
|
|
548
|
+
if inspecting {
|
|
549
|
+
let row = UIStackView()
|
|
550
|
+
row.axis = .horizontal
|
|
551
|
+
row.spacing = 8
|
|
552
|
+
row.alignment = .center
|
|
553
|
+
let spinner = UIActivityIndicatorView(style: .medium)
|
|
554
|
+
spinner.color = OnePassColor.primary500
|
|
555
|
+
spinner.startAnimating()
|
|
556
|
+
let label = UILabel()
|
|
557
|
+
label.text = "Sedang memproses foto KTP..."
|
|
558
|
+
label.font = OnePassFont.medium(12)
|
|
559
|
+
label.textColor = OnePassColor.tertiary700
|
|
560
|
+
row.addArrangedSubview(spinner)
|
|
561
|
+
row.addArrangedSubview(label)
|
|
562
|
+
let center = UIStackView(arrangedSubviews: [row])
|
|
563
|
+
center.alignment = .center
|
|
564
|
+
stack.addArrangedSubview(center)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if let errorMessage {
|
|
568
|
+
let banner = UILabel()
|
|
569
|
+
banner.text = errorMessage
|
|
570
|
+
banner.font = OnePassFont.regular(14)
|
|
571
|
+
banner.textColor = OnePassColor.error600
|
|
572
|
+
banner.backgroundColor = OnePassColor.error50
|
|
573
|
+
banner.numberOfLines = 0
|
|
574
|
+
banner.textAlignment = .center
|
|
575
|
+
banner.layer.cornerRadius = 6
|
|
576
|
+
banner.layer.masksToBounds = true
|
|
577
|
+
banner.heightAnchor.constraint(greaterThanOrEqualToConstant: 34).isActive = true
|
|
578
|
+
stack.addArrangedSubview(banner)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
let retake = OnePassButton(
|
|
582
|
+
title: "Ambil Ulang",
|
|
583
|
+
style: .outline(border: OnePassColor.primary500, text: OnePassColor.primary500),
|
|
584
|
+
cornerRadius: Metric.buttonRadius,
|
|
585
|
+
font: OnePassFont.semibold(14)
|
|
586
|
+
)
|
|
587
|
+
retake.addTarget(self, action: #selector(handleRetake), for: .touchUpInside)
|
|
588
|
+
|
|
589
|
+
let blockedByGate = qualityIssue != nil && gateFailures < Metric.gateFailuresBeforeOverride
|
|
590
|
+
let save = OnePassButton(
|
|
591
|
+
title: "Simpan",
|
|
592
|
+
style: .solid(primaryColor),
|
|
593
|
+
cornerRadius: Metric.buttonRadius,
|
|
594
|
+
font: OnePassFont.semibold(14)
|
|
595
|
+
)
|
|
596
|
+
save.isEnabled = !blockedByGate
|
|
597
|
+
save.alpha = blockedByGate ? 0.6 : 1
|
|
598
|
+
save.addTarget(self, action: #selector(handleSave), for: .touchUpInside)
|
|
599
|
+
|
|
600
|
+
let actions = UIStackView(arrangedSubviews: [retake, save])
|
|
601
|
+
actions.axis = .horizontal
|
|
602
|
+
actions.spacing = 12
|
|
603
|
+
actions.distribution = .fillEqually
|
|
604
|
+
stack.addArrangedSubview(actions)
|
|
605
|
+
review.addSubview(stack)
|
|
606
|
+
|
|
607
|
+
[photoArea, review].forEach(container.addSubview)
|
|
608
|
+
|
|
609
|
+
NSLayoutConstraint.activate([
|
|
610
|
+
photoArea.topAnchor.constraint(equalTo: container.topAnchor),
|
|
611
|
+
photoArea.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
612
|
+
photoArea.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
613
|
+
photoArea.bottomAnchor.constraint(equalTo: review.topAnchor),
|
|
614
|
+
|
|
615
|
+
instructions.topAnchor.constraint(equalTo: photoArea.topAnchor, constant: 120),
|
|
616
|
+
instructions.leadingAnchor.constraint(equalTo: photoArea.leadingAnchor, constant: 16),
|
|
617
|
+
instructions.trailingAnchor.constraint(equalTo: photoArea.trailingAnchor, constant: -16),
|
|
618
|
+
|
|
619
|
+
review.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
620
|
+
review.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
621
|
+
review.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
622
|
+
|
|
623
|
+
stack.topAnchor.constraint(equalTo: review.topAnchor, constant: Metric.contentPadding),
|
|
624
|
+
stack.leadingAnchor.constraint(
|
|
625
|
+
equalTo: review.leadingAnchor,
|
|
626
|
+
constant: Metric.contentPadding
|
|
627
|
+
),
|
|
628
|
+
stack.trailingAnchor.constraint(
|
|
629
|
+
equalTo: review.trailingAnchor,
|
|
630
|
+
constant: -Metric.contentPadding
|
|
631
|
+
),
|
|
632
|
+
stack.bottomAnchor.constraint(
|
|
633
|
+
equalTo: review.safeAreaLayoutGuide.bottomAnchor,
|
|
634
|
+
constant: -Metric.contentPadding
|
|
635
|
+
),
|
|
636
|
+
actions.heightAnchor.constraint(equalToConstant: Metric.buttonHeight),
|
|
637
|
+
])
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private func inspectThenPreview(_ framed: UIImage) {
|
|
641
|
+
showPreviewScreen(image: nil, errorMessage: nil, inspecting: true)
|
|
642
|
+
inspector.inspect(image: framed) { [weak self] inspection in
|
|
643
|
+
DispatchQueue.main.async {
|
|
644
|
+
guard let self else { return }
|
|
645
|
+
let finalImage = KtpImagePreparer.cropToDetectedBounds(
|
|
646
|
+
image: framed,
|
|
647
|
+
bounds: inspection.textBounds
|
|
648
|
+
)
|
|
649
|
+
self.capturedJpeg = finalImage.jpegData(
|
|
650
|
+
compressionQuality: KtpImagePreparer.jpegQuality
|
|
651
|
+
)
|
|
652
|
+
self.qualityIssue = inspection.quality.passed ? nil : inspection.quality
|
|
653
|
+
if self.qualityIssue != nil { self.gateFailures += 1 }
|
|
654
|
+
self.stopCamera()
|
|
655
|
+
self.showPreviewScreen(
|
|
656
|
+
image: finalImage,
|
|
657
|
+
errorMessage: self.qualityIssue?.message
|
|
658
|
+
)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
@objc private func handleRetake() {
|
|
664
|
+
showCaptureScreen()
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
@objc func handleSave() {
|
|
668
|
+
guard let jpeg = capturedJpeg else { return }
|
|
669
|
+
finish(.success, imageBase64: jpeg.base64EncodedString())
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
extension KtpOcrViewController: AVCapturePhotoCaptureDelegate {
|
|
674
|
+
public func photoOutput(
|
|
675
|
+
_ output: AVCapturePhotoOutput,
|
|
676
|
+
didFinishProcessingPhoto photo: AVCapturePhoto,
|
|
677
|
+
error: Error?
|
|
678
|
+
) {
|
|
679
|
+
guard
|
|
680
|
+
error == nil,
|
|
681
|
+
let data = photo.fileDataRepresentation(),
|
|
682
|
+
let image = UIImage(data: data)
|
|
683
|
+
else {
|
|
684
|
+
DispatchQueue.main.async { [weak self] in
|
|
685
|
+
self?.showPreviewScreen(image: nil, errorMessage: "Gagal mengambil foto, silakan coba lagi.")
|
|
686
|
+
}
|
|
687
|
+
return
|
|
688
|
+
}
|
|
689
|
+
let displaySize = cameraContainer?.bounds.size ?? .zero
|
|
690
|
+
let frame = overlay?.frameRect ?? .zero
|
|
691
|
+
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
692
|
+
let framed = KtpImagePreparer.cropToFrame(
|
|
693
|
+
image: image,
|
|
694
|
+
displaySize: displaySize,
|
|
695
|
+
frame: frame
|
|
696
|
+
)
|
|
697
|
+
DispatchQueue.main.async {
|
|
698
|
+
guard let self else { return }
|
|
699
|
+
self.inspectThenPreview(framed.image)
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|