@opndev/react-native-events 0.0.14 → 0.0.16

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/Changes CHANGED
@@ -1,5 +1,17 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.16 2026-06-27 23:25:11Z
4
+
5
+ * Add widgets for dynamic and static content:
6
+ news now is dynamic and some other bits are static because we don't need to
7
+ do call to a backend to decide what scheduling or rules there are. Offline
8
+ mode is cool too
9
+
10
+ 0.0.15 2026-06-26 22:56:26Z
11
+
12
+ * Add debugRouteState() to debug export to debug route states more easily
13
+ * Throw shade at panels: I mean shadows, we not dissing panels here.
14
+
3
15
  0.0.14 2026-06-26 04:53:23Z
4
16
 
5
17
  * Add Panel instead of a Tile, Tiles are more buttony thing. Panels are
package/README.md CHANGED
@@ -5,7 +5,7 @@ SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
5
5
  SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
6
6
  -->
7
7
 
8
- # Welcome to @opndev/opndev-react-native-events
8
+ # Welcome to @opndev/react-native-events
9
9
 
10
10
  Reusable React Native / Expo components for event-style apps.
11
11
 
@@ -26,7 +26,7 @@ This package provides a small set of building blocks for:
26
26
  ## Installation
27
27
 
28
28
  ```bash
29
- npm install @opndev/opndev-react-native-events
29
+ npm install @opndev/react-native-events
30
30
  ```
31
31
 
32
32
  Peer dependencies are expected to be installed by the consuming app.
