@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.
Files changed (53) hide show
  1. package/README.md +84 -2
  2. package/android/build.gradle +51 -0
  3. package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.aar +0 -0
  4. package/android/maven/id/co/onepass/onepass-sdk/0.2.3/onepass-sdk-0.2.3.pom +52 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/id/co/onepass/expo/OnePassSignatureExpoView.kt +25 -0
  7. package/android/src/main/java/id/co/onepass/expo/OnepassExpoModule.kt +134 -0
  8. package/app.plugin.js +1 -0
  9. package/build/OnePassSignatureView.d.ts +19 -0
  10. package/build/OnePassSignatureView.d.ts.map +1 -0
  11. package/build/OnePassSignatureView.js +15 -0
  12. package/build/OnePassSignatureView.js.map +1 -0
  13. package/build/index.d.ts +21 -0
  14. package/build/index.d.ts.map +1 -0
  15. package/build/index.js +10 -0
  16. package/build/index.js.map +1 -0
  17. package/expo-module.config.json +9 -0
  18. package/ios/OnePassSDK/DesignSystem.swift +139 -0
  19. package/ios/OnePassSDK/IconView.swift +54 -0
  20. package/ios/OnePassSDK/IconsaxPath.swift +216 -0
  21. package/ios/OnePassSDK/Liveness/FaceAnalyzer.swift +114 -0
  22. package/ios/OnePassSDK/Liveness/FaceLandmarkMetrics.swift +28 -0
  23. package/ios/OnePassSDK/Liveness/FaceSnapshotMapper.swift +37 -0
  24. package/ios/OnePassSDK/Liveness/LivenessGate.swift +155 -0
  25. package/ios/OnePassSDK/Liveness/LivenessImagePreparer.swift +44 -0
  26. package/ios/OnePassSDK/Liveness/LivenessViewController.swift +724 -0
  27. package/ios/OnePassSDK/Ocr/CoverFitCrop.swift +37 -0
  28. package/ios/OnePassSDK/Ocr/KtpFrameOverlayView.swift +98 -0
  29. package/ios/OnePassSDK/Ocr/KtpImagePreparer.swift +90 -0
  30. package/ios/OnePassSDK/Ocr/KtpOcrViewController.swift +703 -0
  31. package/ios/OnePassSDK/Ocr/KtpPhotoInspector.swift +93 -0
  32. package/ios/OnePassSDK/Ocr/KtpQualityGate.swift +185 -0
  33. package/ios/OnePassSDK/OnePass.swift +28 -0
  34. package/ios/OnePassSDK/Resources/hindari_pemakaian.png +0 -0
  35. package/ios/OnePassSDK/Resources/inter_bold.ttf +0 -0
  36. package/ios/OnePassSDK/Resources/inter_medium.ttf +0 -0
  37. package/ios/OnePassSDK/Resources/inter_regular.ttf +0 -0
  38. package/ios/OnePassSDK/Resources/inter_semibold.ttf +0 -0
  39. package/ios/OnePassSDK/Resources/ktp_illustration.webp +0 -0
  40. package/ios/OnePassSDK/Resources/logo_smmf.png +0 -0
  41. package/ios/OnePassSDK/Resources/placeholder_head.png +0 -0
  42. package/ios/OnePassSDK/Signature/SignaturePadView.swift +144 -0
  43. package/ios/OnePassSignatureExpoView.swift +22 -0
  44. package/ios/OnepassExpo.podspec +23 -0
  45. package/ios/OnepassExpoModule.swift +77 -0
  46. package/licenses/Iconsax-MIT.txt +26 -0
  47. package/licenses/Inter-OFL-1.1.txt +93 -0
  48. package/package.json +49 -4
  49. package/plugin/src/inject.js +31 -0
  50. package/plugin/withOnePass.js +32 -0
  51. package/scripts/sync-native.js +84 -0
  52. package/src/OnePassSignatureView.tsx +34 -0
  53. 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
+ }