@trustchex/react-native-sdk 1.381.0 → 1.464.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.
Files changed (204) hide show
  1. package/android/src/main/java/com/trustchex/reactnativesdk/TrustchexSDKModule.kt +2 -8
  2. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +60 -13
  3. package/android/src/main/java/com/trustchex/reactnativesdk/mlkit/MLKitModule.kt +1 -1
  4. package/ios/Camera/TrustchexCameraView.swift +10 -13
  5. package/ios/MLKit/MLKitModule.swift +1 -1
  6. package/lib/module/Screens/Debug/BarcodeTestScreen.js +308 -0
  7. package/lib/module/Screens/Debug/MRZTestScreen.js +105 -13
  8. package/lib/module/Screens/Debug/NFCScanTestScreen.js +635 -0
  9. package/lib/module/Screens/Dynamic/ContractAcceptanceScreen.js +49 -32
  10. package/lib/module/Screens/Dynamic/IdentityDocumentEIDScanningScreen.js +22 -4
  11. package/lib/module/Screens/Dynamic/IdentityDocumentScanningScreen.js +5 -0
  12. package/lib/module/Screens/Dynamic/LivenessDetectionScreen.js +126 -27
  13. package/lib/module/Screens/Dynamic/VerbalConsentScreen.js +1079 -0
  14. package/lib/module/Screens/Dynamic/VideoCallScreen.js +678 -0
  15. package/lib/module/Screens/Static/OTPVerificationScreen.js +6 -0
  16. package/lib/module/Screens/Static/QrCodeScanningScreen.js +7 -1
  17. package/lib/module/Screens/Static/ResultScreen.js +154 -34
  18. package/lib/module/Screens/Static/VerificationSessionCheckScreen.js +59 -51
  19. package/lib/module/Shared/Animations/recording.json +1 -0
  20. package/lib/module/Shared/Animations/video-call.json +1 -0
  21. package/lib/module/Shared/Components/DebugNavigationPanel.js +231 -67
  22. package/lib/module/Shared/Components/EIDScanner.js +213 -112
  23. package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +5 -3
  24. package/lib/module/Shared/Components/IdentityDocumentCamera.js +77 -39
  25. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +13 -4
  26. package/lib/module/Shared/Components/NavigationManager.js +39 -19
  27. package/lib/module/Shared/Contexts/AppContext.js +1 -0
  28. package/lib/module/Shared/EIDReader/aesSecureMessagingWrapper.js +51 -0
  29. package/lib/module/Shared/EIDReader/apduLevelPACECapable.js +3 -0
  30. package/lib/module/Shared/EIDReader/bacKey.js +16 -2
  31. package/lib/module/Shared/EIDReader/eidReader.js +354 -13
  32. package/lib/module/Shared/EIDReader/eidService.js +25 -1
  33. package/lib/module/Shared/EIDReader/nfcManagerCardService.js +4 -7
  34. package/lib/module/Shared/EIDReader/paceInfo.js +85 -0
  35. package/lib/module/Shared/EIDReader/paceKeySpec.js +51 -0
  36. package/lib/module/Shared/EIDReader/protocol/paceAPDUSender.js +100 -0
  37. package/lib/module/Shared/EIDReader/protocol/paceProtocol.js +655 -0
  38. package/lib/module/Shared/EIDReader/protocol/paceResult.js +37 -0
  39. package/lib/module/Shared/EIDReader/secureMessagingWrapper.js +27 -4
  40. package/lib/module/Shared/EIDReader/smartcards/commandAPDU.js +2 -1
  41. package/lib/module/Shared/EIDReader/tlv/tlv.helpers.js +1 -1
  42. package/lib/module/Shared/EIDReader/tlv/tlv.utils.js +6 -3
  43. package/lib/module/Shared/EIDReader/utils/aesCrypto.utils.js +189 -0
  44. package/lib/module/Shared/Libs/SignalingClient.js +128 -0
  45. package/lib/module/Shared/Libs/analytics.utils.js +8 -0
  46. package/lib/module/Shared/Libs/contains.js +1 -40
  47. package/lib/module/Shared/Libs/country-display.utils.js +34 -0
  48. package/lib/module/Shared/Libs/deeplink.utils.js +9 -1
  49. package/lib/module/Shared/Libs/demo.utils.js +8 -0
  50. package/lib/module/Shared/Libs/http-client.js +9 -0
  51. package/lib/module/Shared/Libs/mrz.utils.js +3 -2
  52. package/lib/module/Shared/Libs/promise.utils.js +16 -2
  53. package/lib/module/Shared/Libs/status-bar.utils.js +23 -0
  54. package/lib/module/Shared/Services/DataUploadService.js +294 -0
  55. package/lib/module/Shared/Services/VideoSessionService.js +156 -0
  56. package/lib/module/Shared/Services/WebRTCService.js +510 -0
  57. package/lib/module/Shared/Types/analytics.types.js +4 -0
  58. package/lib/module/Translation/Resources/en.js +61 -2
  59. package/lib/module/Translation/Resources/tr.js +61 -2
  60. package/lib/module/Trustchex.js +64 -20
  61. package/lib/module/version.js +1 -1
  62. package/lib/typescript/src/Screens/Debug/BarcodeTestScreen.d.ts +3 -0
  63. package/lib/typescript/src/Screens/Debug/BarcodeTestScreen.d.ts.map +1 -0
  64. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  65. package/lib/typescript/src/Screens/Debug/NFCScanTestScreen.d.ts +3 -0
  66. package/lib/typescript/src/Screens/Debug/NFCScanTestScreen.d.ts.map +1 -0
  67. package/lib/typescript/src/Screens/Dynamic/ContractAcceptanceScreen.d.ts.map +1 -1
  68. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.d.ts.map +1 -1
  69. package/lib/typescript/src/Screens/Dynamic/IdentityDocumentScanningScreen.d.ts.map +1 -1
  70. package/lib/typescript/src/Screens/Dynamic/LivenessDetectionScreen.d.ts.map +1 -1
  71. package/lib/typescript/src/Screens/Dynamic/VerbalConsentScreen.d.ts +3 -0
  72. package/lib/typescript/src/Screens/Dynamic/VerbalConsentScreen.d.ts.map +1 -0
  73. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts +3 -0
  74. package/lib/typescript/src/Screens/Dynamic/VideoCallScreen.d.ts.map +1 -0
  75. package/lib/typescript/src/Screens/Static/OTPVerificationScreen.d.ts.map +1 -1
  76. package/lib/typescript/src/Screens/Static/QrCodeScanningScreen.d.ts.map +1 -1
  77. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  78. package/lib/typescript/src/Screens/Static/VerificationSessionCheckScreen.d.ts.map +1 -1
  79. package/lib/typescript/src/Shared/Components/DebugNavigationPanel.d.ts.map +1 -1
  80. package/lib/typescript/src/Shared/Components/EIDScanner.d.ts.map +1 -1
  81. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  82. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +1 -1
  83. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -1
  84. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +5 -0
  85. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -1
  86. package/lib/typescript/src/Shared/Components/NavigationManager.d.ts.map +1 -1
  87. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts +1 -0
  88. package/lib/typescript/src/Shared/Contexts/AppContext.d.ts.map +1 -1
  89. package/lib/typescript/src/Shared/EIDReader/aesSecureMessagingWrapper.d.ts +18 -0
  90. package/lib/typescript/src/Shared/EIDReader/aesSecureMessagingWrapper.d.ts.map +1 -0
  91. package/lib/typescript/src/Shared/EIDReader/apduLevelPACECapable.d.ts +23 -0
  92. package/lib/typescript/src/Shared/EIDReader/apduLevelPACECapable.d.ts.map +1 -0
  93. package/lib/typescript/src/Shared/EIDReader/bacKey.d.ts +6 -0
  94. package/lib/typescript/src/Shared/EIDReader/bacKey.d.ts.map +1 -1
  95. package/lib/typescript/src/Shared/EIDReader/eidReader.d.ts.map +1 -1
  96. package/lib/typescript/src/Shared/EIDReader/eidService.d.ts +9 -0
  97. package/lib/typescript/src/Shared/EIDReader/eidService.d.ts.map +1 -1
  98. package/lib/typescript/src/Shared/EIDReader/nfcManagerCardService.d.ts.map +1 -1
  99. package/lib/typescript/src/Shared/EIDReader/paceInfo.d.ts +50 -0
  100. package/lib/typescript/src/Shared/EIDReader/paceInfo.d.ts.map +1 -0
  101. package/lib/typescript/src/Shared/EIDReader/paceKeySpec.d.ts +30 -0
  102. package/lib/typescript/src/Shared/EIDReader/paceKeySpec.d.ts.map +1 -0
  103. package/lib/typescript/src/Shared/EIDReader/protocol/paceAPDUSender.d.ts +17 -0
  104. package/lib/typescript/src/Shared/EIDReader/protocol/paceAPDUSender.d.ts.map +1 -0
  105. package/lib/typescript/src/Shared/EIDReader/protocol/paceProtocol.d.ts +105 -0
  106. package/lib/typescript/src/Shared/EIDReader/protocol/paceProtocol.d.ts.map +1 -0
  107. package/lib/typescript/src/Shared/EIDReader/protocol/paceResult.d.ts +24 -0
  108. package/lib/typescript/src/Shared/EIDReader/protocol/paceResult.d.ts.map +1 -0
  109. package/lib/typescript/src/Shared/EIDReader/secureMessagingWrapper.d.ts +15 -0
  110. package/lib/typescript/src/Shared/EIDReader/secureMessagingWrapper.d.ts.map +1 -1
  111. package/lib/typescript/src/Shared/EIDReader/smartcards/commandAPDU.d.ts.map +1 -1
  112. package/lib/typescript/src/Shared/EIDReader/tlv/tlv.utils.d.ts.map +1 -1
  113. package/lib/typescript/src/Shared/EIDReader/utils/aesCrypto.utils.d.ts +39 -0
  114. package/lib/typescript/src/Shared/EIDReader/utils/aesCrypto.utils.d.ts.map +1 -0
  115. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts +24 -0
  116. package/lib/typescript/src/Shared/Libs/SignalingClient.d.ts.map +1 -0
  117. package/lib/typescript/src/Shared/Libs/analytics.utils.d.ts.map +1 -1
  118. package/lib/typescript/src/Shared/Libs/contains.d.ts +0 -7
  119. package/lib/typescript/src/Shared/Libs/contains.d.ts.map +1 -1
  120. package/lib/typescript/src/Shared/Libs/country-display.utils.d.ts +2 -0
  121. package/lib/typescript/src/Shared/Libs/country-display.utils.d.ts.map +1 -0
  122. package/lib/typescript/src/Shared/Libs/deeplink.utils.d.ts.map +1 -1
  123. package/lib/typescript/src/Shared/Libs/demo.utils.d.ts.map +1 -1
  124. package/lib/typescript/src/Shared/Libs/http-client.d.ts +1 -1
  125. package/lib/typescript/src/Shared/Libs/http-client.d.ts.map +1 -1
  126. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  127. package/lib/typescript/src/Shared/Libs/promise.utils.d.ts.map +1 -1
  128. package/lib/typescript/src/Shared/Libs/status-bar.utils.d.ts +9 -0
  129. package/lib/typescript/src/Shared/Libs/status-bar.utils.d.ts.map +1 -0
  130. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts +25 -0
  131. package/lib/typescript/src/Shared/Services/DataUploadService.d.ts.map +1 -0
  132. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts +33 -0
  133. package/lib/typescript/src/Shared/Services/VideoSessionService.d.ts.map +1 -0
  134. package/lib/typescript/src/Shared/Services/WebRTCService.d.ts +58 -0
  135. package/lib/typescript/src/Shared/Services/WebRTCService.d.ts.map +1 -0
  136. package/lib/typescript/src/Shared/Types/analytics.types.d.ts +4 -0
  137. package/lib/typescript/src/Shared/Types/analytics.types.d.ts.map +1 -1
  138. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts +13 -1
  139. package/lib/typescript/src/Shared/Types/identificationInfo.d.ts.map +1 -1
  140. package/lib/typescript/src/Translation/Resources/en.d.ts +60 -1
  141. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  142. package/lib/typescript/src/Translation/Resources/tr.d.ts +60 -1
  143. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  144. package/lib/typescript/src/Trustchex.d.ts.map +1 -1
  145. package/lib/typescript/src/version.d.ts +1 -1
  146. package/package.json +35 -5
  147. package/src/Screens/Debug/BarcodeTestScreen.tsx +317 -0
  148. package/src/Screens/Debug/MRZTestScreen.tsx +107 -13
  149. package/src/Screens/Debug/NFCScanTestScreen.tsx +692 -0
  150. package/src/Screens/Dynamic/ContractAcceptanceScreen.tsx +58 -35
  151. package/src/Screens/Dynamic/IdentityDocumentEIDScanningScreen.tsx +27 -4
  152. package/src/Screens/Dynamic/IdentityDocumentScanningScreen.tsx +6 -0
  153. package/src/Screens/Dynamic/LivenessDetectionScreen.tsx +156 -27
  154. package/src/Screens/Dynamic/VerbalConsentScreen.tsx +1401 -0
  155. package/src/Screens/Dynamic/VideoCallScreen.tsx +766 -0
  156. package/src/Screens/Static/OTPVerificationScreen.tsx +6 -0
  157. package/src/Screens/Static/QrCodeScanningScreen.tsx +7 -1
  158. package/src/Screens/Static/ResultScreen.tsx +235 -48
  159. package/src/Screens/Static/VerificationSessionCheckScreen.tsx +67 -72
  160. package/src/Shared/Animations/recording.json +1 -0
  161. package/src/Shared/Animations/video-call.json +1 -0
  162. package/src/Shared/Components/DebugNavigationPanel.tsx +252 -51
  163. package/src/Shared/Components/EIDScanner.tsx +223 -116
  164. package/src/Shared/Components/IdentityDocumentCamera.flows.ts +7 -4
  165. package/src/Shared/Components/IdentityDocumentCamera.tsx +224 -188
  166. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +13 -4
  167. package/src/Shared/Components/NavigationManager.tsx +41 -19
  168. package/src/Shared/Contexts/AppContext.ts +2 -0
  169. package/src/Shared/EIDReader/aesSecureMessagingWrapper.ts +69 -0
  170. package/src/Shared/EIDReader/apduLevelPACECapable.ts +34 -0
  171. package/src/Shared/EIDReader/bacKey.ts +24 -8
  172. package/src/Shared/EIDReader/eidReader.ts +398 -12
  173. package/src/Shared/EIDReader/eidService.ts +49 -1
  174. package/src/Shared/EIDReader/nfcManagerCardService.ts +4 -6
  175. package/src/Shared/EIDReader/paceInfo.ts +159 -0
  176. package/src/Shared/EIDReader/paceKeySpec.ts +56 -0
  177. package/src/Shared/EIDReader/protocol/paceAPDUSender.ts +163 -0
  178. package/src/Shared/EIDReader/protocol/paceProtocol.ts +946 -0
  179. package/src/Shared/EIDReader/protocol/paceResult.ts +62 -0
  180. package/src/Shared/EIDReader/secureMessagingWrapper.ts +28 -10
  181. package/src/Shared/EIDReader/smartcards/commandAPDU.ts +2 -1
  182. package/src/Shared/EIDReader/tlv/tlv.helpers.ts +1 -1
  183. package/src/Shared/EIDReader/tlv/tlv.utils.ts +8 -5
  184. package/src/Shared/EIDReader/utils/aesCrypto.utils.ts +217 -0
  185. package/src/Shared/Libs/SignalingClient.ts +189 -0
  186. package/src/Shared/Libs/analytics.utils.ts +8 -0
  187. package/src/Shared/Libs/contains.ts +0 -53
  188. package/src/Shared/Libs/country-display.utils.ts +55 -0
  189. package/src/Shared/Libs/crypto.utils.ts +2 -2
  190. package/src/Shared/Libs/deeplink.utils.ts +12 -1
  191. package/src/Shared/Libs/demo.utils.ts +10 -0
  192. package/src/Shared/Libs/http-client.ts +19 -1
  193. package/src/Shared/Libs/mrz.utils.ts +3 -2
  194. package/src/Shared/Libs/promise.utils.ts +16 -2
  195. package/src/Shared/Libs/status-bar.utils.ts +21 -0
  196. package/src/Shared/Services/DataUploadService.ts +395 -0
  197. package/src/Shared/Services/VideoSessionService.ts +190 -0
  198. package/src/Shared/Services/WebRTCService.ts +636 -0
  199. package/src/Shared/Types/analytics.types.ts +4 -0
  200. package/src/Shared/Types/identificationInfo.ts +16 -1
  201. package/src/Translation/Resources/en.ts +88 -3
  202. package/src/Translation/Resources/tr.ts +89 -3
  203. package/src/Trustchex.tsx +65 -19
  204. package/src/version.ts +1 -1
