@myazahq/kyc-sdk-react-native 2.0.2 → 2.2.0
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 +152 -8
- package/android/build.gradle +6 -0
- package/android/src/main/AndroidManifest.xml +14 -4
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaEmrtd.kt +358 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +88 -1
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaRectDetector.kt +34 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizer.kt +118 -0
- package/android/src/main/java/com/margelo/nitro/myazakyc/Jp2Decoder.kt +117 -0
- package/app.plugin.js +102 -13
- package/ios/HybridMyazaEmrtd.swift +447 -0
- package/ios/HybridMyazaFaceDetector.swift +104 -2
- package/ios/HybridMyazaRectDetector.swift +103 -0
- package/ios/HybridMyazaTextRecognizer.swift +111 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.cmake +87 -0
- package/nitrogen/generated/android/KycSdkReactNative+autolinking.gradle +27 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.cpp +102 -0
- package/nitrogen/generated/android/KycSdkReactNativeOnLoad.hpp +34 -0
- package/nitrogen/generated/android/c++/JDetectedRect.hpp +81 -0
- package/nitrogen/generated/android/c++/JEmrtdApduResponse.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmrtdTagInfo.hpp +61 -0
- package/nitrogen/generated/android/c++/JFaceResult.hpp +113 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.cpp +176 -0
- package/nitrogen/generated/android/c++/JHybridMyazaEmrtdSpec.hpp +75 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.cpp +60 -0
- package/nitrogen/generated/android/c++/JHybridMyazaRectDetectorSpec.hpp +63 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.cpp +80 -0
- package/nitrogen/generated/android/c++/JHybridMyazaTextRecognizerSpec.hpp +64 -0
- package/nitrogen/generated/android/c++/JTextResult.hpp +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/DetectedRect.kt +81 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdApduResponse.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/EmrtdTagInfo.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/FaceResult.kt +121 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaEmrtdSpec.kt +103 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaRectDetectorSpec.kt +55 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaTextRecognizerSpec.kt +60 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/KycSdkReactNativeOnLoad.kt +35 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/TextResult.kt +51 -0
- package/nitrogen/generated/ios/KycSdkReactNative+autolinking.rb +62 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +143 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +423 -0
- package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Umbrella.hpp +81 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.mm +57 -0
- package/nitrogen/generated/ios/KycSdkReactNativeAutolinking.swift +62 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaEmrtdSpecSwift.hpp +184 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaRectDetectorSpecSwift.hpp +87 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridMyazaTextRecognizerSpecSwift.hpp +98 -0
- package/nitrogen/generated/ios/swift/DetectedRect.swift +59 -0
- package/nitrogen/generated/ios/swift/EmrtdApduResponse.swift +34 -0
- package/nitrogen/generated/ios/swift/EmrtdTagInfo.swift +34 -0
- package/nitrogen/generated/ios/swift/FaceResult.swift +99 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdApduResponse.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_EmrtdTagInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_TextResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec.swift +67 -0
- package/nitrogen/generated/ios/swift/HybridMyazaEmrtdSpec_cxx.swift +317 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec.swift +56 -0
- package/nitrogen/generated/ios/swift/HybridMyazaRectDetectorSpec_cxx.swift +143 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec.swift +57 -0
- package/nitrogen/generated/ios/swift/HybridMyazaTextRecognizerSpec_cxx.swift +162 -0
- package/nitrogen/generated/ios/swift/TextResult.swift +35 -0
- package/nitrogen/generated/shared/c++/DetectedRect.hpp +107 -0
- package/nitrogen/generated/shared/c++/EmrtdApduResponse.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmrtdTagInfo.hpp +87 -0
- package/nitrogen/generated/shared/c++/FaceResult.hpp +139 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.cpp +33 -0
- package/nitrogen/generated/shared/c++/HybridMyazaEmrtdSpec.hpp +80 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.cpp +21 -0
- package/nitrogen/generated/shared/c++/HybridMyazaRectDetectorSpec.hpp +67 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.cpp +22 -0
- package/nitrogen/generated/shared/c++/HybridMyazaTextRecognizerSpec.hpp +70 -0
- package/nitrogen/generated/shared/c++/TextResult.hpp +84 -0
- package/package.json +16 -4
- package/src/MyazaKYC.tsx +115 -22
- package/src/capture/documentIdentity.ts +153 -0
- package/src/capture/documentSignals.ts +177 -0
- package/src/capture/documentTextGate.ts +171 -0
- package/src/capture/hints.ts +57 -0
- package/src/capture/index.ts +24 -0
- package/src/capture/rectDetector.ts +84 -0
- package/src/capture/rectGate.ts +91 -0
- package/src/capture/useAutoCapture.ts +198 -0
- package/src/components/CameraViewfinder.tsx +226 -70
- package/src/components/ContactCodeEntry.tsx +139 -0
- package/src/components/CountryField.tsx +87 -0
- package/src/components/CountryFlag.tsx +15 -15
- package/src/components/DialCodePicker.tsx +186 -0
- package/src/components/DocumentCropper.tsx +1 -1
- package/src/components/DocumentReview.tsx +142 -0
- package/src/components/DocumentReviewSide.tsx +135 -0
- package/src/components/DocumentReviewZoom.tsx +119 -0
- package/src/components/ExpiryCountdown.tsx +52 -0
- package/src/components/FlashOverlay.tsx +156 -0
- package/src/components/Icon.tsx +70 -1
- package/src/components/KycFlow.tsx +66 -94
- package/src/components/KycSheet.tsx +148 -18
- package/src/components/MediaSourceSheet.tsx +163 -0
- package/src/components/MyazaButton.tsx +14 -2
- package/src/components/MyazaDateField.tsx +240 -0
- package/src/components/MyazaInput.tsx +73 -26
- package/src/components/MyazaSelect.tsx +283 -0
- package/src/components/MyazaWordmark.tsx +33 -0
- package/src/components/OptionRow.tsx +132 -0
- package/src/components/PhoneNumberInput.tsx +149 -0
- package/src/components/PoweredBy.tsx +89 -0
- package/src/components/ReadyPrimer.tsx +149 -0
- package/src/components/StepView.tsx +91 -0
- package/src/components/Typography.tsx +18 -3
- package/src/components/VerifiedNotice.tsx +38 -0
- package/src/components/WhatsAppIcon.tsx +29 -0
- package/src/components/WorkflowGate.tsx +128 -0
- package/src/components/brand-font.ts +113 -0
- package/src/components/flashHoleGeometry.ts +35 -0
- package/src/components/flow/FatalConfigError.tsx +56 -0
- package/src/components/fonts.ts +2 -19
- package/src/components/readyPrimerContent.ts +52 -0
- package/src/components/runtime.tsx +38 -43
- package/src/components/stepHeaderMeta.tsx +87 -0
- package/src/components/theme-provider.tsx +89 -0
- package/src/components/useWorkflowMount.ts +116 -0
- package/src/components/viewfinder/CardGuide.tsx +60 -0
- package/src/components/viewfinder/DocumentGhost.tsx +149 -0
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +172 -0
- package/src/components/viewfinder/ImmersiveControls.tsx +127 -0
- package/src/components/viewfinder/ImmersiveGuide.tsx +174 -0
- package/src/components/viewfinder/ImmersiveOverlay.tsx +79 -0
- package/src/components/viewfinder/ViewfinderControls.tsx +154 -0
- package/src/config/brand.ts +97 -0
- package/src/config/business.ts +179 -0
- package/src/config/businessSteps.ts +105 -0
- package/src/config/contact.ts +159 -0
- package/src/config/countryNames.g.ts +254 -0
- package/src/config/currencyFlags.ts +144 -0
- package/src/config/font-resolve.ts +78 -0
- package/src/config/idTypes.ts +96 -1
- package/src/config/keyPeople.ts +162 -0
- package/src/config/phone.ts +39 -0
- package/src/config/proofOfAddress.ts +75 -0
- package/src/config/questionnaire.ts +153 -0
- package/src/config/regions.ts +118 -0
- package/src/config/registrationHint.ts +87 -0
- package/src/config/stepOrder.ts +118 -0
- package/src/config/theme.ts +38 -2
- package/src/config/workflowMerge.ts +124 -0
- package/src/emrtd/bac.ts +128 -0
- package/src/emrtd/bytes.ts +127 -0
- package/src/emrtd/crypto.ts +163 -0
- package/src/emrtd/der.ts +192 -0
- package/src/emrtd/dg2.ts +111 -0
- package/src/emrtd/files.ts +215 -0
- package/src/emrtd/index.ts +30 -0
- package/src/emrtd/mrzKey.ts +54 -0
- package/src/emrtd/native.ts +79 -0
- package/src/emrtd/optionalRead.ts +42 -0
- package/src/emrtd/read.ts +224 -0
- package/src/emrtd/retry.ts +98 -0
- package/src/emrtd/secureMessaging.ts +178 -0
- package/src/emrtd/session.ts +200 -0
- package/src/emrtd/stages.ts +122 -0
- package/src/index.ts +110 -0
- package/src/lib/calendar.ts +71 -0
- package/src/lib/use-keyboard-inset.ts +34 -0
- package/src/liveness/challengeManager.ts +27 -1
- package/src/liveness/faceContinuity.ts +153 -0
- package/src/liveness/flashDetector.ts +145 -0
- package/src/liveness/flashReadyGate.ts +106 -0
- package/src/liveness/flashRunner.ts +118 -0
- package/src/liveness/integritySignals.ts +60 -0
- package/src/liveness/livenessState.ts +139 -0
- package/src/liveness/speech.ts +19 -1
- package/src/liveness/types.ts +48 -1
- package/src/liveness/useFlashPhase.ts +78 -0
- package/src/liveness/useLiveness.ts +260 -75
- package/src/liveness/visionCameraFaceDetector.ts +14 -0
- package/src/mrz/extract.ts +101 -0
- package/src/mrz/parse.ts +178 -0
- package/src/mrz/textRecognizer.ts +149 -0
- package/src/screens/ApplicantRoleStep.tsx +322 -0
- package/src/screens/BusinessDetailsStep.tsx +212 -0
- package/src/screens/BusinessDocumentSlot.tsx +167 -0
- package/src/screens/BusinessDocumentsStep.tsx +213 -0
- package/src/screens/BusinessKeyPeopleStep.tsx +207 -0
- package/src/screens/CompanyInfoFields.tsx +83 -0
- package/src/screens/ConsentStep.tsx +68 -68
- package/src/screens/ContactChannelChoice.tsx +133 -0
- package/src/screens/ContactCodeStep.tsx +130 -0
- package/src/screens/ContactDestinationField.tsx +70 -0
- package/src/screens/ContactFooterNote.tsx +34 -0
- package/src/screens/ContactVerificationStep.tsx +199 -0
- package/src/screens/ContactVerifiedPanel.tsx +36 -0
- package/src/screens/CountrySelectStep.tsx +210 -0
- package/src/screens/DocumentCaptureStep.tsx +156 -132
- package/src/screens/IdInputStep.tsx +13 -5
- package/src/screens/IdTypeStep.tsx +74 -33
- package/src/screens/KeyPeopleInviteLinks.tsx +239 -0
- package/src/screens/KeyPersonCard.tsx +119 -0
- package/src/screens/KeyPersonForm.tsx +148 -0
- package/src/screens/KeyPersonSheet.tsx +184 -0
- package/src/screens/LivenessStep.tsx +155 -247
- package/src/screens/MrzScanView.tsx +173 -0
- package/src/screens/NfcStep.tsx +290 -0
- package/src/screens/ProofOfAddressStep.tsx +238 -0
- package/src/screens/QuestionnaireField.tsx +219 -0
- package/src/screens/QuestionnaireMoneyField.tsx +134 -0
- package/src/screens/QuestionnaireStep.tsx +76 -0
- package/src/screens/SubmittedStep.tsx +5 -0
- package/src/screens/consent/model.ts +109 -0
- package/src/screens/document/CameraPhase.tsx +150 -0
- package/src/screens/document/RequiredPill.tsx +60 -0
- package/src/screens/liveness/LightingBanner.tsx +50 -0
- package/src/screens/liveness/LivenessOutcome.tsx +109 -0
- package/src/screens/liveness/ProgressDots.tsx +67 -0
- package/src/screens/liveness/SelfiePreview.tsx +39 -0
- package/src/screens/liveness/constants.ts +31 -0
- package/src/screens/liveness/index.ts +8 -0
- package/src/screens/liveness/resolveGuidance.ts +27 -0
- package/src/screens/liveness/useSelfieUpload.ts +107 -0
- package/src/screens/nfc/NfcMrzPrompt.tsx +40 -0
- package/src/screens/nfc/NfcReadBody.tsx +63 -0
- package/src/screens/nfc/NfcReadProgress.tsx +96 -0
- package/src/screens/nfc/NfcScanIllustration.tsx +244 -0
- package/src/screens/nfc/NfcScannedSummary.tsx +74 -0
- package/src/screens/nfc/NfcSuccessPanel.tsx +162 -0
- package/src/screens/nfc/availability.ts +73 -0
- package/src/screens/nfc/readErrorMessage.ts +93 -0
- package/src/screens/nfc/useChipPortrait.ts +58 -0
- package/src/screens/nfc/useNfcAvailability.ts +82 -0
- package/src/screens/useFlashHole.ts +105 -0
- package/src/screens/useFlashSequence.ts +96 -0
- package/src/screens/usePoaAttach.ts +121 -0
- package/src/services/api-types.ts +164 -0
- package/src/services/api-verify-types.ts +99 -0
- package/src/services/api.ts +72 -102
- package/src/services/cardCrop.ts +25 -10
- package/src/services/contactErrors.ts +43 -0
- package/src/services/deviceMetadata.ts +1 -1
- package/src/services/documentPicker.ts +20 -0
- package/src/services/fingerprint-sources.ts +129 -0
- package/src/services/fingerprint.ts +101 -0
- package/src/services/haptics.ts +38 -0
- package/src/services/mediaCompress.ts +7 -2
- package/src/services/resolveUrl.ts +2 -2
- package/src/services/workflowGate.ts +138 -0
- package/src/specs/MyazaEmrtd.nitro.ts +114 -0
- package/src/specs/MyazaFaceDetector.nitro.ts +10 -0
- package/src/specs/MyazaRectDetector.nitro.ts +50 -0
- package/src/specs/MyazaTextRecognizer.nitro.ts +48 -0
- package/src/store/derive.ts +225 -0
- package/src/store/kycStore.ts +250 -149
- package/src/store/state.ts +253 -0
- package/src/store/submit.ts +101 -0
- package/src/store/submitApplicant.ts +81 -0
- package/src/types/appearance.ts +113 -0
- package/src/types/business.ts +131 -0
- package/src/types/config.ts +150 -124
- package/src/types/id-types.ts +57 -0
- package/src/types/verification.ts +5 -0
- package/src/types/workflow.ts +141 -0
- package/src/utils/amountFormat.ts +73 -0
- package/src/utils/tokens.ts +4 -2
- package/src/__tests__/cardCrop.test.ts +0 -39
- package/src/__tests__/deviceMetadata.test.ts +0 -34
- package/src/__tests__/errors.test.ts +0 -34
- package/src/__tests__/flow.test.ts +0 -61
- package/src/__tests__/gestureDetector.test.ts +0 -37
- package/src/__tests__/liveness.test.ts +0 -112
- package/src/__tests__/resolveUrl.test.ts +0 -64
- package/src/__tests__/validators.test.ts +0 -38
|
@@ -1,18 +1,22 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
2
|
import { Image, Platform, ScrollView, StatusBar, View } from 'react-native';
|
|
3
|
+
import { SvgXml } from 'react-native-svg';
|
|
3
4
|
import { StatusBarController } from './StatusBarController';
|
|
4
5
|
import { initialWindowMetrics } from 'react-native-safe-area-context';
|
|
5
6
|
|
|
6
7
|
import { headerSurface, radius, spacing } from '../config/theme';
|
|
8
|
+
import { useKeyboardInset } from '../lib/use-keyboard-inset';
|
|
7
9
|
import type { SupportedCountry } from '../types/config';
|
|
8
10
|
import { useKycConfig, useTheme } from './runtime';
|
|
9
11
|
import { useBranding } from './useBranding';
|
|
10
12
|
import { MyazaText } from './Typography';
|
|
13
|
+
import { PoweredBy } from './PoweredBy';
|
|
11
14
|
import { StepHeader } from './StepHeader';
|
|
12
15
|
import { StepIndicator } from './StepIndicator';
|
|
13
16
|
import { GlassIconButton } from './GlassIconButton';
|
|
14
17
|
import { GlassSurface } from './glass/GlassSurface';
|
|
15
18
|
import { GlassGroup } from './glass/GlassGroup';
|
|
19
|
+
import { FlashOverlay } from './FlashOverlay';
|
|
16
20
|
|
|
17
21
|
// Full-screen sheet shell — 1:1 with the Flutter SDK's KycBottomSheet:
|
|
18
22
|
// • a tinted header block (Liquid Glass on iOS 26) with a bottom border:
|
|
@@ -33,6 +37,29 @@ export interface KycSheetProps {
|
|
|
33
37
|
onClose: () => void;
|
|
34
38
|
/** Hide the persistent brand bar (e.g. on a fatal config-error screen). */
|
|
35
39
|
hideBrand?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Render the step body as a fixed-height flex container instead of a scroll
|
|
42
|
+
* view, so the step owns its own scrolling.
|
|
43
|
+
*
|
|
44
|
+
* Needed by any step with a PINNED element above a long list — the country
|
|
45
|
+
* picker's search box, for one. RN gives a nested same-axis ScrollView an
|
|
46
|
+
* unbounded height, so the inner list grows to fit every row, the outer
|
|
47
|
+
* content outgrows the viewport, and the outer view scrolls the "pinned"
|
|
48
|
+
* element off the top. `flexGrow: 1` cannot prevent that: it sets a minimum
|
|
49
|
+
* height, not a maximum. Flutter hits the same wall and solves it the same
|
|
50
|
+
* way (`KycBottomSheet.fillsViewport`).
|
|
51
|
+
*/
|
|
52
|
+
fillsViewport?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Hand the whole surface to the step — no header, no footer, no padding.
|
|
55
|
+
*
|
|
56
|
+
* Used by the live camera, which wants the entire display. It is a PROP
|
|
57
|
+
* rather than the caller rendering its own tree because the toggle happens
|
|
58
|
+
* MID-STEP: swapping the step's parent would unmount and remount it, losing
|
|
59
|
+
* its state (the acknowledged primer, the captured sides) and restarting the
|
|
60
|
+
* camera. Keeping one stable tree is the whole point.
|
|
61
|
+
*/
|
|
62
|
+
immersive?: boolean;
|
|
36
63
|
}
|
|
37
64
|
|
|
38
65
|
export function KycSheet({
|
|
@@ -45,10 +72,15 @@ export function KycSheet({
|
|
|
45
72
|
onBack,
|
|
46
73
|
onClose,
|
|
47
74
|
hideBrand,
|
|
75
|
+
fillsViewport,
|
|
76
|
+
immersive = false,
|
|
48
77
|
}: KycSheetProps): React.ReactElement {
|
|
49
78
|
const { colors, mode, toggle } = useTheme();
|
|
50
79
|
const config = useKycConfig();
|
|
51
80
|
const { logoUri, companyName } = useBranding();
|
|
81
|
+
// Android-only (0 on iOS): the translucent Modal blocks adjustResize there,
|
|
82
|
+
// so the keyboard height is added to the scroll padding by hand.
|
|
83
|
+
const keyboardInset = useKeyboardInset();
|
|
52
84
|
|
|
53
85
|
const tint = headerSurface(colors, mode);
|
|
54
86
|
const showIndicator = progress != null && stepCount != null;
|
|
@@ -85,7 +117,7 @@ export function KycSheet({
|
|
|
85
117
|
Platform.OS === 'android' ? sa?.bottom ?? 0 : iosFullScreen ? 34 : 0;
|
|
86
118
|
|
|
87
119
|
return (
|
|
88
|
-
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
|
120
|
+
<View style={{ flex: 1, backgroundColor: immersive ? '#000000' : colors.background }}>
|
|
89
121
|
{/* The SDK theme drives the status-bar glyph colour so it stays readable on
|
|
90
122
|
the modal background — light glyphs in dark mode, dark in light mode.
|
|
91
123
|
Android: JS StatusBar/SystemBars can't reach the <Modal>'s Dialog window,
|
|
@@ -98,8 +130,9 @@ export function KycSheet({
|
|
|
98
130
|
<StatusBar barStyle={mode === 'dark' ? 'light-content' : 'dark-content'} animated />
|
|
99
131
|
) : null}
|
|
100
132
|
<View style={{ flex: 1 }}>
|
|
101
|
-
{/* Header block (glass on iOS 26, tinted surface otherwise)
|
|
102
|
-
|
|
133
|
+
{/* Header block (glass on iOS 26, tinted surface otherwise) — dropped
|
|
134
|
+
entirely when the step owns the display. */}
|
|
135
|
+
{immersive ? null : <GlassSurface
|
|
103
136
|
glassStyle="regular"
|
|
104
137
|
fallbackColor={tint}
|
|
105
138
|
style={{ borderBottomWidth: 1, borderBottomColor: colors.border, paddingBottom: spacing.md }}
|
|
@@ -169,26 +202,100 @@ export function KycSheet({
|
|
|
169
202
|
<StepIndicator progress={progress!} stepCount={stepCount!} />
|
|
170
203
|
</View>
|
|
171
204
|
) : null}
|
|
172
|
-
</GlassSurface>
|
|
205
|
+
</GlassSurface>}
|
|
173
206
|
|
|
174
207
|
{/* Step body */}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
208
|
+
{fillsViewport ? (
|
|
209
|
+
// Only the visual gap here, NOT the scroll variant's `xl`: that
|
|
210
|
+
// padding exists so scrolled content clears the home indicator, but
|
|
211
|
+
// in a fixed-height body it just lifts the list off the footer and
|
|
212
|
+
// reads as a dead band. PoweredBy owns the safe-area inset already.
|
|
213
|
+
<View style={{ flex: 1, padding: spacing.md, paddingBottom: spacing.md }}>
|
|
214
|
+
{children}
|
|
215
|
+
</View>
|
|
216
|
+
) : (
|
|
217
|
+
// IMMERSIVE keeps this a ScrollView rather than swapping in a View.
|
|
218
|
+
// The toggle happens MID-STEP, and changing the children's parent
|
|
219
|
+
// element remounts the step — which wiped the acknowledged primer and
|
|
220
|
+
// bounced the user straight back to it. Same element, different
|
|
221
|
+
// props: scrolling off and the padding dropped.
|
|
222
|
+
<ScrollView
|
|
223
|
+
scrollEnabled={!immersive}
|
|
224
|
+
contentContainerStyle={
|
|
225
|
+
immersive
|
|
226
|
+
? { flexGrow: 1 }
|
|
227
|
+
: {
|
|
228
|
+
padding: spacing.md,
|
|
229
|
+
paddingTop: spacing.md,
|
|
230
|
+
paddingBottom: spacing.xl + keyboardInset,
|
|
231
|
+
flexGrow: 1,
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
keyboardShouldPersistTaps="handled"
|
|
235
|
+
// iOS: inset the content by the keyboard AND scroll the focused
|
|
236
|
+
// input into view — the platform's own handling, which a
|
|
237
|
+
// KeyboardAvoidingView only approximates. Without this the
|
|
238
|
+
// keyboard simply covered the bottom of every input step.
|
|
239
|
+
automaticallyAdjustKeyboardInsets={Platform.OS === 'ios' && !immersive}
|
|
240
|
+
>
|
|
241
|
+
{children}
|
|
242
|
+
</ScrollView>
|
|
243
|
+
)}
|
|
244
|
+
|
|
245
|
+
{/* Vendor attribution — a sibling of the ScrollView, so it stays pinned
|
|
246
|
+
while a long step scrolls under it. It now owns the bottom safe-area
|
|
247
|
+
inset that the scroll padding used to carry. */}
|
|
248
|
+
{immersive ? null : <PoweredBy bottomInset={bottomInset} />}
|
|
181
249
|
</View>
|
|
250
|
+
|
|
251
|
+
{/* The flash paints over the WHOLE sheet — header, body and footer — but
|
|
252
|
+
from inside the sheet's own tree, so its cutout and the camera preview
|
|
253
|
+
scale together. It subscribes to the store itself, so a colour change
|
|
254
|
+
re-renders this leaf and not the step (and not the camera).
|
|
255
|
+
|
|
256
|
+
LAST sibling, on purpose. As the first child it sat below everything
|
|
257
|
+
later in the tree, and on Android the header's elevated surface
|
|
258
|
+
painted over it — the flash lit the body and stopped dead at the
|
|
259
|
+
header. Last-with-elevation out-stacks the header on both platforms
|
|
260
|
+
by construction. */}
|
|
261
|
+
<FlashOverlay />
|
|
182
262
|
</View>
|
|
183
263
|
);
|
|
184
264
|
}
|
|
185
265
|
|
|
266
|
+
/** Whether a logo URI points at an SVG (extension check, query-safe). */
|
|
267
|
+
function isSvgUri(uri: string): boolean {
|
|
268
|
+
const path = uri.split('?')[0] ?? uri;
|
|
269
|
+
return path.toLowerCase().endsWith('.svg');
|
|
270
|
+
}
|
|
271
|
+
|
|
186
272
|
// Persistent header brand — circular logo avatar + company name. A broken/missing
|
|
187
273
|
// logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
|
|
188
274
|
// Flutter's `errorBuilder`), so the header never shows a broken-image box.
|
|
189
275
|
function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: string }): React.ReactElement | null {
|
|
190
276
|
const { colors } = useTheme();
|
|
191
277
|
const [broken, setBroken] = useState(false);
|
|
278
|
+
const svg = isSvgUri(logoUri);
|
|
279
|
+
// SVG logos render through SvgXml with SELF-FETCHED markup — the exact
|
|
280
|
+
// pipeline the country flags use, which is proven on-device. SvgUri's own
|
|
281
|
+
// fetch/sizing was flaky here (the favicon rendered at intrinsic size
|
|
282
|
+
// instead of filling the chip).
|
|
283
|
+
const [xml, setXml] = useState<string | null>(null);
|
|
284
|
+
useEffect(() => {
|
|
285
|
+
if (!svg) return;
|
|
286
|
+
let cancelled = false;
|
|
287
|
+
fetch(logoUri)
|
|
288
|
+
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
|
289
|
+
.then((text) => {
|
|
290
|
+
if (!cancelled) setXml(text);
|
|
291
|
+
})
|
|
292
|
+
.catch(() => {
|
|
293
|
+
if (!cancelled) setBroken(true);
|
|
294
|
+
});
|
|
295
|
+
return () => {
|
|
296
|
+
cancelled = true;
|
|
297
|
+
};
|
|
298
|
+
}, [logoUri, svg]);
|
|
192
299
|
if (broken) return null;
|
|
193
300
|
return (
|
|
194
301
|
<>
|
|
@@ -198,8 +305,16 @@ function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: stri
|
|
|
198
305
|
height: 28,
|
|
199
306
|
borderRadius: radius.full,
|
|
200
307
|
backgroundColor: '#FFFFFF',
|
|
308
|
+
// Light border + soft shadow — the same treatment as the Flutter
|
|
309
|
+
// SDK's brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and
|
|
310
|
+
// the web SDK's ring-1 ring-black/5.
|
|
201
311
|
borderWidth: 1,
|
|
202
312
|
borderColor: 'rgba(0,0,0,0.05)',
|
|
313
|
+
shadowColor: '#000000',
|
|
314
|
+
shadowOpacity: 0.06,
|
|
315
|
+
shadowRadius: 4,
|
|
316
|
+
shadowOffset: { width: 0, height: 1 },
|
|
317
|
+
elevation: 1,
|
|
203
318
|
overflow: 'hidden',
|
|
204
319
|
marginRight: spacing.sm,
|
|
205
320
|
alignItems: 'center',
|
|
@@ -208,13 +323,28 @@ function BrandBar({ logoUri, companyName }: { logoUri: string; companyName: stri
|
|
|
208
323
|
>
|
|
209
324
|
{/* Fill + cover-crop the circle. The borderRadius is repeated on the
|
|
210
325
|
Image itself because iOS doesn't reliably clip an Image child to a
|
|
211
|
-
parent's rounded corners (mirrors Flutter's ClipOval + cover).
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
326
|
+
parent's rounded corners (mirrors Flutter's ClipOval + cover).
|
|
327
|
+
SVG logos (common — the brand import picks up site favicons) can't
|
|
328
|
+
be decoded by <Image>; the fetched markup renders via SvgXml at
|
|
329
|
+
the chip's inner size (26 = 28 minus the 1px border each side),
|
|
330
|
+
slice = cover-crop. */}
|
|
331
|
+
{svg ? (
|
|
332
|
+
xml ? (
|
|
333
|
+
<SvgXml
|
|
334
|
+
xml={xml}
|
|
335
|
+
width={26}
|
|
336
|
+
height={26}
|
|
337
|
+
preserveAspectRatio="xMidYMid slice"
|
|
338
|
+
/>
|
|
339
|
+
) : null
|
|
340
|
+
) : (
|
|
341
|
+
<Image
|
|
342
|
+
source={{ uri: logoUri }}
|
|
343
|
+
style={{ width: '100%', height: '100%', borderRadius: radius.full }}
|
|
344
|
+
resizeMode="cover"
|
|
345
|
+
onError={() => setBroken(true)}
|
|
346
|
+
/>
|
|
347
|
+
)}
|
|
218
348
|
</View>
|
|
219
349
|
<MyazaText variant="heading3" numberOfLines={1} color={colors.textDark} style={{ flexShrink: 1, fontSize: 14 }}>
|
|
220
350
|
{companyName}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import { Modal, Platform, Pressable, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import { Icon, type IconName } from './Icon';
|
|
6
|
+
import { useTheme } from './runtime';
|
|
7
|
+
import { MyazaText } from './Typography';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The SDK's own "where is the document?" sheet.
|
|
11
|
+
*
|
|
12
|
+
* Replaces `ActionSheetIOS` / `Alert.alert`, which were the two NATIVE
|
|
13
|
+
* choosers — grey system UI with bare uppercase labels, dropped into the
|
|
14
|
+
* middle of an otherwise fully branded flow. A verification flow trades on
|
|
15
|
+
* looking deliberate; one stock OS dialog in the middle of it reads as a seam,
|
|
16
|
+
* and on Android the three-button alert didn't even look like a chooser.
|
|
17
|
+
*
|
|
18
|
+
* Same presentation grammar as the SDK's other sheets (select, date picker):
|
|
19
|
+
* bottom card, backdrop dismiss, org palette, one icon per option so the three
|
|
20
|
+
* sources are scannable without reading.
|
|
21
|
+
*/
|
|
22
|
+
export interface MediaSourceOption {
|
|
23
|
+
icon: IconName;
|
|
24
|
+
label: string;
|
|
25
|
+
/** One line of what picking this means — the scannable part. */
|
|
26
|
+
caption: string;
|
|
27
|
+
onPress: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function MediaSourceSheet({
|
|
31
|
+
open,
|
|
32
|
+
title,
|
|
33
|
+
options,
|
|
34
|
+
onClose,
|
|
35
|
+
}: {
|
|
36
|
+
open: boolean;
|
|
37
|
+
title: string;
|
|
38
|
+
options: MediaSourceOption[];
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}): React.ReactElement | null {
|
|
41
|
+
const { colors } = useTheme();
|
|
42
|
+
|
|
43
|
+
// iOS: a picker (photo library / files / camera) is a view controller, and
|
|
44
|
+
// UIKit REFUSES to present one while this sheet's own modal is still
|
|
45
|
+
// animating its dismissal — which is precisely when a "close then run the
|
|
46
|
+
// picker" handler runs. So the chosen action is parked and executed from
|
|
47
|
+
// the Modal's `onDismiss`, the callback that fires only after the
|
|
48
|
+
// dismissal has fully completed. That is also why the Modal stays MOUNTED
|
|
49
|
+
// (`visible` instead of an early null return): unmounting skips the
|
|
50
|
+
// dismiss lifecycle and onDismiss never fires. Android pickers are
|
|
51
|
+
// separate activities with no such conflict, so they run immediately.
|
|
52
|
+
const pendingRef = useRef<(() => void) | null>(null);
|
|
53
|
+
const choose = (action: () => void): void => {
|
|
54
|
+
if (Platform.OS === 'ios') {
|
|
55
|
+
pendingRef.current = action;
|
|
56
|
+
onClose();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
onClose();
|
|
60
|
+
action();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Modal
|
|
65
|
+
transparent
|
|
66
|
+
visible={open}
|
|
67
|
+
animationType="fade"
|
|
68
|
+
onRequestClose={onClose}
|
|
69
|
+
onDismiss={() => {
|
|
70
|
+
const run = pendingRef.current;
|
|
71
|
+
pendingRef.current = null;
|
|
72
|
+
run?.();
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<Pressable
|
|
76
|
+
onPress={onClose}
|
|
77
|
+
style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
78
|
+
>
|
|
79
|
+
{/* Swallow taps on the card so only the backdrop dismisses. */}
|
|
80
|
+
<Pressable
|
|
81
|
+
onPress={() => undefined}
|
|
82
|
+
style={{
|
|
83
|
+
backgroundColor: colors.background,
|
|
84
|
+
borderTopLeftRadius: radius.xl,
|
|
85
|
+
borderTopRightRadius: radius.xl,
|
|
86
|
+
padding: spacing.md,
|
|
87
|
+
paddingBottom: spacing.xl,
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{/* Grab handle — the affordance that says "sheet", and therefore
|
|
91
|
+
"swipe-away-able", without a close button competing with the
|
|
92
|
+
options. */}
|
|
93
|
+
<View
|
|
94
|
+
style={{
|
|
95
|
+
alignSelf: 'center',
|
|
96
|
+
width: 36,
|
|
97
|
+
height: 4,
|
|
98
|
+
borderRadius: 2,
|
|
99
|
+
backgroundColor: colors.border,
|
|
100
|
+
marginBottom: spacing.md,
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
<MyazaText variant="heading3" style={{ textAlign: 'center', marginBottom: spacing.md }}>
|
|
105
|
+
{title}
|
|
106
|
+
</MyazaText>
|
|
107
|
+
|
|
108
|
+
{options.map((o, i) => (
|
|
109
|
+
<Pressable
|
|
110
|
+
key={o.label}
|
|
111
|
+
onPress={() => choose(o.onPress)}
|
|
112
|
+
accessibilityRole="button"
|
|
113
|
+
accessibilityLabel={o.label}
|
|
114
|
+
style={({ pressed }) => ({
|
|
115
|
+
flexDirection: 'row',
|
|
116
|
+
alignItems: 'center',
|
|
117
|
+
gap: spacing.md,
|
|
118
|
+
paddingVertical: spacing.md,
|
|
119
|
+
paddingHorizontal: spacing.sm,
|
|
120
|
+
borderRadius: radius.md,
|
|
121
|
+
backgroundColor: pressed ? colors.backgroundSecondary : 'transparent',
|
|
122
|
+
marginTop: i === 0 ? 0 : spacing.xs,
|
|
123
|
+
})}
|
|
124
|
+
>
|
|
125
|
+
{/* Tinted icon disc — the same treatment the flow's info rows
|
|
126
|
+
use, so the sheet reads as part of the SDK, not the OS. */}
|
|
127
|
+
<View
|
|
128
|
+
style={{
|
|
129
|
+
width: 42,
|
|
130
|
+
height: 42,
|
|
131
|
+
borderRadius: radius.full,
|
|
132
|
+
backgroundColor: colors.primary50,
|
|
133
|
+
alignItems: 'center',
|
|
134
|
+
justifyContent: 'center',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<Icon name={o.icon} size={20} color={colors.primary} />
|
|
138
|
+
</View>
|
|
139
|
+
<View style={{ flex: 1 }}>
|
|
140
|
+
<MyazaText variant="label">{o.label}</MyazaText>
|
|
141
|
+
<MyazaText variant="bodySmall" color={colors.textMuted}>
|
|
142
|
+
{o.caption}
|
|
143
|
+
</MyazaText>
|
|
144
|
+
</View>
|
|
145
|
+
<Icon name="chevron-right" size={18} color={colors.textMuted} />
|
|
146
|
+
</Pressable>
|
|
147
|
+
))}
|
|
148
|
+
|
|
149
|
+
<Pressable
|
|
150
|
+
onPress={onClose}
|
|
151
|
+
accessibilityRole="button"
|
|
152
|
+
accessibilityLabel="Cancel"
|
|
153
|
+
style={{ paddingVertical: spacing.md, alignItems: 'center', marginTop: spacing.xs }}
|
|
154
|
+
>
|
|
155
|
+
<MyazaText variant="label" color={colors.textSecondary}>
|
|
156
|
+
Cancel
|
|
157
|
+
</MyazaText>
|
|
158
|
+
</Pressable>
|
|
159
|
+
</Pressable>
|
|
160
|
+
</Pressable>
|
|
161
|
+
</Modal>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -85,13 +85,25 @@ export function MyazaButton({
|
|
|
85
85
|
{loading ? (
|
|
86
86
|
<ActivityIndicator color={effectiveFg} size="small" />
|
|
87
87
|
) : (
|
|
88
|
-
|
|
88
|
+
// `flexShrink: 1` on the ROW and the label: a Text in a row that cannot
|
|
89
|
+
// shrink does not wrap, it OVERFLOWS and gets clipped. That was
|
|
90
|
+
// invisible with the bundled font and cut "Continue" to "Continu" the
|
|
91
|
+
// moment an org picked a wider one. A button label must never be
|
|
92
|
+
// truncated by a branding choice, hence the shrink-to-fit as well.
|
|
93
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', flexShrink: 1 }}>
|
|
89
94
|
{leadingIcon ? (
|
|
90
95
|
<View style={{ marginRight: 8 }}>
|
|
91
96
|
<Icon name={leadingIcon} size={18} color={effectiveFg} />
|
|
92
97
|
</View>
|
|
93
98
|
) : null}
|
|
94
|
-
<MyazaText
|
|
99
|
+
<MyazaText
|
|
100
|
+
variant="button"
|
|
101
|
+
color={effectiveFg}
|
|
102
|
+
numberOfLines={1}
|
|
103
|
+
adjustsFontSizeToFit
|
|
104
|
+
minimumFontScale={0.8}
|
|
105
|
+
style={{ flexShrink: 1 }}
|
|
106
|
+
>
|
|
95
107
|
{label}
|
|
96
108
|
</MyazaText>
|
|
97
109
|
</View>
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Modal, Pressable, ScrollView, View } from 'react-native';
|
|
3
|
+
|
|
4
|
+
import { radius, spacing } from '../config/theme';
|
|
5
|
+
import {
|
|
6
|
+
MONTH_NAMES,
|
|
7
|
+
WEEKDAY_INITIALS,
|
|
8
|
+
addMonth,
|
|
9
|
+
monthMatrix,
|
|
10
|
+
parseIsoDate,
|
|
11
|
+
toIsoDate,
|
|
12
|
+
} from '../lib/calendar';
|
|
13
|
+
import { Icon } from './Icon';
|
|
14
|
+
import { useTheme } from './runtime';
|
|
15
|
+
import { MyazaText } from './Typography';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A date field that opens a REAL calendar picker, replacing the "type
|
|
19
|
+
* YYYY-MM-DD" text input — nobody should hand-format an ISO date on a phone
|
|
20
|
+
* keyboard. JS-drawn rather than `@react-native-community/datetimepicker`
|
|
21
|
+
* because a native module would be a new install step for every SDK consumer,
|
|
22
|
+
* and a JS calendar themes correctly (org palette, dark mode) for free — the
|
|
23
|
+
* same reason the Flutter SDK wraps `showDatePicker` in its own theme.
|
|
24
|
+
*
|
|
25
|
+
* The value contract is unchanged: emits strict `YYYY-MM-DD`, exactly what the
|
|
26
|
+
* text input produced and what the server validates.
|
|
27
|
+
*
|
|
28
|
+
* Years run NEWEST-FIRST in the year grid: the common date questions are
|
|
29
|
+
* birthdays and document dates, both reached faster from the recent end.
|
|
30
|
+
*/
|
|
31
|
+
export function MyazaDateField({
|
|
32
|
+
value,
|
|
33
|
+
onChange,
|
|
34
|
+
placeholder,
|
|
35
|
+
error,
|
|
36
|
+
}: {
|
|
37
|
+
value?: string;
|
|
38
|
+
onChange: (iso: string) => void;
|
|
39
|
+
placeholder?: string;
|
|
40
|
+
error?: string | null;
|
|
41
|
+
}): React.ReactElement {
|
|
42
|
+
const { colors } = useTheme();
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const selected = parseIsoDate(value);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
<Pressable
|
|
49
|
+
onPress={() => setOpen(true)}
|
|
50
|
+
accessibilityRole="button"
|
|
51
|
+
accessibilityLabel={selected ? `Date, ${value}` : (placeholder ?? 'Select a date')}
|
|
52
|
+
style={{
|
|
53
|
+
flexDirection: 'row',
|
|
54
|
+
alignItems: 'center',
|
|
55
|
+
paddingHorizontal: spacing.md,
|
|
56
|
+
paddingVertical: spacing.md,
|
|
57
|
+
borderRadius: radius.sm,
|
|
58
|
+
// Mirrors MyazaInput's border grammar so the two field kinds sit
|
|
59
|
+
// side by side without looking like different systems.
|
|
60
|
+
borderWidth: error ? 1.5 : 1,
|
|
61
|
+
borderColor: error ? colors.error : colors.border,
|
|
62
|
+
backgroundColor: colors.background,
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
<Icon name="calendar" size={18} color={colors.textSecondary} />
|
|
66
|
+
<View style={{ width: spacing.sm }} />
|
|
67
|
+
<MyazaText variant="body" color={selected ? undefined : colors.textMuted} style={{ flex: 1 }}>
|
|
68
|
+
{selected ? value! : (placeholder ?? 'Select a date')}
|
|
69
|
+
</MyazaText>
|
|
70
|
+
<Icon name="chevron-down" size={18} color={colors.textSecondary} />
|
|
71
|
+
</Pressable>
|
|
72
|
+
{error ? (
|
|
73
|
+
<MyazaText variant="bodySmall" color={colors.error} style={{ marginTop: spacing.xs }}>
|
|
74
|
+
{error}
|
|
75
|
+
</MyazaText>
|
|
76
|
+
) : null}
|
|
77
|
+
|
|
78
|
+
{open ? (
|
|
79
|
+
<DatePickerSheet
|
|
80
|
+
initial={selected}
|
|
81
|
+
onClose={() => setOpen(false)}
|
|
82
|
+
onPick={(iso) => {
|
|
83
|
+
setOpen(false);
|
|
84
|
+
onChange(iso);
|
|
85
|
+
}}
|
|
86
|
+
/>
|
|
87
|
+
) : null}
|
|
88
|
+
</>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Same range as the Flutter SDK's picker: 1900 through five years out. */
|
|
93
|
+
const MIN_YEAR = 1900;
|
|
94
|
+
const MAX_YEAR = new Date().getFullYear() + 5;
|
|
95
|
+
|
|
96
|
+
function DatePickerSheet({
|
|
97
|
+
initial,
|
|
98
|
+
onClose,
|
|
99
|
+
onPick,
|
|
100
|
+
}: {
|
|
101
|
+
initial: ReturnType<typeof parseIsoDate>;
|
|
102
|
+
onClose: () => void;
|
|
103
|
+
onPick: (iso: string) => void;
|
|
104
|
+
}): React.ReactElement {
|
|
105
|
+
const { colors } = useTheme();
|
|
106
|
+
const today = new Date();
|
|
107
|
+
const [cursor, setCursor] = useState({
|
|
108
|
+
year: initial?.year ?? today.getFullYear(),
|
|
109
|
+
month0: initial?.month0 ?? today.getMonth(),
|
|
110
|
+
});
|
|
111
|
+
const [pickingYear, setPickingYear] = useState(false);
|
|
112
|
+
|
|
113
|
+
const weeks = monthMatrix(cursor.year, cursor.month0);
|
|
114
|
+
const isToday = (d: number) =>
|
|
115
|
+
cursor.year === today.getFullYear() && cursor.month0 === today.getMonth() && d === today.getDate();
|
|
116
|
+
const isSelected = (d: number) =>
|
|
117
|
+
!!initial && initial.year === cursor.year && initial.month0 === cursor.month0 && initial.day === d;
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<Modal transparent animationType="fade" onRequestClose={onClose}>
|
|
121
|
+
<Pressable
|
|
122
|
+
onPress={onClose}
|
|
123
|
+
style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
124
|
+
>
|
|
125
|
+
{/* Stop the backdrop press from closing when the card itself is tapped. */}
|
|
126
|
+
<Pressable
|
|
127
|
+
onPress={() => undefined}
|
|
128
|
+
style={{
|
|
129
|
+
backgroundColor: colors.background,
|
|
130
|
+
borderTopLeftRadius: radius.xl,
|
|
131
|
+
borderTopRightRadius: radius.xl,
|
|
132
|
+
padding: spacing.md,
|
|
133
|
+
paddingBottom: spacing.xl,
|
|
134
|
+
}}
|
|
135
|
+
>
|
|
136
|
+
{/* Month header: step, or tap the title to jump years. */}
|
|
137
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }}>
|
|
138
|
+
<Pressable
|
|
139
|
+
onPress={() => setCursor((c) => addMonth(c.year, c.month0, -1))}
|
|
140
|
+
hitSlop={8}
|
|
141
|
+
accessibilityLabel="Previous month"
|
|
142
|
+
style={{ padding: spacing.sm }}
|
|
143
|
+
>
|
|
144
|
+
<Icon name="chevron-left" size={20} color={colors.textSecondary} />
|
|
145
|
+
</Pressable>
|
|
146
|
+
<Pressable
|
|
147
|
+
onPress={() => setPickingYear((v) => !v)}
|
|
148
|
+
accessibilityLabel="Choose year"
|
|
149
|
+
style={{ flex: 1, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 4 }}
|
|
150
|
+
>
|
|
151
|
+
<MyazaText variant="label">{`${MONTH_NAMES[cursor.month0]} ${cursor.year}`}</MyazaText>
|
|
152
|
+
<Icon name="chevron-down" size={16} color={colors.textSecondary} />
|
|
153
|
+
</Pressable>
|
|
154
|
+
<Pressable
|
|
155
|
+
onPress={() => setCursor((c) => addMonth(c.year, c.month0, 1))}
|
|
156
|
+
hitSlop={8}
|
|
157
|
+
accessibilityLabel="Next month"
|
|
158
|
+
style={{ padding: spacing.sm }}
|
|
159
|
+
>
|
|
160
|
+
<Icon name="chevron-right" size={20} color={colors.textSecondary} />
|
|
161
|
+
</Pressable>
|
|
162
|
+
</View>
|
|
163
|
+
|
|
164
|
+
{pickingYear ? (
|
|
165
|
+
<ScrollView style={{ height: 280 }}>
|
|
166
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
|
|
167
|
+
{Array.from({ length: MAX_YEAR - MIN_YEAR + 1 }, (_, i) => MAX_YEAR - i).map((y) => (
|
|
168
|
+
<Pressable
|
|
169
|
+
key={y}
|
|
170
|
+
onPress={() => {
|
|
171
|
+
setCursor((c) => ({ ...c, year: y }));
|
|
172
|
+
setPickingYear(false);
|
|
173
|
+
}}
|
|
174
|
+
style={{ width: '25%', paddingVertical: spacing.sm, alignItems: 'center' }}
|
|
175
|
+
>
|
|
176
|
+
<MyazaText
|
|
177
|
+
variant="body"
|
|
178
|
+
color={y === cursor.year ? colors.primary : undefined}
|
|
179
|
+
style={y === cursor.year ? { fontWeight: '600' } : undefined}
|
|
180
|
+
>
|
|
181
|
+
{String(y)}
|
|
182
|
+
</MyazaText>
|
|
183
|
+
</Pressable>
|
|
184
|
+
))}
|
|
185
|
+
</View>
|
|
186
|
+
</ScrollView>
|
|
187
|
+
) : (
|
|
188
|
+
<>
|
|
189
|
+
<View style={{ flexDirection: 'row' }}>
|
|
190
|
+
{WEEKDAY_INITIALS.map((w, i) => (
|
|
191
|
+
<MyazaText
|
|
192
|
+
key={`${w}${i}`}
|
|
193
|
+
variant="bodySmall"
|
|
194
|
+
color={colors.textMuted}
|
|
195
|
+
style={{ flex: 1, textAlign: 'center' }}
|
|
196
|
+
>
|
|
197
|
+
{w}
|
|
198
|
+
</MyazaText>
|
|
199
|
+
))}
|
|
200
|
+
</View>
|
|
201
|
+
{weeks.map((week, wi) => (
|
|
202
|
+
<View key={wi} style={{ flexDirection: 'row' }}>
|
|
203
|
+
{week.map((day, di) =>
|
|
204
|
+
day == null ? (
|
|
205
|
+
<View key={di} style={{ flex: 1, aspectRatio: 1 }} />
|
|
206
|
+
) : (
|
|
207
|
+
<Pressable
|
|
208
|
+
key={di}
|
|
209
|
+
onPress={() => onPick(toIsoDate({ year: cursor.year, month0: cursor.month0, day }))}
|
|
210
|
+
accessibilityRole="button"
|
|
211
|
+
accessibilityLabel={`${day} ${MONTH_NAMES[cursor.month0]} ${cursor.year}`}
|
|
212
|
+
style={{
|
|
213
|
+
flex: 1,
|
|
214
|
+
aspectRatio: 1,
|
|
215
|
+
alignItems: 'center',
|
|
216
|
+
justifyContent: 'center',
|
|
217
|
+
borderRadius: radius.full,
|
|
218
|
+
backgroundColor: isSelected(day) ? colors.primary : undefined,
|
|
219
|
+
borderWidth: isToday(day) && !isSelected(day) ? 1 : 0,
|
|
220
|
+
borderColor: colors.primary,
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
<MyazaText
|
|
224
|
+
variant="body"
|
|
225
|
+
color={isSelected(day) ? colors.onPrimary : undefined}
|
|
226
|
+
>
|
|
227
|
+
{String(day)}
|
|
228
|
+
</MyazaText>
|
|
229
|
+
</Pressable>
|
|
230
|
+
),
|
|
231
|
+
)}
|
|
232
|
+
</View>
|
|
233
|
+
))}
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
</Pressable>
|
|
237
|
+
</Pressable>
|
|
238
|
+
</Modal>
|
|
239
|
+
);
|
|
240
|
+
}
|