@nebula-rn/host-apis 0.0.1

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.
@@ -0,0 +1,263 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import {
3
+ Dimensions,
4
+ Image,
5
+ PermissionsAndroid,
6
+ Platform,
7
+ StatusBar,
8
+ StyleSheet,
9
+ TouchableOpacity,
10
+ View,
11
+ } from 'react-native';
12
+ import {
13
+ Camera,
14
+ useCameraDevice,
15
+ useCodeScanner,
16
+ } from 'react-native-vision-camera';
17
+ import type { Code, CodeType } from 'react-native-vision-camera';
18
+ import { NebulaAPI } from '@nebula-rn/sdk';
19
+ import type { NebulaApiInvokeResult } from '@nebula-rn/sdk';
20
+ import { scanCodeChannel } from './scanCodeRuntime';
21
+
22
+ const { width, height } = Dimensions.get('screen');
23
+
24
+ function createFailureResult(
25
+ code:
26
+ | 'PERMISSION_DENIED'
27
+ | 'USER_CANCELLED'
28
+ | 'UNSUPPORTED_SOURCE'
29
+ | 'SCAN_FAILED',
30
+ message: string,
31
+ ): NebulaApiInvokeResult {
32
+ return {
33
+ ok: false,
34
+ error: {
35
+ code,
36
+ message,
37
+ },
38
+ };
39
+ }
40
+
41
+ function createSuccessResult(code: Code): NebulaApiInvokeResult {
42
+ return {
43
+ ok: true,
44
+ data: {
45
+ result: code.value,
46
+ scanType: code.type,
47
+ rawData: code,
48
+ },
49
+ };
50
+ }
51
+
52
+ export default function NebulaHostScanModal(): React.ReactElement | null {
53
+ const [request, setRequest] = useState(scanCodeChannel.getCurrent());
54
+ const [isActive, setIsActive] = useState(true);
55
+ const [hasPermission, setHasPermission] = useState(Platform.OS !== 'android');
56
+ const didHandleScanRef = useRef(false);
57
+ const device = useCameraDevice('back');
58
+ const scanTypes = (request?.scanTypes ?? [
59
+ 'qr',
60
+ 'ean-13',
61
+ 'code-128',
62
+ ]) as CodeType[];
63
+ const codeScanner = useCodeScanner({
64
+ codeTypes: scanTypes,
65
+ onCodeScanned: codes => {
66
+ if (!codes[0] || !isActive || didHandleScanRef.current) {
67
+ return;
68
+ }
69
+
70
+ didHandleScanRef.current = true;
71
+ setIsActive(false);
72
+ closeWithResult(createSuccessResult(codes[0]));
73
+ },
74
+ });
75
+
76
+ const closeWithResult = (result: NebulaApiInvokeResult) => {
77
+ NebulaAPI.dismissHostModal()
78
+ .catch(() => {})
79
+ .finally(() => {
80
+ scanCodeChannel.settle(result);
81
+ });
82
+ };
83
+
84
+ useEffect(() => {
85
+ return scanCodeChannel.subscribe(nextRequest => {
86
+ didHandleScanRef.current = false;
87
+ setRequest(nextRequest);
88
+ setIsActive(true);
89
+ setHasPermission(Platform.OS !== 'android');
90
+ });
91
+ }, []);
92
+
93
+ useEffect(() => {
94
+ if (!request) {
95
+ return;
96
+ }
97
+
98
+ return () => {
99
+ if (didHandleScanRef.current) {
100
+ return;
101
+ }
102
+ didHandleScanRef.current = true;
103
+ scanCodeChannel.settle(
104
+ createFailureResult('USER_CANCELLED', 'scanCode:fail cancel'),
105
+ );
106
+ };
107
+ }, [request]);
108
+
109
+ useEffect(() => {
110
+ if (Platform.OS !== 'android' || !request) {
111
+ return;
112
+ }
113
+
114
+ let cancelled = false;
115
+
116
+ const requestAndroidPermission = async () => {
117
+ const granted = await PermissionsAndroid.request(
118
+ PermissionsAndroid.PERMISSIONS.CAMERA,
119
+ );
120
+
121
+ if (cancelled || didHandleScanRef.current) {
122
+ return;
123
+ }
124
+
125
+ if (granted === PermissionsAndroid.RESULTS.GRANTED) {
126
+ setHasPermission(true);
127
+ return;
128
+ }
129
+
130
+ didHandleScanRef.current = true;
131
+ closeWithResult(
132
+ createFailureResult(
133
+ 'PERMISSION_DENIED',
134
+ 'scanCode:fail permission denied',
135
+ ),
136
+ );
137
+ };
138
+
139
+ requestAndroidPermission().catch(() => {
140
+ if (cancelled || didHandleScanRef.current) {
141
+ return;
142
+ }
143
+ didHandleScanRef.current = true;
144
+ closeWithResult(
145
+ createFailureResult(
146
+ 'SCAN_FAILED',
147
+ 'scanCode:fail unable to request camera permission',
148
+ ),
149
+ );
150
+ });
151
+
152
+ return () => {
153
+ cancelled = true;
154
+ };
155
+ }, [request]);
156
+
157
+ if (!request) {
158
+ return null;
159
+ }
160
+
161
+ if (Platform.OS === 'android' && !hasPermission) {
162
+ return <View style={styles.container} />;
163
+ }
164
+
165
+ if (!device) {
166
+ return <View style={styles.container} />;
167
+ }
168
+
169
+ const safePaddingTop = Platform.OS === 'ios' ? 54 : 20;
170
+
171
+ return (
172
+ <View style={styles.container}>
173
+ <StatusBar
174
+ translucent
175
+ backgroundColor="transparent"
176
+ barStyle="light-content"
177
+ />
178
+ <Camera
179
+ codeScanner={codeScanner}
180
+ device={device}
181
+ isActive={isActive}
182
+ style={StyleSheet.absoluteFill}
183
+ />
184
+
185
+ <TouchableOpacity
186
+ style={[styles.closeIcon, { paddingTop: safePaddingTop }]}
187
+ onPress={() => {
188
+ if (didHandleScanRef.current) {
189
+ return;
190
+ }
191
+ didHandleScanRef.current = true;
192
+ closeWithResult(
193
+ createFailureResult('USER_CANCELLED', 'scanCode:fail cancel'),
194
+ );
195
+ }}
196
+ >
197
+ <Image
198
+ source={require('./assets/icon_close.png')}
199
+ style={styles.closeImg}
200
+ />
201
+ </TouchableOpacity>
202
+
203
+ {!request.onlyFromCamera ? (
204
+ <TouchableOpacity
205
+ style={styles.albumIcon}
206
+ onPress={() => {
207
+ if (didHandleScanRef.current) {
208
+ return;
209
+ }
210
+ didHandleScanRef.current = true;
211
+ closeWithResult(
212
+ createFailureResult(
213
+ 'UNSUPPORTED_SOURCE',
214
+ 'scanCode:fail album selection not implemented',
215
+ ),
216
+ );
217
+ }}
218
+ >
219
+ <Image
220
+ source={require('./assets/icon_pic.png')}
221
+ style={styles.albumImg}
222
+ />
223
+ </TouchableOpacity>
224
+ ) : null}
225
+ </View>
226
+ );
227
+ }
228
+
229
+ const styles = StyleSheet.create({
230
+ container: {
231
+ position: 'absolute',
232
+ top: 0,
233
+ left: 0,
234
+ width,
235
+ height,
236
+ backgroundColor: '#000',
237
+ },
238
+ closeIcon: {
239
+ position: 'absolute',
240
+ left: 20,
241
+ top: 0,
242
+ zIndex: 1010,
243
+ },
244
+ closeImg: {
245
+ width: 26,
246
+ height: 26,
247
+ tintColor: '#FFF',
248
+ },
249
+ albumIcon: {
250
+ position: 'absolute',
251
+ right: 20,
252
+ bottom: 40,
253
+ zIndex: 1010,
254
+ backgroundColor: 'rgba(0,0,0,0.5)',
255
+ padding: 12,
256
+ borderRadius: 30,
257
+ },
258
+ albumImg: {
259
+ width: 24,
260
+ height: 24,
261
+ tintColor: '#FFF',
262
+ },
263
+ });
@@ -0,0 +1,36 @@
1
+ import { createHostModalApiFeature } from '@nebula-rn/sdk';
2
+ import NebulaHostPreviewImageModal from './NebulaHostPreviewImageModal';
3
+ import { previewImageChannel } from './previewImageRuntime';
4
+
5
+ export type PreviewImagePayload = {
6
+ urls: string[];
7
+ current?: string;
8
+ showMenu?: boolean;
9
+ saveMediaText?: string;
10
+ cancelText?: string;
11
+ };
12
+
13
+ export const previewImageHostApi = createHostModalApiFeature<
14
+ PreviewImagePayload,
15
+ PreviewImagePayload
16
+ >({
17
+ apiName: 'previewImage',
18
+ description: {
19
+ summary:
20
+ 'Open a host-managed fullscreen image preview modal for one or more image URLs.',
21
+ description:
22
+ 'Supports selecting the initial image and optionally showing save actions in the preview UI.',
23
+ tags: ['media', 'image', 'modal'],
24
+ },
25
+ component: NebulaHostPreviewImageModal,
26
+ channel: previewImageChannel,
27
+ createRequest: payload => ({
28
+ urls: payload.urls,
29
+ current: payload.current,
30
+ showMenu: payload.showMenu,
31
+ saveMediaText: payload.saveMediaText,
32
+ cancelText: payload.cancelText,
33
+ }),
34
+ onUnmountErrorMessage:
35
+ 'previewImage:fail host preview was unmounted before completion',
36
+ });
@@ -0,0 +1,58 @@
1
+ import { Camera } from 'react-native-vision-camera';
2
+ import {
3
+ createHostApiFailure,
4
+ createHostModalApiFeature,
5
+ } from '@nebula-rn/sdk';
6
+ import NebulaHostScanModal from './NebulaHostScanModal';
7
+ import { scanCodeChannel } from './scanCodeRuntime';
8
+ import { Platform } from 'react-native';
9
+
10
+ type ScanCodePayload = {
11
+ onlyFromCamera?: boolean;
12
+ scanType?: string[];
13
+ };
14
+
15
+ function normalizeScanTypes(scanTypes?: string[]): string[] {
16
+ const defaultTypes = ['qr', 'ean-13', 'code-128'];
17
+ if (!scanTypes || scanTypes.length === 0) {
18
+ return defaultTypes;
19
+ }
20
+
21
+ const normalized = scanTypes.filter(Boolean);
22
+ return normalized.length > 0 ? normalized : defaultTypes;
23
+ }
24
+
25
+ export const scanCodeHostApi = createHostModalApiFeature<
26
+ ScanCodePayload,
27
+ { onlyFromCamera?: boolean; scanTypes: string[] }
28
+ >({
29
+ apiName: 'scanCode',
30
+ description: {
31
+ summary:
32
+ 'Open a host-managed scanner modal and return the first detected barcode or QR code.',
33
+ description:
34
+ 'Supports camera-based scanning and returns structured scan metadata when successful.',
35
+ tags: ['camera', 'scanner', 'modal'],
36
+ },
37
+ component: NebulaHostScanModal,
38
+ channel: scanCodeChannel,
39
+ createRequest: payload => ({
40
+ onlyFromCamera: payload.onlyFromCamera,
41
+ scanTypes: normalizeScanTypes(payload.scanType),
42
+ }),
43
+ onBeforeOpen: async () => {
44
+ if (Platform.OS === 'android') {
45
+ return null;
46
+ }
47
+ const permission = await Camera.requestCameraPermission();
48
+ if (permission !== 'granted') {
49
+ return createHostApiFailure(
50
+ 'PERMISSION_DENIED',
51
+ 'scanCode:fail permission denied',
52
+ );
53
+ }
54
+ return null;
55
+ },
56
+ onUnmountErrorMessage:
57
+ 'scanCode:fail host scanner was unmounted before completion',
58
+ });
Binary file
Binary file