@@ -0,0 +1,1401 @@
1
+ import React, {
2
+ useCallback,
3
+ useContext,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ } from 'react';
9
+ import {
10
+ Animated,
11
+ Easing,
12
+ Platform,
13
+ SafeAreaView,
14
+ StatusBar,
15
+ StyleSheet,
16
+ Text,
17
+ useWindowDimensions,
18
+ View,
19
+ } from 'react-native';
20
+ import type { LayoutChangeEvent } from 'react-native';
21
+ import LottieView from 'lottie-react-native';
22
+ import RNFS from 'react-native-fs';
23
+ import Video from 'react-native-video';
24
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
25
+ import { useTranslation } from 'react-i18next';
26
+ import FaceCamera, { type Face } from '../../Shared/Components/FaceCamera';
27
+ import type { TrustchexCameraHandle } from '../../Shared/Components/TrustchexCamera';
28
+ import NativeCircularProgress from '../../Shared/Components/NativeCircularProgress';
29
+ import StyledButton from '../../Shared/Components/StyledButton';
30
+ import NavigationManager, {
31
+ type NavigationManagerRef,
32
+ } from '../../Shared/Components/NavigationManager';
33
+ import AppContext from '../../Shared/Contexts/AppContext';
34
+ import { useStatusBarWhiteBackground } from '../../Shared/Libs/status-bar.utils';
35
+ import {
36
+ trackError,
37
+ trackVerificationComplete,
38
+ trackVerificationStart,
39
+ useScreenTracking,
40
+ } from '../../Shared/Libs/analytics.utils';
41
+ import { useKeepAwake } from '../../Shared/Libs/native-keep-awake.utils';
42
+ import { type Rect } from '../../Shared/Libs/contains';
43
+ import { speak, stopSpeaking } from '../../Shared/Libs/tts.utils';
44
+
45
+ const PREVIEW_EDGE_OFFSET = 10;
46
+ const PREVIEW_SIZE_RATIO = 0.95;
47
+ const TITLE_ROW_MIN_HEIGHT = 52;
48
+ const INSTRUCTION_ROW_MIN_HEIGHT = 80;
49
+ const ACTIONS_RESERVED_HEIGHT_IDLE = 72;
50
+ const ALIGNMENT_CONFIRM_FRAMES = 3;
51
+ const ALIGNMENT_LOST_FRAMES = 4;
52
+ const CONSENT_LINE_HEIGHT = 44;
53
+ const CONSENT_CIRCLE_INSET = 2;
54
+ const AUTO_SCROLL_MIN_INTERVAL_MS = 2200;
55
+ const HORIZONTAL_SCROLL_START_DELAY_MS = 5000;
56
+ const AUTO_STOP_SPEECH_BUFFER_MS = 1000;
57
+ const TARGET_WORDS_PER_SECOND = 1;
58
+ // Constant scroll speed (pixels per second) so rate is identical regardless of text length.
59
+ const SCROLL_PIXELS_PER_SECOND = 80;
60
+
61
+ const VerbalConsentScreen = () => {
62
+ useKeepAwake();
63
+ useScreenTracking('verbal_consent_video');
64
+ useStatusBarWhiteBackground();
65
+
66
+ const appContext = useContext(AppContext);
67
+ const insets = useSafeAreaInsets();
68
+ const { width: screenWidth } = useWindowDimensions();
69
+ const { t } = useTranslation();
70
+
71
+ const cameraRef = useRef<TrustchexCameraHandle | null>(null);
72
+ const navigationManagerRef = useRef<NavigationManagerRef>(null);
73
+ const [hasGuideShown, setHasGuideShown] = useState(false);
74
+ const [isRecording, setIsRecording] = useState(false);
75
+ const [recordingPath, setRecordingPath] = useState<string>('');
76
+ const [recordedSeconds, setRecordedSeconds] = useState(0);
77
+ const [isFaceInsideCircle, setIsFaceInsideCircle] = useState(false);
78
+ const [multipleFacesDetected, setMultipleFacesDetected] = useState(false);
79
+ const [cameraLayout, setCameraLayout] = useState({ width: 0, height: 0 });
80
+ const [currentLineIndex, setCurrentLineIndex] = useState(0);
81
+ const [isStoppingRecording, setIsStoppingRecording] = useState(false);
82
+ const [activeLineViewportWidth, setActiveLineViewportWidth] = useState(0);
83
+ const [measuredTextWidth, setMeasuredTextWidth] = useState(0);
84
+ const [hasTextScrollStarted, setHasTextScrollStarted] = useState(false);
85
+ const [isAutoScrollPausedByTouch, setIsAutoScrollPausedByTouch] =
86
+ useState(false);
87
+ const autoStartTriggeredRef = useRef(false);
88
+ const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
89
+ const activeLineTranslateX = useRef(new Animated.Value(0)).current;
90
+ const recordingDotOpacity = useRef(new Animated.Value(1)).current;
91
+ const activeLineTranslateXRef = useRef(0);
92
+ const [scrollProgressPercent, setScrollProgressPercent] = useState(0);
93
+ const autoStoppingRef = useRef(false);
94
+ const stopReasonRef = useRef<'manual' | 'discard' | null>(null);
95
+ const stopInProgressRef = useRef(false);
96
+ const autoStopTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
97
+ const isFaceInsideCircleRef = useRef(false);
98
+ const insideFrameStreakRef = useRef(0);
99
+ const outsideFrameStreakRef = useRef(0);
100
+ const scrollNeedsInitRef = useRef(true);
101
+ const initialDelayAppliedRef = useRef(false);
102
+
103
+ const workflowConsentText =
104
+ appContext.currentWorkflowStep?.data?.verbalConsentText?.trim() || '';
105
+ const workflowConsentTitle =
106
+ appContext.currentWorkflowStep?.data?.verbalConsentTitle?.trim() || '';
107
+
108
+ const consentText =
109
+ workflowConsentText || t('verbalConsentVideoScreen.defaultConsentText');
110
+ const consentTitle =
111
+ workflowConsentTitle || t('verbalConsentVideoScreen.title');
112
+
113
+ const normalizedConsentText = consentText.replace(/\s+/g, ' ').trim();
114
+
115
+ // Keep the full consent text as a single horizontally scrolling line.
116
+ const consentLines = useMemo(() => {
117
+ return normalizedConsentText ? [normalizedConsentText] : [];
118
+ }, [normalizedConsentText]);
119
+
120
+ const consentWords = useMemo(
121
+ () => normalizedConsentText.split(' ').filter(Boolean),
122
+ [normalizedConsentText]
123
+ );
124
+ const consentWordCount = Math.max(1, consentWords.length);
125
+ // Conservative read pace to avoid suggesting stop too early.
126
+ const estimatedReadSeconds = Math.max(
127
+ 6,
128
+ Math.ceil(consentWordCount / TARGET_WORDS_PER_SECOND)
129
+ );
130
+ const autoScrollIntervalMs = Math.max(
131
+ AUTO_SCROLL_MIN_INTERVAL_MS,
132
+ Math.round((estimatedReadSeconds * 1000) / Math.max(1, consentLines.length))
133
+ );
134
+ const currentLineText = consentLines[currentLineIndex] || '';
135
+ // Use measured width as soon as available to keep visual text end and
136
+ // progress completion tightly aligned.
137
+ const estimatedLineContentWidth =
138
+ measuredTextWidth > 0 ? measuredTextWidth : currentLineText.length * 24;
139
+ const isConsentComplete = scrollProgressPercent >= 99;
140
+ const currentInstructionKey = (() => {
141
+ if (multipleFacesDetected) {
142
+ return 'livenessDetectionScreen.multipleFacesDetected';
143
+ }
144
+
145
+ if (!isFaceInsideCircle) {
146
+ return 'livenessDetectionScreen.placeFaceInsideCircle';
147
+ }
148
+
149
+ return 'verbalConsentVideoScreen.readNowPrompt';
150
+ })();
151
+
152
+ // Circle stage uses relative layout: title -> circle -> instruction.
153
+ const containerWidth = cameraLayout.width || screenWidth;
154
+ const previewSize = containerWidth * PREVIEW_SIZE_RATIO;
155
+ const titleTopPadding = Math.max(insets.top, 8);
156
+ const previewRect = {
157
+ minX: (containerWidth - previewSize) / 2,
158
+ minY: 0,
159
+ width: previewSize,
160
+ height: previewSize,
161
+ };
162
+ const circleStrokeColor = 'rgba(255, 255, 255, 0.5)';
163
+ const circleDiameter = previewRect.width - CONSENT_CIRCLE_INSET * 2;
164
+ const circleLeft = previewRect.minX + CONSENT_CIRCLE_INSET;
165
+
166
+ useEffect(() => {
167
+ if (hasGuideShown) {
168
+ // Camera view uses dark background, keep status text readable.
169
+ StatusBar.setBarStyle('light-content', true);
170
+ } else {
171
+ // Guide screen uses white background.
172
+ StatusBar.setBarStyle('dark-content', true);
173
+ if (Platform.OS === 'android') {
174
+ StatusBar.setBackgroundColor('#ffffff', true);
175
+ }
176
+ }
177
+ }, [hasGuideShown]);
178
+
179
+ useEffect(() => {
180
+ if (isRecording) {
181
+ const pulse = Animated.loop(
182
+ Animated.sequence([
183
+ Animated.timing(recordingDotOpacity, {
184
+ toValue: 0.2,
185
+ duration: 600,
186
+ easing: Easing.inOut(Easing.ease),
187
+ useNativeDriver: true,
188
+ }),
189
+ Animated.timing(recordingDotOpacity, {
190
+ toValue: 1,
191
+ duration: 600,
192
+ easing: Easing.inOut(Easing.ease),
193
+ useNativeDriver: true,
194
+ }),
195
+ ])
196
+ );
197
+ pulse.start();
198
+ return () => pulse.stop();
199
+ } else {
200
+ recordingDotOpacity.setValue(1);
201
+ }
202
+ }, [isRecording, recordingDotOpacity]);
203
+
204
+ const clearTimer = useCallback(() => {
205
+ if (intervalRef.current) {
206
+ clearInterval(intervalRef.current);
207
+ intervalRef.current = null;
208
+ }
209
+ }, []);
210
+
211
+ const clearAutoStopTimer = useCallback(() => {
212
+ if (autoStopTimeoutRef.current) {
213
+ clearTimeout(autoStopTimeoutRef.current);
214
+ autoStopTimeoutRef.current = null;
215
+ }
216
+ }, []);
217
+
218
+ const handleStartRecording = useCallback(() => {
219
+ if (
220
+ !cameraRef.current ||
221
+ isRecording ||
222
+ !isFaceInsideCircle ||
223
+ multipleFacesDetected
224
+ ) {
225
+ return;
226
+ }
227
+
228
+ setRecordingPath('');
229
+ setRecordedSeconds(0);
230
+ setCurrentLineIndex(0);
231
+ setIsAutoScrollPausedByTouch(false);
232
+ setHasTextScrollStarted(false);
233
+ setScrollProgressPercent(0);
234
+ clearAutoStopTimer();
235
+ stopReasonRef.current = null;
236
+ stopInProgressRef.current = false;
237
+ scrollNeedsInitRef.current = true;
238
+ initialDelayAppliedRef.current = false;
239
+ activeLineTranslateX.stopAnimation();
240
+ activeLineTranslateX.setValue(0);
241
+ activeLineTranslateXRef.current = 0;
242
+ trackVerificationStart('VERBAL_CONSENT');
243
+
244
+ cameraRef.current.startRecording({
245
+ onRecordingFinished: (video) => {
246
+ stopInProgressRef.current = false;
247
+ setIsStoppingRecording(false);
248
+ const stopReason = stopReasonRef.current;
249
+ stopReasonRef.current = null;
250
+
251
+ if (stopReason === 'manual') {
252
+ setRecordingPath(video.path);
253
+ } else {
254
+ // Recording was discarded - reset state for retry
255
+ setRecordingPath('');
256
+ setRecordedSeconds(0);
257
+ setCurrentLineIndex(0);
258
+ setHasTextScrollStarted(false);
259
+ setScrollProgressPercent(0);
260
+ scrollNeedsInitRef.current = true;
261
+ initialDelayAppliedRef.current = false;
262
+ autoStartTriggeredRef.current = false;
263
+
264
+ if (video.path) {
265
+ RNFS.unlink(video.path).catch(() => {
266
+ // Best-effort cleanup for canceled recordings.
267
+ });
268
+ }
269
+ }
270
+ setIsRecording(false);
271
+ clearTimer();
272
+ },
273
+ onRecordingError: (error) => {
274
+ stopInProgressRef.current = false;
275
+ stopReasonRef.current = null;
276
+ setIsRecording(false);
277
+ setIsStoppingRecording(false);
278
+ setCurrentLineIndex(0);
279
+ setHasTextScrollStarted(false);
280
+ autoStartTriggeredRef.current = false;
281
+ clearTimer();
282
+ trackError(
283
+ 'VERBAL_CONSENT_RECORDING_ERROR',
284
+ error.error ||
285
+ t('verbalConsentVideoScreen.analytics.recordingFailed'),
286
+ 'verbal_consent_video',
287
+ 'medium',
288
+ { recoverable: true }
289
+ );
290
+ },
291
+ });
292
+
293
+ setIsRecording(true);
294
+ clearTimer();
295
+ intervalRef.current = setInterval(() => {
296
+ setRecordedSeconds((prev) => prev + 1);
297
+ }, 1000);
298
+ }, [
299
+ isRecording,
300
+ isFaceInsideCircle,
301
+ multipleFacesDetected,
302
+ clearAutoStopTimer,
303
+ activeLineTranslateX,
304
+ clearTimer,
305
+ t,
306
+ ]);
307
+
308
+ const updateStableAlignment = useCallback((isInsideCurrentFrame: boolean) => {
309
+ if (isInsideCurrentFrame) {
310
+ insideFrameStreakRef.current += 1;
311
+ outsideFrameStreakRef.current = 0;
312
+
313
+ if (
314
+ !isFaceInsideCircleRef.current &&
315
+ insideFrameStreakRef.current >= ALIGNMENT_CONFIRM_FRAMES
316
+ ) {
317
+ isFaceInsideCircleRef.current = true;
318
+ setIsFaceInsideCircle(true);
319
+ }
320
+ return;
321
+ }
322
+
323
+ outsideFrameStreakRef.current += 1;
324
+ insideFrameStreakRef.current = 0;
325
+
326
+ if (
327
+ isFaceInsideCircleRef.current &&
328
+ outsideFrameStreakRef.current >= ALIGNMENT_LOST_FRAMES
329
+ ) {
330
+ isFaceInsideCircleRef.current = false;
331
+ setIsFaceInsideCircle(false);
332
+ }
333
+ }, []);
334
+
335
+ const onFacesDetected = useCallback(
336
+ (
337
+ faces: Face[],
338
+ _image: string,
339
+ _isImageBright: boolean,
340
+ frameWidth: number,
341
+ frameHeight: number
342
+ ) => {
343
+ if (!faces.length) {
344
+ if (isRecording) {
345
+ if (stopReasonRef.current === 'manual') {
346
+ return;
347
+ }
348
+
349
+ // Requirement: stop recording as soon as no face is detected.
350
+ isFaceInsideCircleRef.current = false;
351
+ setIsFaceInsideCircle(false);
352
+ insideFrameStreakRef.current = 0;
353
+ outsideFrameStreakRef.current = ALIGNMENT_LOST_FRAMES;
354
+ } else {
355
+ updateStableAlignment(false);
356
+ }
357
+ setMultipleFacesDetected(false);
358
+ return;
359
+ }
360
+
361
+ const multipleFaces = faces.length > 1;
362
+ setMultipleFacesDetected(multipleFaces);
363
+
364
+ const face = faces[0];
365
+ // Camera uses FILL_CENTER / resizeAspectFill (cover): uniform scale filling
366
+ // the container, cropping overflow symmetrically. Map the on-screen circle
367
+ // into frame coordinates accounting for that crop.
368
+ const containerHeight = Math.max(1, cameraLayout.height || previewSize);
369
+ const coverScale = Math.max(
370
+ containerWidth / frameWidth,
371
+ containerHeight / frameHeight
372
+ );
373
+ const offsetX = (frameWidth - containerWidth / coverScale) / 2;
374
+ const offsetY = (frameHeight - containerHeight / coverScale) / 2;
375
+ const previewRectInFrame: Rect = {
376
+ minX: previewRect.minX / coverScale + offsetX,
377
+ minY: previewRect.minY / coverScale + offsetY,
378
+ width: previewRect.width / coverScale,
379
+ height: previewRect.height / coverScale,
380
+ };
381
+
382
+ // Check containment using face center + yaw-stable core radius.
383
+ // When the head turns, the bounding box grows wider but height stays constant,
384
+ // so min(width, height)/2 is a stable representative size.
385
+ const faceCenterX = face.bounds.x + face.bounds.width / 2;
386
+ const faceCenterY = face.bounds.y + face.bounds.height / 2;
387
+ const faceCoreRadius = Math.max(
388
+ 0,
389
+ Math.min(face.bounds.width, face.bounds.height) / 2 -
390
+ PREVIEW_EDGE_OFFSET / 2
391
+ );
392
+ const circleCX = previewRectInFrame.minX + previewRectInFrame.width / 2;
393
+ const circleCY = previewRectInFrame.minY + previewRectInFrame.height / 2;
394
+ const circleR =
395
+ previewRectInFrame.width / 2 - CONSENT_CIRCLE_INSET / coverScale;
396
+ const faceDx = faceCenterX - circleCX;
397
+ const faceDy = faceCenterY - circleCY;
398
+ const previewContainsFace =
399
+ faceDx * faceDx + faceDy * faceDy <=
400
+ (circleR - faceCoreRadius) * (circleR - faceCoreRadius);
401
+
402
+ if (isRecording) {
403
+ if (stopReasonRef.current === 'manual') {
404
+ return;
405
+ }
406
+
407
+ if (!previewContainsFace || multipleFaces) {
408
+ // During recording, cancel immediately when alignment is lost.
409
+ isFaceInsideCircleRef.current = false;
410
+ setIsFaceInsideCircle(false);
411
+ insideFrameStreakRef.current = 0;
412
+ outsideFrameStreakRef.current = ALIGNMENT_LOST_FRAMES;
413
+ return;
414
+ }
415
+
416
+ isFaceInsideCircleRef.current = true;
417
+ setIsFaceInsideCircle(true);
418
+ insideFrameStreakRef.current = ALIGNMENT_CONFIRM_FRAMES;
419
+ outsideFrameStreakRef.current = 0;
420
+ return;
421
+ }
422
+
423
+ updateStableAlignment(previewContainsFace && !multipleFaces);
424
+ },
425
+ [
426
+ cameraLayout.height,
427
+ containerWidth,
428
+ previewRect.minX,
429
+ previewRect.minY,
430
+ previewRect.width,
431
+ previewRect.height,
432
+ previewSize,
433
+ isRecording,
434
+ updateStableAlignment,
435
+ ]
436
+ );
437
+
438
+ const onCameraContainerLayout = useCallback((event: LayoutChangeEvent) => {
439
+ const { width, height } = event.nativeEvent.layout;
440
+ setCameraLayout({ width, height });
441
+ }, []);
442
+
443
+ const handleStopRecording = useCallback(
444
+ async (discard: boolean = false) => {
445
+ if (!cameraRef.current || !isRecording) {
446
+ return;
447
+ }
448
+
449
+ if (stopInProgressRef.current) {
450
+ // If stop is already in progress, allow manual button press to take precedence.
451
+ if (!discard) {
452
+ stopReasonRef.current = 'manual';
453
+ }
454
+ return;
455
+ }
456
+
457
+ // Set immediate visual feedback
458
+ if (!discard) {
459
+ setIsStoppingRecording(true);
460
+ }
461
+
462
+ stopReasonRef.current = discard ? 'discard' : 'manual';
463
+ stopInProgressRef.current = true;
464
+
465
+ await cameraRef.current.stopRecording();
466
+ // Do NOT setIsRecording(false) here — let onRecordingFinished handle it
467
+ // atomically together with setRecordingPath to avoid a flash of the
468
+ // start-recording UI between stop and preview.
469
+ },
470
+ [isRecording]
471
+ );
472
+
473
+ const handleTryAgain = useCallback(() => {
474
+ clearTimer();
475
+ clearAutoStopTimer();
476
+ setIsRecording(false);
477
+ setRecordingPath('');
478
+ setRecordedSeconds(0);
479
+ setCurrentLineIndex(0);
480
+ setIsStoppingRecording(false);
481
+ setMultipleFacesDetected(false);
482
+ setIsFaceInsideCircle(false);
483
+ setScrollProgressPercent(0);
484
+ autoStartTriggeredRef.current = false;
485
+ stopReasonRef.current = null;
486
+ stopInProgressRef.current = false;
487
+ isFaceInsideCircleRef.current = false;
488
+ insideFrameStreakRef.current = 0;
489
+ outsideFrameStreakRef.current = 0;
490
+ scrollNeedsInitRef.current = true;
491
+ initialDelayAppliedRef.current = false;
492
+ }, [clearAutoStopTimer, clearTimer]);
493
+
494
+ const persistVerbalConsentData = useCallback(() => {
495
+ if (!recordingPath) {
496
+ return;
497
+ }
498
+
499
+ const existingVideos =
500
+ appContext.identificationInfo.verbalConsentVideos || [];
501
+ appContext.identificationInfo.verbalConsentVideos = [
502
+ ...existingVideos,
503
+ {
504
+ title: consentTitle,
505
+ text: consentText,
506
+ videoPath: recordingPath,
507
+ recordedSeconds,
508
+ },
509
+ ];
510
+ }, [
511
+ appContext.identificationInfo,
512
+ consentText,
513
+ consentTitle,
514
+ recordedSeconds,
515
+ recordingPath,
516
+ ]);
517
+
518
+ const submitVerbalConsent = useCallback(() => {
519
+ if (!recordingPath) {
520
+ return;
521
+ }
522
+
523
+ persistVerbalConsentData();
524
+ trackVerificationComplete('VERBAL_CONSENT', true, 1);
525
+ navigationManagerRef.current?.navigateToNextStep();
526
+ }, [persistVerbalConsentData, recordingPath]);
527
+
528
+ const canSubmit = !!recordingPath && !isRecording;
529
+ const isPreviewAvailable = !!recordingPath && !isRecording;
530
+ // Show text as soon as face is inside circle so user can read what to say.
531
+ const shouldShowTextOverlay = isFaceInsideCircle && hasGuideShown;
532
+
533
+ // Auto-start recording when face is aligned
534
+ useEffect(() => {
535
+ if (!hasGuideShown) {
536
+ return;
537
+ }
538
+
539
+ if (isRecording || isPreviewAvailable || autoStartTriggeredRef.current) {
540
+ return;
541
+ }
542
+
543
+ if (isFaceInsideCircle && !multipleFacesDetected) {
544
+ autoStartTriggeredRef.current = true;
545
+ handleStartRecording();
546
+ }
547
+ }, [
548
+ isFaceInsideCircle,
549
+ isRecording,
550
+ isPreviewAvailable,
551
+ multipleFacesDetected,
552
+ handleStartRecording,
553
+ hasGuideShown,
554
+ ]);
555
+
556
+ useEffect(() => {
557
+ if (!isRecording) {
558
+ autoStoppingRef.current = false;
559
+ return;
560
+ }
561
+
562
+ if (stopReasonRef.current === 'manual') {
563
+ return;
564
+ }
565
+
566
+ if (isFaceInsideCircle && !multipleFacesDetected) {
567
+ return;
568
+ }
569
+
570
+ if (autoStoppingRef.current) {
571
+ return;
572
+ }
573
+
574
+ // Immediately reset scroll and progress so the UI is clean before the
575
+ // async stopRecording resolves.
576
+ activeLineTranslateX.stopAnimation();
577
+ activeLineTranslateX.setValue(0);
578
+ activeLineTranslateXRef.current = 0;
579
+ scrollNeedsInitRef.current = true;
580
+ initialDelayAppliedRef.current = false;
581
+ setScrollProgressPercent(0);
582
+ clearAutoStopTimer();
583
+
584
+ stopReasonRef.current = 'discard';
585
+ autoStoppingRef.current = true;
586
+ handleStopRecording(true).finally(() => {
587
+ autoStoppingRef.current = false;
588
+ });
589
+ }, [
590
+ activeLineTranslateX,
591
+ clearAutoStopTimer,
592
+ handleStopRecording,
593
+ isFaceInsideCircle,
594
+ isRecording,
595
+ multipleFacesDetected,
596
+ ]);
597
+
598
+ useEffect(() => {
599
+ const startX = Math.max(0, activeLineViewportWidth);
600
+ // Complete when the full text has moved out of view to the left.
601
+ const endX = -Math.max(1, estimatedLineContentWidth);
602
+ const totalDistance = Math.max(1, startX - endX);
603
+ const listenerId = activeLineTranslateX.addListener(({ value }) => {
604
+ activeLineTranslateXRef.current = value;
605
+ // value goes from startX (offscreen right) to endX (offscreen left)
606
+ // traveled = startX - value, so 0 at start and totalDistance at end
607
+ const traveled = Math.max(0, startX - value);
608
+ const pct = Math.min(100, (traveled / totalDistance) * 100);
609
+ setScrollProgressPercent(pct);
610
+ });
611
+
612
+ return () => {
613
+ activeLineTranslateX.removeListener(listenerId);
614
+ };
615
+ }, [
616
+ activeLineTranslateX,
617
+ estimatedLineContentWidth,
618
+ activeLineViewportWidth,
619
+ isRecording,
620
+ ]);
621
+
622
+ useEffect(() => {
623
+ if (!isRecording) {
624
+ setHasTextScrollStarted(false);
625
+ }
626
+ }, [isRecording]);
627
+
628
+ useEffect(() => {
629
+ if (!isRecording || activeLineViewportWidth <= 0) {
630
+ activeLineTranslateX.stopAnimation();
631
+ activeLineTranslateX.setValue(0);
632
+ activeLineTranslateXRef.current = 0;
633
+ scrollNeedsInitRef.current = true;
634
+ setHasTextScrollStarted(false);
635
+ return;
636
+ }
637
+
638
+ const contentWidth = Math.max(1, estimatedLineContentWidth);
639
+
640
+ if (isAutoScrollPausedByTouch) {
641
+ activeLineTranslateX.stopAnimation((value) => {
642
+ activeLineTranslateXRef.current = value;
643
+ });
644
+ return;
645
+ }
646
+
647
+ const maxX = activeLineViewportWidth;
648
+ const minX = -contentWidth;
649
+
650
+ // First start after reset: position text at the right edge of the circle
651
+ if (scrollNeedsInitRef.current) {
652
+ scrollNeedsInitRef.current = false;
653
+ activeLineTranslateX.setValue(maxX);
654
+ activeLineTranslateXRef.current = maxX;
655
+ }
656
+
657
+ const clampedCurrentX = Math.max(
658
+ minX,
659
+ Math.min(maxX, activeLineTranslateXRef.current)
660
+ );
661
+ const remainingDistance = Math.abs(minX - clampedCurrentX);
662
+
663
+ if (remainingDistance <= 0.5) {
664
+ activeLineTranslateX.setValue(minX);
665
+ activeLineTranslateXRef.current = minX;
666
+ return;
667
+ }
668
+
669
+ const totalRange = maxX - minX;
670
+ const lineDurationMs = Math.max(
671
+ 3000,
672
+ Math.round((totalRange / SCROLL_PIXELS_PER_SECOND) * 1000)
673
+ );
674
+ const progressRatio =
675
+ totalRange > 0 ? Math.min(1, (maxX - clampedCurrentX) / totalRange) : 0;
676
+ const remainingDurationMs = Math.max(
677
+ 500,
678
+ Math.round(lineDurationMs * (1 - progressRatio))
679
+ );
680
+
681
+ activeLineTranslateX.stopAnimation();
682
+ activeLineTranslateX.setValue(clampedCurrentX);
683
+
684
+ const shouldApplyDelay =
685
+ !initialDelayAppliedRef.current && clampedCurrentX >= maxX - 1;
686
+ if (shouldApplyDelay) {
687
+ initialDelayAppliedRef.current = true;
688
+ // Show text only when animation is about to actually start moving
689
+ setTimeout(() => {
690
+ setHasTextScrollStarted(true);
691
+ }, HORIZONTAL_SCROLL_START_DELAY_MS);
692
+ } else {
693
+ // No delay, show immediately
694
+ setHasTextScrollStarted(true);
695
+ }
696
+
697
+ Animated.timing(activeLineTranslateX, {
698
+ toValue: minX,
699
+ duration: remainingDurationMs,
700
+ delay: shouldApplyDelay ? HORIZONTAL_SCROLL_START_DELAY_MS : 0,
701
+ easing: Easing.linear,
702
+ useNativeDriver: false,
703
+ }).start((result) => {
704
+ if (result.finished) {
705
+ setScrollProgressPercent(100);
706
+ }
707
+ });
708
+ }, [
709
+ activeLineTranslateX,
710
+ activeLineViewportWidth,
711
+ currentLineIndex,
712
+ currentLineText,
713
+ estimatedLineContentWidth,
714
+ isAutoScrollPausedByTouch,
715
+ isRecording,
716
+ shouldShowTextOverlay,
717
+ consentLines,
718
+ ]);
719
+
720
+ useEffect(() => {
721
+ if (!isRecording || !shouldShowTextOverlay || isAutoScrollPausedByTouch) {
722
+ return;
723
+ }
724
+
725
+ if (
726
+ currentLineIndex >= consentLines.length - 1 ||
727
+ consentLines.length <= 1
728
+ ) {
729
+ return;
730
+ }
731
+
732
+ const intervalId = setInterval(() => {
733
+ setCurrentLineIndex((prev) => {
734
+ if (prev >= consentLines.length - 1) {
735
+ return prev;
736
+ }
737
+
738
+ return prev + 1;
739
+ });
740
+ }, autoScrollIntervalMs);
741
+
742
+ return () => {
743
+ clearInterval(intervalId);
744
+ };
745
+ }, [
746
+ autoScrollIntervalMs,
747
+ consentLines.length,
748
+ currentLineIndex,
749
+ isAutoScrollPausedByTouch,
750
+ isRecording,
751
+ shouldShowTextOverlay,
752
+ ]);
753
+
754
+ useEffect(() => {
755
+ if (!isRecording || consentLines.length === 0 || isStoppingRecording) {
756
+ clearAutoStopTimer();
757
+ return;
758
+ }
759
+
760
+ if (!isConsentComplete) {
761
+ clearAutoStopTimer();
762
+ return;
763
+ }
764
+
765
+ if (autoStopTimeoutRef.current || stopInProgressRef.current) {
766
+ return;
767
+ }
768
+
769
+ // Give a brief tail buffer so users can finish the final spoken word.
770
+ autoStopTimeoutRef.current = setTimeout(() => {
771
+ autoStopTimeoutRef.current = null;
772
+ handleStopRecording(false);
773
+ }, AUTO_STOP_SPEECH_BUFFER_MS);
774
+
775
+ return () => {
776
+ clearAutoStopTimer();
777
+ };
778
+ }, [
779
+ clearAutoStopTimer,
780
+ handleStopRecording,
781
+ isConsentComplete,
782
+ isRecording,
783
+ isStoppingRecording,
784
+ consentLines.length,
785
+ ]);
786
+
787
+ useEffect(() => {
788
+ return () => {
789
+ clearAutoStopTimer();
790
+ };
791
+ }, [clearAutoStopTimer]);
792
+
793
+ useEffect(() => {
794
+ if (!hasGuideShown) {
795
+ return;
796
+ }
797
+
798
+ if (!appContext.currentWorkflowStep?.data?.voiceGuidanceActive) {
799
+ return;
800
+ }
801
+ if (!currentInstructionKey) {
802
+ return;
803
+ }
804
+ speak(t(currentInstructionKey));
805
+ }, [
806
+ appContext.currentWorkflowStep?.data?.voiceGuidanceActive,
807
+ currentInstructionKey,
808
+ hasGuideShown,
809
+ t,
810
+ ]);
811
+
812
+ useEffect(() => {
813
+ return () => {
814
+ stopSpeaking().catch(() => {});
815
+ };
816
+ }, []);
817
+
818
+ return (
819
+ <>
820
+ {!hasGuideShown ? (
821
+ <View
822
+ style={[
823
+ styles.guide,
824
+ { paddingTop: insets.top, paddingBottom: insets.bottom },
825
+ ]}
826
+ >
827
+ <LottieView
828
+ source={require('../../Shared/Animations/recording.json')}
829
+ style={styles.guideAnimation}
830
+ loop={true}
831
+ autoPlay
832
+ />
833
+ <Text style={styles.guideHeader}>
834
+ {t('verbalConsentVideoScreen.guideHeader')}
835
+ </Text>
836
+ <View style={styles.guidePoints}>
837
+ <Text style={styles.guideText}>
838
+ {t('verbalConsentVideoScreen.guideText')}
839
+ </Text>
840
+ <Text style={styles.guideText}>
841
+ • {t('verbalConsentVideoScreen.guidePoint1')}
842
+ </Text>
843
+ <Text style={styles.guideText}>
844
+ • {t('verbalConsentVideoScreen.guidePoint2')}
845
+ </Text>
846
+ <Text style={styles.guideText}>
847
+ • {t('verbalConsentVideoScreen.guidePoint3')}
848
+ </Text>
849
+ <Text style={styles.guideText}>
850
+ • {t('verbalConsentVideoScreen.guidePoint4')}
851
+ </Text>
852
+ </View>
853
+ <StyledButton
854
+ mode="contained"
855
+ onPress={() => {
856
+ setHasGuideShown(true);
857
+ }}
858
+ >
859
+ {t('general.letsGo')}
860
+ </StyledButton>
861
+ </View>
862
+ ) : (
863
+ <SafeAreaView style={styles.container}>
864
+ <View
865
+ style={[
866
+ styles.cameraContainer,
867
+ isPreviewAvailable
868
+ ? styles.cameraContainerPreview
869
+ : styles.cameraContainerPreview,
870
+ ]}
871
+ >
872
+ {isPreviewAvailable ? (
873
+ <View
874
+ style={[
875
+ styles.previewContainer,
876
+ {
877
+ paddingTop: Math.max(insets.top, 12),
878
+ paddingLeft: 16 + insets.left,
879
+ paddingRight: 16 + insets.right,
880
+ paddingBottom: Math.max(insets.bottom, 12),
881
+ },
882
+ ]}
883
+ >
884
+ <Video
885
+ source={{ uri: recordingPath }}
886
+ resizeMode="contain"
887
+ style={styles.previewVideo}
888
+ controls={true}
889
+ muted={false}
890
+ />
891
+ <View style={styles.previewActionsContainer}>
892
+ <Text style={styles.previewActionHint}>
893
+ {t('verbalConsentVideoScreen.previewTitle')}
894
+ </Text>
895
+ <StyledButton mode="contained" onPress={handleTryAgain}>
896
+ {t('verbalConsentVideoScreen.tryAgain')}
897
+ </StyledButton>
898
+ <StyledButton
899
+ mode="outlined"
900
+ onPress={submitVerbalConsent}
901
+ disabled={!canSubmit}
902
+ >
903
+ {t('verbalConsentVideoScreen.submit')}
904
+ </StyledButton>
905
+ </View>
906
+ </View>
907
+ ) : (
908
+ <View
909
+ style={[
910
+ styles.captureSection,
911
+ {
912
+ paddingLeft: insets.left,
913
+ paddingRight: insets.right,
914
+ },
915
+ ]}
916
+ onTouchStart={() => {
917
+ if (
918
+ isRecording &&
919
+ shouldShowTextOverlay &&
920
+ !isAutoScrollPausedByTouch
921
+ ) {
922
+ setIsAutoScrollPausedByTouch(true);
923
+ }
924
+ }}
925
+ onTouchEnd={() => {
926
+ if (
927
+ isRecording &&
928
+ shouldShowTextOverlay &&
929
+ isAutoScrollPausedByTouch
930
+ ) {
931
+ setIsAutoScrollPausedByTouch(false);
932
+ }
933
+ }}
934
+ onTouchCancel={() => {
935
+ if (
936
+ isRecording &&
937
+ shouldShowTextOverlay &&
938
+ isAutoScrollPausedByTouch
939
+ ) {
940
+ setIsAutoScrollPausedByTouch(false);
941
+ }
942
+ }}
943
+ >
944
+ {/* Grouped content: title, circle, instruction, stop button */}
945
+ <View style={styles.groupedContentContainer}>
946
+ {/* Title */}
947
+ <View
948
+ style={[styles.titleRow, { paddingTop: titleTopPadding }]}
949
+ >
950
+ {isRecording ? (
951
+ <View style={styles.recordingIndicator}>
952
+ <Animated.View
953
+ style={[
954
+ styles.recordingDot,
955
+ { opacity: recordingDotOpacity },
956
+ ]}
957
+ />
958
+ <Text style={styles.recordingText}>REC</Text>
959
+ </View>
960
+ ) : (
961
+ <Text
962
+ style={styles.topTitleText}
963
+ numberOfLines={2}
964
+ ellipsizeMode="tail"
965
+ >
966
+ {consentTitle}
967
+ </Text>
968
+ )}
969
+ </View>
970
+
971
+ {/* Circle */}
972
+ <View
973
+ style={[styles.circleStage, { height: previewSize }]}
974
+ onLayout={onCameraContainerLayout}
975
+ >
976
+ <View
977
+ style={[
978
+ styles.cameraCircle,
979
+ {
980
+ width: circleDiameter,
981
+ height: circleDiameter,
982
+ left: circleLeft,
983
+ top: CONSENT_CIRCLE_INSET,
984
+ borderRadius: circleDiameter / 2,
985
+ },
986
+ ]}
987
+ >
988
+ <FaceCamera
989
+ onCameraInitialized={(camera) => {
990
+ cameraRef.current = camera;
991
+ }}
992
+ onFacesDetected={onFacesDetected}
993
+ />
994
+ </View>
995
+
996
+ {/* Progress arc with rail — matches liveness screen */}
997
+ <NativeCircularProgress
998
+ style={[
999
+ styles.circleProgress,
1000
+ { left: previewRect.minX },
1001
+ ]}
1002
+ size={previewRect.width}
1003
+ width={8}
1004
+ backgroundWidth={8}
1005
+ fill={isRecording ? scrollProgressPercent : 0}
1006
+ tintColor={
1007
+ isRecording
1008
+ ? appContext.branding.primaryColor
1009
+ : 'transparent'
1010
+ }
1011
+ backgroundColor={
1012
+ shouldShowTextOverlay
1013
+ ? 'rgba(255, 255, 255, 0.3)'
1014
+ : circleStrokeColor
1015
+ }
1016
+ />
1017
+
1018
+ {/* Scrollable text area – fills the full circle, clipped */}
1019
+ <View
1020
+ style={[
1021
+ styles.textSingleLineContainer,
1022
+ {
1023
+ width: circleDiameter,
1024
+ height: circleDiameter,
1025
+ left: circleLeft,
1026
+ top: CONSENT_CIRCLE_INSET,
1027
+ borderRadius: circleDiameter / 2,
1028
+ },
1029
+ (!shouldShowTextOverlay || !hasTextScrollStarted) &&
1030
+ styles.hiddenOffscreen,
1031
+ ]}
1032
+ >
1033
+ <View
1034
+ style={styles.textSingleLineInner}
1035
+ onLayout={(event) => {
1036
+ setActiveLineViewportWidth(
1037
+ event.nativeEvent.layout.width
1038
+ );
1039
+ }}
1040
+ >
1041
+ <View
1042
+ style={[
1043
+ styles.textLineContainer,
1044
+ styles.textLineHighlighted,
1045
+ ]}
1046
+ >
1047
+ <View style={styles.activeLineViewport}>
1048
+ <Animated.View
1049
+ style={[
1050
+ styles.activeLineTrack,
1051
+ {
1052
+ transform: [
1053
+ { translateX: activeLineTranslateX },
1054
+ ],
1055
+ },
1056
+ ]}
1057
+ >
1058
+ <Text
1059
+ style={[
1060
+ styles.consentTextLine,
1061
+ styles.consentTextLineActive,
1062
+ ]}
1063
+ numberOfLines={1}
1064
+ >
1065
+ {consentLines[currentLineIndex] || ''}
1066
+ </Text>
1067
+ </Animated.View>
1068
+ </View>
1069
+ </View>
1070
+ </View>
1071
+ </View>
1072
+ </View>
1073
+
1074
+ {/* Instruction - always rendered to prevent layout shift */}
1075
+ <View style={styles.instructionRow}>
1076
+ <Text
1077
+ style={[
1078
+ styles.circleInstructionText,
1079
+ !currentInstructionKey && styles.hidden,
1080
+ ]}
1081
+ numberOfLines={2}
1082
+ ellipsizeMode="tail"
1083
+ >
1084
+ {currentInstructionKey ? t(currentInstructionKey) : ' '}
1085
+ </Text>
1086
+ </View>
1087
+ </View>
1088
+ </View>
1089
+ )}
1090
+ </View>
1091
+
1092
+ {/* Off-screen text for reliable width measurement (Android wraps
1093
+ absolutely-positioned text at the parent width, so onTextLayout
1094
+ inside the circle reports only the first wrapped line). */}
1095
+ <Text
1096
+ style={[
1097
+ styles.consentTextLine,
1098
+ styles.consentTextLineActive,
1099
+ styles.offscreenMeasure,
1100
+ ]}
1101
+ numberOfLines={1}
1102
+ onTextLayout={(e) => {
1103
+ const lineWidth = e.nativeEvent.lines[0]?.width ?? 0;
1104
+ if (lineWidth > 0) {
1105
+ setMeasuredTextWidth(Math.ceil(lineWidth));
1106
+ }
1107
+ }}
1108
+ >
1109
+ {consentLines[currentLineIndex] || ''}
1110
+ </Text>
1111
+ </SafeAreaView>
1112
+ )}
1113
+ <View style={[styles.footer, { bottom: insets.bottom }]}>
1114
+ <NavigationManager ref={navigationManagerRef} />
1115
+ </View>
1116
+ </>
1117
+ );
1118
+ };
1119
+
1120
+ const styles = StyleSheet.create({
1121
+ container: {
1122
+ flex: 1,
1123
+ backgroundColor: '#ffffff',
1124
+ },
1125
+ footer: {
1126
+ position: 'absolute',
1127
+ flex: 0,
1128
+ bottom: 0,
1129
+ width: '100%',
1130
+ zIndex: 1,
1131
+ },
1132
+ guide: {
1133
+ flex: 1,
1134
+ display: 'flex',
1135
+ justifyContent: 'center',
1136
+ paddingHorizontal: 20,
1137
+ gap: 10,
1138
+ backgroundColor: 'white',
1139
+ },
1140
+ guideAnimation: {
1141
+ width: 250,
1142
+ height: 250,
1143
+ alignSelf: 'center',
1144
+ },
1145
+ guideHeader: {
1146
+ color: 'black',
1147
+ fontSize: 18,
1148
+ textAlign: 'center',
1149
+ fontWeight: 'bold',
1150
+ },
1151
+ guidePoints: {
1152
+ display: 'flex',
1153
+ gap: 10,
1154
+ padding: 10,
1155
+ },
1156
+ guideText: {
1157
+ color: 'black',
1158
+ fontSize: 14,
1159
+ },
1160
+ cameraContainer: {
1161
+ width: '100%',
1162
+ },
1163
+ cameraContainerPreview: {
1164
+ flex: 1,
1165
+ },
1166
+ captureSection: {
1167
+ flex: 1,
1168
+ overflow: 'hidden' as const,
1169
+ backgroundColor: '#ffffff',
1170
+ justifyContent: 'center',
1171
+ alignItems: 'center',
1172
+ },
1173
+ groupedContentContainer: {
1174
+ width: '100%',
1175
+ alignItems: 'center',
1176
+ },
1177
+ titleRow: {
1178
+ minHeight: TITLE_ROW_MIN_HEIGHT,
1179
+ justifyContent: 'center',
1180
+ alignItems: 'center',
1181
+ paddingHorizontal: 16,
1182
+ marginBottom: 16,
1183
+ },
1184
+ recordingIndicator: {
1185
+ flexDirection: 'row',
1186
+ alignItems: 'center',
1187
+ gap: 8,
1188
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
1189
+ paddingVertical: 6,
1190
+ paddingHorizontal: 14,
1191
+ borderRadius: 20,
1192
+ },
1193
+ recordingDot: {
1194
+ width: 10,
1195
+ height: 10,
1196
+ borderRadius: 5,
1197
+ backgroundColor: '#ef4444',
1198
+ },
1199
+ recordingText: {
1200
+ color: '#ffffff',
1201
+ fontSize: 14,
1202
+ fontWeight: '700',
1203
+ letterSpacing: 1,
1204
+ },
1205
+ circleStage: {
1206
+ position: 'relative',
1207
+ width: '100%',
1208
+ backgroundColor: '#ffffff',
1209
+ },
1210
+ cameraCircle: {
1211
+ position: 'absolute',
1212
+ overflow: 'hidden',
1213
+ },
1214
+ instructionRow: {
1215
+ height: INSTRUCTION_ROW_MIN_HEIGHT,
1216
+ justifyContent: 'center',
1217
+ alignItems: 'center',
1218
+ paddingHorizontal: 16,
1219
+ paddingTop: 10,
1220
+ marginTop: 16,
1221
+ overflow: 'hidden' as const,
1222
+ },
1223
+ instructionRowHidden: {
1224
+ opacity: 0,
1225
+ },
1226
+
1227
+ topTitleText: {
1228
+ fontSize: 18,
1229
+ fontWeight: '700',
1230
+ color: '#111827',
1231
+ textAlign: 'center',
1232
+ backgroundColor: 'rgba(255,255,255,0.95)',
1233
+ paddingVertical: 6,
1234
+ paddingHorizontal: 10,
1235
+ borderRadius: 10,
1236
+ },
1237
+ circleInstructionText: {
1238
+ color: '#111827',
1239
+ fontSize: 18,
1240
+ fontWeight: '700',
1241
+ textAlign: 'center',
1242
+ backgroundColor: 'rgba(255,255,255,0.92)',
1243
+ paddingVertical: 10,
1244
+ paddingHorizontal: 14,
1245
+ borderRadius: 8,
1246
+ lineHeight: 24,
1247
+ },
1248
+ previewContainer: {
1249
+ flex: 1,
1250
+ backgroundColor: '#ffffff',
1251
+ paddingHorizontal: 16,
1252
+ paddingTop: 16,
1253
+ paddingBottom: 12,
1254
+ justifyContent: 'flex-start',
1255
+ },
1256
+ previewActionsContainer: {
1257
+ paddingTop: 10,
1258
+ gap: 8,
1259
+ },
1260
+ previewVideo: {
1261
+ width: '100%',
1262
+ height: '64%',
1263
+ borderRadius: 8,
1264
+ backgroundColor: '#000000',
1265
+ alignSelf: 'center',
1266
+ borderWidth: 1,
1267
+ borderColor: '#e5e7eb',
1268
+ },
1269
+ actionsContainerNearCircle: {
1270
+ gap: 8,
1271
+ paddingTop: 8,
1272
+ paddingHorizontal: 20,
1273
+ minHeight: ACTIONS_RESERVED_HEIGHT_IDLE,
1274
+ },
1275
+ actionsContainerHidden: {
1276
+ opacity: 0,
1277
+ pointerEvents: 'none' as const,
1278
+ },
1279
+ actionsContainerNearCircleRecording: {
1280
+ minHeight: ACTIONS_RESERVED_HEIGHT_IDLE,
1281
+ paddingTop: 8,
1282
+ },
1283
+ arrowButtonCircle: {
1284
+ position: 'absolute',
1285
+ width: 56,
1286
+ height: 56,
1287
+ borderRadius: 28,
1288
+ backgroundColor: '#ffffff',
1289
+ justifyContent: 'center',
1290
+ alignItems: 'center',
1291
+ shadowColor: '#000',
1292
+ shadowOffset: { width: 0, height: 2 },
1293
+ shadowOpacity: 0.15,
1294
+ shadowRadius: 6,
1295
+ elevation: 4,
1296
+ borderWidth: 2,
1297
+ borderColor: '#e5e7eb',
1298
+ },
1299
+ arrowTextCircle: {
1300
+ fontSize: 26,
1301
+ color: '#1f2937',
1302
+ fontWeight: '700',
1303
+ },
1304
+ textSingleLineContainer: {
1305
+ position: 'absolute',
1306
+ backgroundColor: 'rgba(255, 255, 255, 0.5)',
1307
+ overflow: 'hidden',
1308
+ justifyContent: 'center',
1309
+ },
1310
+ textSingleLineInner: {
1311
+ flex: 1,
1312
+ justifyContent: 'center',
1313
+ paddingHorizontal: 0,
1314
+ },
1315
+ textLineContainer: {
1316
+ height: CONSENT_LINE_HEIGHT,
1317
+ paddingHorizontal: 0,
1318
+ marginVertical: 0,
1319
+ borderRadius: 0,
1320
+ backgroundColor: 'transparent',
1321
+ justifyContent: 'center',
1322
+ },
1323
+ textLineHighlighted: {
1324
+ backgroundColor: 'transparent',
1325
+ },
1326
+ activeLineViewport: {
1327
+ width: '100%',
1328
+ height: CONSENT_LINE_HEIGHT,
1329
+ overflow: 'hidden',
1330
+ },
1331
+ activeLineTrack: {
1332
+ position: 'absolute',
1333
+ top: 0,
1334
+ bottom: 0,
1335
+ width: 9999,
1336
+ justifyContent: 'center',
1337
+ },
1338
+ consentTextLine: {
1339
+ fontSize: 14,
1340
+ color: 'rgba(156, 163, 175, 0.6)',
1341
+ lineHeight: 22,
1342
+ fontWeight: '400',
1343
+ textAlign: 'left',
1344
+ },
1345
+ consentTextLineActive: {
1346
+ fontSize: 32,
1347
+ color: '#111827',
1348
+ fontWeight: '900',
1349
+ lineHeight: 44,
1350
+ },
1351
+ circleProgress: {
1352
+ position: 'absolute',
1353
+ top: 0,
1354
+ zIndex: 2,
1355
+ pointerEvents: 'none' as const,
1356
+ },
1357
+ offscreenMeasure: {
1358
+ position: 'absolute',
1359
+ top: -9999,
1360
+ left: 0,
1361
+ opacity: 0,
1362
+ width: 9999,
1363
+ },
1364
+ hidden: {
1365
+ opacity: 0,
1366
+ },
1367
+ hiddenOffscreen: {
1368
+ opacity: 0,
1369
+ transform: [{ translateY: -10000 }],
1370
+ },
1371
+ arrowButtonDisabled: {
1372
+ backgroundColor: '#e5e7eb',
1373
+ shadowOpacity: 0,
1374
+ elevation: 0,
1375
+ },
1376
+ arrowTextDisabled: {
1377
+ color: '#9ca3af',
1378
+ },
1379
+ previewActionHint: {
1380
+ fontSize: 13,
1381
+ color: '#4b5563',
1382
+ textAlign: 'center',
1383
+ },
1384
+ recordingBlockedContainer: {
1385
+ borderRadius: 10,
1386
+ backgroundColor: '#ffffff',
1387
+ borderWidth: 1,
1388
+ borderColor: '#d1d5db',
1389
+ paddingVertical: 16,
1390
+ paddingHorizontal: 14,
1391
+ },
1392
+ recordingBlockedText: {
1393
+ fontSize: 16,
1394
+ color: '#111827',
1395
+ fontWeight: '600',
1396
+ textAlign: 'center',
1397
+ lineHeight: 22,
1398
+ },
1399
+ });
1400
+
1401
+ export default VerbalConsentScreen;