@@ -2,99 +2,21 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- /**
6
- * News API cache time-to-live.
7
- */
8
- const TTL_MS = 15 * 60 * 1000;
9
-
10
- /**
11
- * In-memory cache for news API responses.
12
- */
13
- const cache = new Map();
14
-
15
- /**
16
- * Builds a cache key for a given URI and bearer token.
17
- *
18
- * @param {string} uri
19
- * @param {string} [bearerToken]
20
- *
21
- * @returns {string}
22
- */
23
- function getCacheKey(uri, bearerToken) {
24
- return `${uri}::${bearerToken || ''}`;
25
- }
26
-
27
- /**
28
- * Returns true when a cache entry is still fresh.
29
- *
30
- * @param {object} entry
31
- *
32
- * @returns {boolean}
33
- */
34
- function isFresh(entry) {
35
- if (!entry) {
36
- return false;
37
- }
38
-
39
- return (Date.now() - entry.fetchedAt) < TTL_MS;
40
- }
41
-
42
- /**
43
- * Fetches JSON from the news API with optional bearer token support.
44
- *
45
- * Cached responses are reused for 15 minutes unless `force` is set.
46
- *
47
- * @param {object} args
48
- * @param {string} args.uri
49
- * @param {string} [args.bearerToken]
50
- * @param {boolean} [args.force]
51
- *
52
- * @returns {Promise<any>}
53
- */
54
- export async function fetchNewsJson({
55
- uri,
56
- bearerToken,
57
- force = false,
58
- }) {
59
- const key = getCacheKey(uri, bearerToken);
60
- const entry = cache.get(key);
61
-
62
- if (!force && isFresh(entry)) {
63
- return entry.data;
64
- }
65
-
66
- const headers = {};
67
- if (bearerToken) {
68
- headers.Authorization = `Bearer ${bearerToken}`;
69
- }
70
-
71
- const res = await fetch(uri, {
72
- method: 'GET',
73
- headers,
74
- });
75
-
76
- const data = await res.json();
77
-
78
- cache.set(key, {
79
- data,
80
- fetchedAt: Date.now(),
81
- });
82
-
83
- return data;
5
+ // This used to be its own standalone cache implementation. The
6
+ // logic was always fully generic underneath (just a TTL cache
7
+ // around fetch + bearer auth) — it's been moved to
8
+ // @opndev/react-native-events as useJsonApiData's default fetcher,
9
+ // shared with RemoteDataWidget. This file now just re-exports under
10
+ // the original names so NewsListScreen/NewsItemScreen don't need to
11
+ // change, and there's only one actual cache implementation rather
12
+ // than two that could drift apart.
13
+
14
+ import { fetchJson, clearJsonCache } from '@opndev/react-native-events/widgets';
15
+
16
+ export function fetchNewsJson({ uri, bearerToken, force }) {
17
+ return fetchJson({ uri, bearerToken, force });
84
18
  }
85
19
 
86
- /**
87
- * Clears the cached response for a specific URI.
88
- *
89
- * @param {object} args
90
- * @param {string} args.uri
91
- * @param {string} [args.bearerToken]
92
- *
93
- * @returns {void}
94
- */
95
- export function clearNewsCache({
96
- uri,
97
- bearerToken,
98
- }) {
99
- cache.delete(getCacheKey(uri, bearerToken));
20
+ export function clearNewsCache({ uri, bearerToken }) {
21
+ return clearJsonCache({ uri, bearerToken });
100
22
  }
@@ -84,10 +84,10 @@ export default function AppScreenTopBar({
84
84
  return (
85
85
  <View
86
86
  style={[
87
+ style,
87
88
  styles.bar,
88
89
  { height, justifyContent, paddingHorizontal: horizontalPadding, paddingBottom: verticalPadding },
89
90
  backgroundColor ? { backgroundColor } : null,
90
- style,
91
91
  ]}
92
92
  >
93
93
  {applyBarStyling(logo, { height, maxWidthPixels })}
@@ -1,7 +1,6 @@
1
1
  // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
-
5
4
  import { View, StyleSheet } from 'react-native';
6
5
  import { useSafeAreaTopInset } from '../hooks/use-safe-area-top-inset';
7
6
  import { AppScreenOffsetContext } from '../hooks/use-app-screen-offset';
@@ -17,6 +16,7 @@ const defaultStyle = StyleSheet.create({
17
16
  left: 0,
18
17
  width: '100%',
19
18
  zIndex: 10,
19
+ elevation: 10,
20
20
  },
21
21
  });
22
22
 
@@ -67,6 +67,7 @@ export default function AppScreen({
67
67
  style={[
68
68
  defaultStyle.topBarWrap,
69
69
  { height: offset, paddingTop: insetTop },
70
+ topBar.props?.backgroundColor ? { backgroundColor: topBar.props.backgroundColor } : null,
70
71
  ]}
71
72
  >
72
73
  {topBar}
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
- renderHeaderImage,
8
7
  HeaderOverlay,
8
+ HeaderContent,
9
+ splitCarouselChildren,
9
10
  useHeroHeaderHeight,
10
11
  heroContentStyle,
11
12
  } from './hero-screen-header';
@@ -38,9 +39,12 @@ const defaultStyle = StyleSheet.create({
38
39
  * gap: 16 — so spacing matches the parallax variant exactly.
39
40
  *
40
41
  * @param {object} props
41
- * @param {React.ReactNode} props.children
42
- * @param {object} [props.headerImage]
42
+ * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
43
+ * @param {object} [props.headerImage] Used when there are no <CarouselScreen> children.
43
44
  * @param {React.ReactNode} [props.headerOverlay]
45
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
46
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
47
+ * @param {boolean} [props.loop] Defaults to true.
44
48
  * @param {string} [props.backgroundColor]
45
49
  * @param {string} [props.headerBackgroundColor]
46
50
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
@@ -55,6 +59,9 @@ export default function HeroScreenFixed({
55
59
  children,
56
60
  headerImage,
57
61
  headerOverlay,
62
+ onSlidePress,
63
+ autoPlayInterval,
64
+ loop,
58
65
  backgroundColor,
59
66
  headerBackgroundColor,
60
67
  headerHeight = 220,
@@ -63,7 +70,8 @@ export default function HeroScreenFixed({
63
70
  headerStyle,
64
71
  contentStyle,
65
72
  }) {
66
- const { insetTop, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
73
+ const { topOffset, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
74
+ const { slideElements, bodyChildren } = splitCarouselChildren(children);
67
75
 
68
76
  return (
69
77
  <View
@@ -84,10 +92,16 @@ export default function HeroScreenFixed({
84
92
  <View
85
93
  style={[
86
94
  defaultStyle.imageInner,
87
- { top: insetTop, height: headerHeight },
95
+ { top: topOffset, height: headerHeight },
88
96
  ]}
89
97
  >
90
- {renderHeaderImage(headerImage)}
98
+ <HeaderContent
99
+ headerImage={headerImage}
100
+ slideElements={slideElements}
101
+ onSlidePress={onSlidePress}
102
+ autoPlayInterval={autoPlayInterval}
103
+ loop={loop}
104
+ />
91
105
  </View>
92
106
  </View>
93
107
 
@@ -97,7 +111,7 @@ export default function HeroScreenFixed({
97
111
  >
98
112
  <View style={[heroContentStyle, contentStyle]}>
99
113
  <HeaderOverlay headerOverlay={headerOverlay} />
100
- {children}
114
+ {bodyChildren}
101
115
  </View>
102
116
  </ContentComponent>
103
117
  </View>
@@ -2,8 +2,10 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
+ import { useState, isValidElement, Children } from 'react';
5
6
  import { View, Image, Pressable, StyleSheet } from 'react-native';
6
7
  import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
8
+ import Carousel from 'react-native-reanimated-carousel';
7
9
  import { useAppScreenOffset } from '../hooks/use-app-screen-offset';
8
10
 
9
11
  const styles = StyleSheet.create({
@@ -25,6 +27,27 @@ const styles = StyleSheet.create({
25
27
  shadowOpacity: 0.3,
26
28
  shadowRadius: 3,
27
29
  },
30
+ carouselFill: StyleSheet.absoluteFillObject,
31
+ slideFill: { flex: 1 },
32
+ dotsRow: {
33
+ position: 'absolute',
34
+ bottom: 12,
35
+ left: 0,
36
+ right: 0,
37
+ flexDirection: 'row',
38
+ justifyContent: 'center',
39
+ gap: 6,
40
+ },
41
+ dot: {
42
+ width: 6,
43
+ height: 6,
44
+ borderRadius: 3,
45
+ backgroundColor: 'rgba(255,255,255,0.5)',
46
+ },
47
+ dotActive: {
48
+ backgroundColor: '#FFFFFF',
49
+ width: 16,
50
+ },
28
51
  });
29
52
 
30
53
  /**
@@ -66,6 +89,140 @@ export function HeaderOverlay({ headerOverlay }) {
66
89
  return <View style={styles.titleWrap}>{headerOverlay}</View>;
67
90
  }
68
91
 
92
+ /**
93
+ * CarouselScreen
94
+ *
95
+ * A marker component, not a real standalone screen — used as a
96
+ * child of any Hero variant to declare one carousel slide:
97
+ *
98
+ * <HeroScreenFixed onSlidePress={(slide) => router.push(slide.route)}>
99
+ * <CarouselScreen item={fooData} route="/foo">
100
+ * ...whatever JSX you want for this slide...
101
+ * </CarouselScreen>
102
+ * <CarouselScreen item={barData} route="/bar">
103
+ * ...
104
+ * </CarouselScreen>
105
+ * </HeroScreenFixed>
106
+ *
107
+ * Every Hero variant scans its children for these by type (via
108
+ * splitCarouselChildren below) and pulls out `item`/`route` plus
109
+ * your own nested children for each slide. Any non-CarouselScreen
110
+ * children are treated as normal body content, same as always.
111
+ *
112
+ * If rendered standalone it just renders its own children, so it's
113
+ * harmless either way.
114
+ *
115
+ * @param {object} props
116
+ * @param {any} [props.item]
117
+ * @param {any} [props.route]
118
+ * @param {React.ReactNode} props.children
119
+ */
120
+ export function CarouselScreen({ children }) {
121
+ return children ?? null;
122
+ }
123
+
124
+ /**
125
+ * splitCarouselChildren
126
+ *
127
+ * Separates a Hero variant's children into carousel slides
128
+ * (CarouselScreen elements) and normal body content (everything
129
+ * else). Shared by every Hero variant so the splitting logic is
130
+ * identical across all of them.
131
+ *
132
+ * @param {React.ReactNode} children
133
+ * @returns {{ slideElements: React.ReactElement[], bodyChildren: React.ReactNode[] }}
134
+ */
135
+ export function splitCarouselChildren(children) {
136
+ const childArray = Children.toArray(children);
137
+
138
+ const slideElements = childArray.filter(
139
+ (child) => isValidElement(child) && child.type === CarouselScreen
140
+ );
141
+ const bodyChildren = childArray.filter(
142
+ (child) => !(isValidElement(child) && child.type === CarouselScreen)
143
+ );
144
+
145
+ return { slideElements, bodyChildren };
146
+ }
147
+
148
+ /**
149
+ * HeaderContent
150
+ *
151
+ * The single thing every Hero variant renders inside its header
152
+ * box — decides whether that's a static image (renderHeaderImage,
153
+ * unchanged default behaviour) or a swipeable/auto-advancing
154
+ * carousel (when CarouselScreen children were found), so a Hero
155
+ * variant only ever needs to swap one line to gain carousel
156
+ * support, with zero changes to its own scroll/positioning logic.
157
+ *
158
+ * Fills whatever box it's placed in — each variant owns its own
159
+ * positioning (Fixed/Overlay via their own imageInner wrapper,
160
+ * Parallax via ParallaxScrollView's existing wrapper) — this
161
+ * component doesn't position itself.
162
+ *
163
+ * @param {object} props
164
+ * @param {object} [props.headerImage] Used when there are no slides.
165
+ * @param {React.ReactElement[]} [props.slideElements] From splitCarouselChildren. When empty/absent, renders the static image instead.
166
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
167
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
168
+ * @param {boolean} [props.loop] Defaults to true.
169
+ *
170
+ * @returns {JSX.Element|null}
171
+ */
172
+ export function HeaderContent({
173
+ headerImage,
174
+ slideElements,
175
+ onSlidePress,
176
+ autoPlayInterval = 4000,
177
+ loop = true,
178
+ }) {
179
+ const [activeIndex, setActiveIndex] = useState(0);
180
+
181
+ if (!slideElements || slideElements.length === 0) {
182
+ return renderHeaderImage(headerImage);
183
+ }
184
+
185
+ return (
186
+ <View style={styles.carouselFill}>
187
+ <Carousel
188
+ style={{ width: '100%', height: '100%' }}
189
+ data={slideElements}
190
+ loop={loop}
191
+ autoPlay={autoPlayInterval > 0}
192
+ autoPlayInterval={autoPlayInterval}
193
+ onSnapToItem={setActiveIndex}
194
+ renderItem={({ item: slideElement, index }) => {
195
+ const { item, route } = slideElement.props;
196
+ const onPress = onSlidePress
197
+ ? () => onSlidePress({ item, route }, index)
198
+ : undefined;
199
+
200
+ if (!onPress) {
201
+ return <View style={styles.slideFill}>{slideElement}</View>;
202
+ }
203
+
204
+ return (
205
+ <Pressable style={styles.slideFill} onPress={onPress}>
206
+ {slideElement}
207
+ </Pressable>
208
+ );
209
+ }}
210
+ />
211
+
212
+ {slideElements.length > 1 ? (
213
+ <View style={styles.dotsRow}>
214
+ {slideElements.map((_, i) => (
215
+ <View
216
+ key={i}
217
+ style={[styles.dot, i === activeIndex ? styles.dotActive : null]}
218
+ />
219
+ ))}
220
+ </View>
221
+ ) : null}
222
+ </View>
223
+ );
224
+ }
225
+
69
226
  /**
70
227
  * useTopOffset
71
228
  *
@@ -2,49 +2,78 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- import { View, StyleSheet } from 'react-native';
5
+ import {
6
+ HeaderOverlay,
7
+ HeaderContent,
8
+ splitCarouselChildren,
9
+ } from './hero-screen-header';
6
10
  import ParallaxScrollView from './parallax-scroll-view';
7
- import { renderHeaderImage, HeaderOverlay } from './hero-screen-header';
8
11
 
9
- const defaultStyle = StyleSheet.create({
10
- headerImageWrap: {
11
- width: '100%',
12
- height: 220,
13
- bottom: 0,
14
- left: 0,
15
- position: 'absolute',
16
- },
17
- });
18
-
19
- export default function HeroScreen({
12
+ /**
13
+ * HeroScreenParallax
14
+ *
15
+ * Header image scales/translates with scroll (the classic parallax
16
+ * effect) via ParallaxScrollView. Carousel support needed no new
17
+ * positioning logic here at all — ParallaxScrollView already treats
18
+ * its header as an opaque element it animates, so swapping in
19
+ * HeaderContent (image-or-carousel) instead of a plain image just
20
+ * works, same one-line swap as Fixed/Overlay.
21
+ *
22
+ * @param {object} props
23
+ * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
24
+ * @param {object} [props.headerImage] Used when there are no <CarouselScreen> children.
25
+ * @param {React.ReactNode} [props.headerOverlay]
26
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
27
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
28
+ * @param {boolean} [props.loop] Defaults to true.
29
+ * @param {string} [props.backgroundColor]
30
+ * @param {string} [props.headerBackgroundColor]
31
+ * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
32
+ * @param {React.ComponentType} [props.ContentComponent]
33
+ * @param {object} [props.containerStyle]
34
+ * @param {object} [props.headerStyle]
35
+ * @param {object} [props.contentStyle]
36
+ *
37
+ * @returns {JSX.Element}
38
+ */
39
+ export default function HeroScreenParallax({
20
40
  children,
21
41
  headerImage,
22
42
  headerOverlay,
43
+ onSlidePress,
44
+ autoPlayInterval,
45
+ loop,
23
46
  backgroundColor,
24
47
  headerBackgroundColor,
25
48
  headerHeight,
26
- ContentComponent,
27
49
  containerStyle,
28
50
  headerStyle,
29
51
  contentStyle,
52
+ ContentComponent,
30
53
  }) {
54
+ const { slideElements, bodyChildren } = splitCarouselChildren(children);
55
+
31
56
  return (
32
57
  <ParallaxScrollView
58
+ headerImage={
59
+ <HeaderContent
60
+ headerImage={headerImage}
61
+ slideElements={slideElements}
62
+ onSlidePress={onSlidePress}
63
+ autoPlayInterval={autoPlayInterval}
64
+ loop={loop}
65
+ />
66
+ }
33
67
  backgroundColor={backgroundColor}
34
68
  headerBackgroundColor={headerBackgroundColor}
35
69
  headerHeight={headerHeight}
36
- ContentComponent={ContentComponent}
37
70
  containerStyle={containerStyle}
38
71
  headerStyle={headerStyle}
39
72
  contentStyle={contentStyle}
40
- headerImage={
41
- <View style={defaultStyle.headerImageWrap}>
42
- {renderHeaderImage(headerImage)}
43
- </View>
44
- }
73
+ ContentComponent={ContentComponent}
45
74
  >
46
75
  <HeaderOverlay headerOverlay={headerOverlay} />
47
- {children}
76
+ {bodyChildren}
48
77
  </ParallaxScrollView>
49
78
  );
50
79
  }
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
- renderHeaderImage,
8
7
  HeaderOverlay,
8
+ HeaderContent,
9
+ splitCarouselChildren,
9
10
  BackButton,
10
11
  useHeroHeaderHeight,
11
12
  heroContentStyle,
@@ -44,10 +45,13 @@ const defaultStyle = StyleSheet.create({
44
45
  * own padded content area.
45
46
  *
46
47
  * @param {object} props
47
- * @param {React.ReactNode} props.children
48
- * @param {object} [props.headerImage]
48
+ * @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
49
+ * @param {object} [props.headerImage] Used when there are no <CarouselScreen> children.
49
50
  * @param {React.ReactNode} [props.headerOverlay]
50
- * @param {Function} [props.onBack] Renders a back button over the header image when provided. Omit for no back button.
51
+ * @param {Function} [props.onBack] Renders a back button over the header when provided. Omit for no back button.
52
+ * @param {(slide: { item: any, route: any }, index: number) => void} [props.onSlidePress]
53
+ * @param {number} [props.autoPlayInterval] Defaults to 4000. Pass 0 to disable.
54
+ * @param {boolean} [props.loop] Defaults to true.
51
55
  * @param {string} [props.backgroundColor]
52
56
  * @param {string} [props.headerBackgroundColor]
53
57
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
@@ -63,6 +67,9 @@ export default function HeroScreenOverlay({
63
67
  headerImage,
64
68
  headerOverlay,
65
69
  onBack,
70
+ onSlidePress,
71
+ autoPlayInterval,
72
+ loop,
66
73
  backgroundColor,
67
74
  headerBackgroundColor,
68
75
  headerHeight = 220,
@@ -72,6 +79,7 @@ export default function HeroScreenOverlay({
72
79
  contentStyle,
73
80
  }) {
74
81
  const { topOffset, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
82
+ const { slideElements, bodyChildren } = splitCarouselChildren(children);
75
83
 
76
84
  return (
77
85
  <View
@@ -89,7 +97,7 @@ export default function HeroScreenOverlay({
89
97
 
90
98
  <View style={[heroContentStyle, contentStyle]}>
91
99
  <HeaderOverlay headerOverlay={headerOverlay} />
92
- {children}
100
+ {bodyChildren}
93
101
  </View>
94
102
  </ContentComponent>
95
103
 
@@ -107,7 +115,13 @@ export default function HeroScreenOverlay({
107
115
  { top: topOffset, height: headerHeight },
108
116
  ]}
109
117
  >
110
- {renderHeaderImage(headerImage)}
118
+ <HeaderContent
119
+ headerImage={headerImage}
120
+ slideElements={slideElements}
121
+ onSlidePress={onSlidePress}
122
+ autoPlayInterval={autoPlayInterval}
123
+ loop={loop}
124
+ />
111
125
  </View>
112
126
 
113
127
  <BackButton