@opndev/react-native-events 0.0.10

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,144 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import { StyleSheet, View, Platform } from 'react-native';
6
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
7
+
8
+ import Animated, {
9
+ interpolate,
10
+ useAnimatedRef,
11
+ useAnimatedStyle,
12
+ useScrollOffset,
13
+ } from 'react-native-reanimated';
14
+
15
+
16
+ const DEFAULT_HEADER_HEIGHT = 250;
17
+
18
+ const defaultStyle = StyleSheet.create({
19
+ container: {
20
+ flex: 1,
21
+ },
22
+ header: {
23
+ overflow: 'hidden',
24
+ zIndex: 1,
25
+ },
26
+ content: {
27
+ padding: 32,
28
+ gap: 16,
29
+ },
30
+ });
31
+
32
+ /**
33
+ * A scroll view with a parallax header image and a pluggable content wrapper.
34
+ *
35
+ * The component is intentionally theme-agnostic. Consumers are expected to pass
36
+ * resolved colors and, if needed, a custom content wrapper component.
37
+ *
38
+ * @param {object} props
39
+ * @param {React.ReactNode} props.children
40
+ * Content rendered below the parallax header.
41
+ * @param {React.ReactElement} props.headerImage
42
+ * Element rendered inside the header area, usually an image.
43
+ * @param {string} [props.backgroundColor='#fff']
44
+ * Background color for the scroll view container.
45
+ * @param {string} [props.headerBackgroundColor]
46
+ * Background color shown behind the header image.
47
+ * @param {number} [props.headerHeight=250]
48
+ * Height of the parallax header in device pixels.
49
+ * @param {React.ComponentType<{style?: any, children?: React.ReactNode}>}
50
+ * [props.ContentComponent=View]
51
+ * Wrapper used for the content area below the header.
52
+ *
53
+ * This is a container injection point, not an arbitrary render callback.
54
+ * The component passed here must accept:
55
+ *
56
+ * - `style`
57
+ * - `children`
58
+ *
59
+ * Typical examples are `View`, `SafeAreaView`, or a themed wrapper such as
60
+ * `ThemedView`.
61
+ *
62
+ * Avoid passing scroll containers such as `ScrollView` unless nested scrolling
63
+ * is explicitly intended.
64
+ * @param {import('react-native').StyleProp<import('react-native').ViewStyle>}
65
+ * [props.containerStyle]
66
+ * Optional style overrides for the outer scroll view container.
67
+ * @param {import('react-native').StyleProp<import('react-native').ViewStyle>}
68
+ * [props.headerStyle]
69
+ * Optional style overrides for the header container.
70
+ * @param {import('react-native').StyleProp<import('react-native').ViewStyle>}
71
+ * [props.contentStyle]
72
+ * Optional style overrides for the content wrapper.
73
+ * @returns {JSX.Element}
74
+ */
75
+
76
+ export default function ParallaxScrollView({
77
+ children,
78
+ headerImage,
79
+ headerBackgroundColor,
80
+ backgroundColor = '#fff',
81
+ colorScheme = 'light',
82
+ headerHeight = DEFAULT_HEADER_HEIGHT,
83
+ containerStyle,
84
+ headerStyle,
85
+ contentStyle,
86
+ ContentComponent = View,
87
+ }) {
88
+ const scrollOffset = useScrollOffset(scrollRef);
89
+ const scrollRef = useAnimatedRef();
90
+ const insets = useSafeAreaInsets();
91
+ const effectiveHeaderHeight = headerHeight + (insets.top / 2);
92
+
93
+ const headerAnimatedStyle = useAnimatedStyle(() => {
94
+ return {
95
+ transform: [
96
+ {
97
+ translateY: interpolate(
98
+ scrollOffset.value,
99
+ [-headerHeight, 0, headerHeight],
100
+ [-headerHeight / 2, 0, headerHeight * 0.75]
101
+ ),
102
+ },
103
+ {
104
+ scale: interpolate(
105
+ scrollOffset.value,
106
+ [-headerHeight, 0, headerHeight],
107
+ [2, 1, 1]
108
+ ),
109
+ },
110
+ ],
111
+ };
112
+ });
113
+
114
+ return (
115
+ <Animated.ScrollView
116
+ ref={scrollRef}
117
+
118
+ style={[
119
+ defaultStyle.container,
120
+ { backgroundColor },
121
+ containerStyle,
122
+ ]}
123
+ scrollEventThrottle={16}
124
+ >
125
+ <Animated.View
126
+ style={[
127
+ defaultStyle.header,
128
+ {
129
+ height: effectiveHeaderHeight,
130
+ backgroundColor: headerBackgroundColor,
131
+ },
132
+ headerAnimatedStyle,
133
+ headerStyle,
134
+ ]}
135
+ >
136
+ {headerImage}
137
+ </Animated.View>
138
+
139
+ <ContentComponent style={[defaultStyle.content, contentStyle]}>
140
+ {children}
141
+ </ContentComponent>
142
+ </Animated.ScrollView>
143
+ );
144
+ }
@@ -0,0 +1,436 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import React, { useEffect, useMemo, useState } from 'react';
6
+ import {
7
+ View,
8
+ TextInput,
9
+ StyleSheet,
10
+ Pressable,
11
+ } from 'react-native';
12
+ import QRCode from 'react-native-qrcode-svg';
13
+
14
+ const defaultStyle = StyleSheet.create({
15
+ container: {
16
+ padding: 16,
17
+ gap: 16,
18
+ },
19
+ section: {
20
+ gap: 12,
21
+ },
22
+ inputWrap: {
23
+ gap: 6,
24
+ },
25
+ input: {
26
+ backgroundColor: '#FFFFFF',
27
+ borderWidth: 1,
28
+ borderRadius: 8,
29
+ padding: 12,
30
+ },
31
+ button: {
32
+ padding: 14,
33
+ borderRadius: 8,
34
+ alignItems: 'center',
35
+ },
36
+ wideButton: {
37
+ alignSelf: 'stretch',
38
+ },
39
+ card: {
40
+ padding: 20,
41
+ borderRadius: 12,
42
+ alignItems: 'center',
43
+ gap: 16,
44
+ },
45
+ qrWrap: {
46
+ padding: 16,
47
+ borderWidth: 4,
48
+ borderRadius: 12,
49
+ backgroundColor: '#FFFFFF',
50
+ },
51
+ });
52
+
53
+ /**
54
+ * QRCodeForm
55
+ *
56
+ * Reusable QR form component.
57
+ *
58
+ * @param {object} props
59
+ * @param {Array<object>} props.fields
60
+ * @param {object} props.action
61
+ * @param {React.ComponentType} props.TextComponent
62
+ * @param {string} [props.backgroundColor]
63
+ * @param {string} [props.inputBackgroundColor]
64
+ * @param {string} [props.inputBorderColor]
65
+ * @param {string} [props.inputTextColor]
66
+ * @param {string} [props.placeholderTextColor]
67
+ * @param {string} [props.primaryButtonBackgroundColor]
68
+ * @param {string} [props.secondaryButtonBackgroundColor]
69
+ * @param {string} [props.cardBackgroundColor]
70
+ * @param {string} [props.linkColor]
71
+ * @param {string} [props.errorBackgroundColor]
72
+ * @param {string} [props.errorTextColor]
73
+ * @param {object} [props.containerStyle]
74
+ * @param {object} [props.labelStyle]
75
+ * @param {object} [props.inputStyle]
76
+ * @param {object} [props.buttonStyle]
77
+ * @param {object} [props.buttonTextStyle]
78
+ * @param {object} [props.secondaryButtonStyle]
79
+ * @param {object} [props.secondaryButtonTextStyle]
80
+ * @param {object} [props.cardStyle]
81
+ * @param {object} [props.linkStyle]
82
+ * @param {object} [props.errorStyle]
83
+ * @param {object} [props.errorTextStyle]
84
+ * @param {string} [props.submitLabel]
85
+ * @param {string} [props.loadingLabel]
86
+ * @param {string} [props.refreshLabel]
87
+ * @param {string} [props.resetLabel]
88
+ * @param {string} [props.errorLabel]
89
+ * @param {number} [props.qrSize]
90
+ * @param {string} [props.qrBorderColor]
91
+ * @param {number} [props.qrBorderWidth]
92
+ * @param {Function} [props.renderAboveQRCode]
93
+ * @param {Function} [props.renderBelowQRCode]
94
+ *
95
+ * @returns {JSX.Element}
96
+ */
97
+ export default function QRCodeForm({
98
+ fields,
99
+ action,
100
+ TextComponent,
101
+ backgroundColor,
102
+ inputBackgroundColor = '#FFFFFF',
103
+ inputBorderColor,
104
+ inputTextColor,
105
+ placeholderTextColor,
106
+ primaryButtonBackgroundColor,
107
+ secondaryButtonBackgroundColor,
108
+ cardBackgroundColor,
109
+ linkColor,
110
+ errorBackgroundColor,
111
+ errorTextColor,
112
+ containerStyle,
113
+ labelStyle,
114
+ inputStyle,
115
+ buttonStyle,
116
+ buttonTextStyle,
117
+ secondaryButtonStyle,
118
+ secondaryButtonTextStyle,
119
+ cardStyle,
120
+ linkStyle,
121
+ errorStyle,
122
+ errorTextStyle,
123
+ submitLabel = 'Generate',
124
+ loadingLabel = 'Loading...',
125
+ refreshLabel = 'Refresh',
126
+ resetLabel = 'Re-enter',
127
+ errorLabel = 'Request failed',
128
+ qrSize = 220,
129
+ qrBorderColor = '#000000',
130
+ qrBorderWidth = 4,
131
+ renderAboveQRCode,
132
+ renderBelowQRCode,
133
+ }) {
134
+ const initialValues = useMemo(() => {
135
+ return fields.reduce((acc, field) => {
136
+ acc[field.key] = '';
137
+ return acc;
138
+ }, {});
139
+ }, [fields]);
140
+
141
+ const [values, setValues] = useState(initialValues);
142
+ const [loading, setLoading] = useState(false);
143
+ const [response, setResponse] = useState(null);
144
+ const [error, setError] = useState(null);
145
+
146
+ useEffect(() => {
147
+ loadValues();
148
+ }, []);
149
+
150
+ async function loadValues() {
151
+ const stored = await action.loadValues();
152
+
153
+ setValues((prev) => ({
154
+ ...prev,
155
+ ...stored,
156
+ }));
157
+ }
158
+
159
+ async function fetchQr() {
160
+ setLoading(true);
161
+ setError(null);
162
+
163
+ try {
164
+ const json = await action.fetchResponse(values);
165
+ setResponse(json);
166
+ }
167
+ catch (e) {
168
+ setError(e.message || errorLabel);
169
+ }
170
+
171
+ setLoading(false);
172
+ }
173
+
174
+ function reset() {
175
+ setResponse(null);
176
+ setError(null);
177
+ }
178
+
179
+ const responseError = getResponseError(response);
180
+ const visibleError = error || responseError;
181
+ const hasResult = Boolean(response && !responseError);
182
+ const token = hasResult ? action.getToken(response) : undefined;
183
+
184
+ const renderArgs = {
185
+ response,
186
+ values,
187
+ fields,
188
+ token,
189
+ loading,
190
+ };
191
+
192
+ /**
193
+ * @param {object|null} value
194
+ * @returns {string|null}
195
+ */
196
+ function getResponseError(value) {
197
+ if (!value) {
198
+ return null;
199
+ }
200
+
201
+ if (value.status === 'error') {
202
+ return value.error || value.message || errorLabel;
203
+ }
204
+
205
+ if (value.error) {
206
+ return value.error;
207
+ }
208
+
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * @param {object} field
214
+ * @returns {JSX.Element}
215
+ */
216
+ function renderField(field) {
217
+ return (
218
+ <View key={field.key} style={defaultStyle.inputWrap}>
219
+ <TextComponent style={labelStyle}>
220
+ {field.label}
221
+ </TextComponent>
222
+
223
+ <TextInput
224
+ style={[
225
+ defaultStyle.input,
226
+ {
227
+ backgroundColor: inputBackgroundColor,
228
+ borderColor: inputBorderColor,
229
+ color: inputTextColor,
230
+ },
231
+ inputStyle,
232
+ ]}
233
+ placeholder={field.placeholder || field.label}
234
+ placeholderTextColor={placeholderTextColor}
235
+ value={values[field.key]}
236
+ onChangeText={(text) => updateValue(field.key, text)}
237
+ autoCapitalize={
238
+ field.autoCapitalize ? 'characters' : 'none'
239
+ }
240
+ keyboardType={field.keyboardType || 'default'}
241
+ />
242
+ </View>
243
+ );
244
+ }
245
+
246
+ /**
247
+ * @param {string} key
248
+ * @param {string} value
249
+ * @returns {void}
250
+ */
251
+ function updateValue(key, value) {
252
+ setValues((prev) => ({
253
+ ...prev,
254
+ [key]: value,
255
+ }));
256
+ }
257
+
258
+ /**
259
+ * @returns {JSX.Element|null}
260
+ */
261
+ function renderForm() {
262
+ if (response) {
263
+ return null;
264
+ }
265
+
266
+ return (
267
+ <View style={defaultStyle.section}>
268
+ {fields.map(renderField)}
269
+ {renderPrimaryButton()}
270
+ </View>
271
+ );
272
+ }
273
+
274
+ /**
275
+ * @returns {JSX.Element}
276
+ */
277
+ function renderPrimaryButton() {
278
+ return (
279
+ <Pressable
280
+ style={[
281
+ defaultStyle.button,
282
+ primaryButtonBackgroundColor
283
+ ? { backgroundColor: primaryButtonBackgroundColor }
284
+ : null,
285
+ buttonStyle,
286
+ ]}
287
+ onPress={fetchQr}
288
+ >
289
+ <TextComponent style={buttonTextStyle}>
290
+ {loading ? loadingLabel : submitLabel}
291
+ </TextComponent>
292
+ </Pressable>
293
+ );
294
+ }
295
+
296
+ /**
297
+ * @returns {JSX.Element|null}
298
+ */
299
+ function renderResult() {
300
+ if (!hasResult) {
301
+ return null;
302
+ }
303
+
304
+ return (
305
+ <View
306
+ style={[
307
+ defaultStyle.card,
308
+ cardBackgroundColor
309
+ ? { backgroundColor: cardBackgroundColor }
310
+ : null,
311
+ cardStyle,
312
+ ]}
313
+ >
314
+ {renderAboveQRCode ? renderAboveQRCode(renderArgs) : null}
315
+ {renderQRCode()}
316
+ {renderBelowQRCode ? renderBelowQRCode(renderArgs) : null}
317
+ {renderRefreshButton()}
318
+ {renderResetLink()}
319
+ </View>
320
+ );
321
+ }
322
+
323
+ /**
324
+ * @returns {JSX.Element|null}
325
+ */
326
+ function renderQRCode() {
327
+ if (!token) {
328
+ return null;
329
+ }
330
+
331
+ return (
332
+ <View
333
+ style={[
334
+ defaultStyle.qrWrap,
335
+ {
336
+ borderColor: qrBorderColor,
337
+ borderWidth: qrBorderWidth,
338
+ },
339
+ ]}
340
+ >
341
+ <QRCode
342
+ value={token}
343
+ size={qrSize}
344
+ backgroundColor="#FFFFFF"
345
+ color="#000000"
346
+ />
347
+ </View>
348
+ );
349
+ }
350
+
351
+ /**
352
+ * @returns {JSX.Element}
353
+ */
354
+ function renderRefreshButton() {
355
+ return (
356
+ <Pressable
357
+ style={[
358
+ defaultStyle.button,
359
+ defaultStyle.wideButton,
360
+ secondaryButtonBackgroundColor
361
+ ? { backgroundColor: secondaryButtonBackgroundColor }
362
+ : null,
363
+ secondaryButtonStyle,
364
+ ]}
365
+ onPress={fetchQr}
366
+ >
367
+ <TextComponent style={secondaryButtonTextStyle}>
368
+ {refreshLabel}
369
+ </TextComponent>
370
+ </Pressable>
371
+ );
372
+ }
373
+
374
+ /**
375
+ * @returns {JSX.Element}
376
+ */
377
+ function renderResetLink() {
378
+ return (
379
+ <Pressable onPress={reset}>
380
+ <TextComponent
381
+ style={[
382
+ linkColor ? { color: linkColor } : null,
383
+ linkStyle,
384
+ ]}
385
+ >
386
+ {resetLabel}
387
+ </TextComponent>
388
+ </Pressable>
389
+ );
390
+ }
391
+
392
+ /**
393
+ * @returns {JSX.Element|null}
394
+ */
395
+ function renderError() {
396
+ if (!visibleError) {
397
+ return null;
398
+ }
399
+
400
+ return (
401
+ <View
402
+ style={[
403
+ errorBackgroundColor
404
+ ? { backgroundColor: errorBackgroundColor }
405
+ : null,
406
+ errorStyle,
407
+ ]}
408
+ >
409
+ <TextComponent
410
+ style={[
411
+ errorTextColor ? { color: errorTextColor } : null,
412
+ errorTextStyle,
413
+ ]}
414
+ >
415
+ {visibleError}
416
+ </TextComponent>
417
+
418
+ {responseError ? renderResetLink() : null}
419
+ </View>
420
+ );
421
+ }
422
+
423
+ return (
424
+ <View
425
+ style={[
426
+ defaultStyle.container,
427
+ backgroundColor ? { backgroundColor } : null,
428
+ containerStyle,
429
+ ]}
430
+ >
431
+ {renderForm()}
432
+ {renderResult()}
433
+ {renderError()}
434
+ </View>
435
+ );
436
+ }
@@ -0,0 +1,123 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import React from 'react';
6
+ import {
7
+ Text,
8
+ View,
9
+ Image,
10
+ StyleSheet,
11
+ Pressable,
12
+ } from 'react-native';
13
+
14
+ import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
15
+
16
+ const defaultStyle = StyleSheet.create({
17
+ surface: {
18
+ borderRadius: 16,
19
+ overflow: 'hidden',
20
+ },
21
+ wrapper: {
22
+ flex: 1,
23
+ padding: 16,
24
+ alignItems: 'center',
25
+ justifyContent: 'center',
26
+ },
27
+ content: {
28
+ alignItems: 'center',
29
+ gap: 8,
30
+ },
31
+ label: {
32
+ fontSize: 18,
33
+ textAlign: 'center',
34
+ },
35
+ image: {
36
+ alignSelf: 'center',
37
+ },
38
+ });
39
+
40
+ /**
41
+ * Shared tile renderer used by Tile and GradientTile.
42
+ *
43
+ * @param {object} props
44
+ * @param {React.ReactNode} [props.background]
45
+ * @param {string} [props.label]
46
+ * @param {string} [props.icon]
47
+ * @param {*} [props.image]
48
+ * @param {number} [props.iconSize=48]
49
+ * @param {string} [props.iconColor='#000']
50
+ * @param {function} [props.onPress]
51
+ * @param {number} [props.pressedOpacity=0.85]
52
+ * @param {object} [props.style]
53
+ * @param {object} [props.wrapperStyle]
54
+ * @param {object} [props.contentStyle]
55
+ * @param {object} [props.labelStyle]
56
+ * @param {object} [props.imageStyle]
57
+ */
58
+ export default function TileBase({
59
+ background,
60
+ label,
61
+ icon,
62
+ image,
63
+ iconSize = 48,
64
+ iconColor = '#000',
65
+ onPress,
66
+ pressedOpacity = 0.85,
67
+ style,
68
+ wrapperStyle,
69
+ contentStyle,
70
+ labelStyle,
71
+ imageStyle,
72
+ TextComponent = Text,
73
+ }) {
74
+ const renderIcon = () => {
75
+ if (image) {
76
+ return (
77
+ <Image
78
+ source={image}
79
+ style={[defaultStyle.image, imageStyle]}
80
+ resizeMode="contain"
81
+ />
82
+ );
83
+ }
84
+
85
+ if (icon) {
86
+ return (
87
+ <MaterialCommunityIcons
88
+ name={icon}
89
+ size={iconSize}
90
+ color={iconColor}
91
+ />
92
+ );
93
+ }
94
+
95
+ return null;
96
+ };
97
+
98
+ return (
99
+ <Pressable
100
+ onPress={onPress}
101
+ style={({ pressed }) => [
102
+ defaultStyle.surface,
103
+ style,
104
+ { opacity: pressed ? pressedOpacity : 1 },
105
+ ]}
106
+ >
107
+ {background}
108
+
109
+ <View style={[defaultStyle.wrapper, wrapperStyle]}>
110
+ <View style={[defaultStyle.content, contentStyle]}>
111
+ {renderIcon()}
112
+
113
+ {label ? (
114
+ <TextComponent style={[defaultStyle.label, labelStyle]}>
115
+ {label}
116
+ </TextComponent>
117
+ ) : null}
118
+ </View>
119
+ </View>
120
+ </Pressable>
121
+ );
122
+ }
123
+