@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.
@@ -6,6 +6,16 @@ import React from 'react';
6
6
  import { View, Pressable, StyleSheet } from 'react-native';
7
7
 
8
8
  const defaultStyle = StyleSheet.create({
9
+ shadowWrap: {
10
+ borderRadius: 16,
11
+ },
12
+ shadowOn: {
13
+ elevation: 4,
14
+ shadowColor: '#000',
15
+ shadowOffset: { width: 0, height: 2 },
16
+ shadowOpacity: 0.15,
17
+ shadowRadius: 6,
18
+ },
9
19
  surface: {
10
20
  borderRadius: 16,
11
21
  overflow: 'hidden',
@@ -25,11 +35,20 @@ const defaultStyle = StyleSheet.create({
25
35
  * widgets (lists, text blocks, NewsWidget, etc.) render exactly as
26
36
  * they would un-wrapped, just inside a rounded/backgrounded card.
27
37
  *
38
+ * Internally split into two layers — an outer view that casts the
39
+ * shadow (when enabled) and an inner view that clips content to the
40
+ * rounded corners. This is deliberate: a shadow renders outside a
41
+ * view's own bounds, so a single view with both `overflow: hidden`
42
+ * and a shadow would clip its own shadow into invisibility. The
43
+ * split means `shadow` can be toggled on/off with no other changes
44
+ * needed, and panels that don't use it are unaffected.
45
+ *
28
46
  * @param {object} props
29
47
  * @param {React.ReactNode} props.children
30
48
  * @param {string} [props.backgroundColor]
49
+ * @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
31
50
  * @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
32
- * @param {object} [props.style] Style for the outer rounded surface.
51
+ * @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
33
52
  * @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
34
53
  *
35
54
  * @returns {JSX.Element}
@@ -37,6 +56,7 @@ const defaultStyle = StyleSheet.create({
37
56
  export default function Panel({
38
57
  children,
39
58
  backgroundColor,
59
+ shadow = false,
40
60
  onPress,
41
61
  style,
42
62
  contentStyle,
@@ -44,7 +64,6 @@ export default function Panel({
44
64
  const surfaceStyle = [
45
65
  defaultStyle.surface,
46
66
  backgroundColor ? { backgroundColor } : null,
47
- style,
48
67
  ];
49
68
 
50
69
  const content = (
@@ -53,13 +72,28 @@ export default function Panel({
53
72
  </View>
54
73
  );
55
74
 
56
- if (onPress) {
57
- return (
58
- <Pressable onPress={onPress} style={surfaceStyle}>
59
- {content}
60
- </Pressable>
61
- );
62
- }
75
+ const inner = onPress ? (
76
+ <Pressable onPress={onPress} style={surfaceStyle}>
77
+ {content}
78
+ </Pressable>
79
+ ) : (
80
+ <View style={surfaceStyle}>{content}</View>
81
+ );
63
82
 
64
- return <View style={surfaceStyle}>{content}</View>;
83
+ return (
84
+ <View
85
+ style={[
86
+ defaultStyle.shadowWrap,
87
+ shadow ? defaultStyle.shadowOn : null,
88
+ // Android's elevation shadow needs an opaque shape with a
89
+ // matching borderRadius to cast a correctly-rounded shadow —
90
+ // a fully transparent outer view can shadow as a square box
91
+ // instead of following the rounded silhouette.
92
+ shadow && backgroundColor ? { backgroundColor } : null,
93
+ style,
94
+ ]}
95
+ >
96
+ {inner}
97
+ </View>
98
+ );
65
99
  }
@@ -11,7 +11,7 @@ import Animated, {
11
11
  } from 'react-native-reanimated';
12
12
  import { useTopOffset, heroContentStyle } from './hero-screen-header';
13
13
 
14
- const DEFAULT_HEADER_HEIGHT = 250;
14
+ const DEFAULT_HEADER_HEIGHT = 220;
15
15
 
16
16
  const defaultStyle = StyleSheet.create({
17
17
  container: {
@@ -50,8 +50,11 @@ const defaultStyle = StyleSheet.create({
50
50
  * Background color for the scroll view container.
51
51
  * @param {string} [props.headerBackgroundColor]
52
52
  * Background color shown behind the header image.
53
- * @param {number} [props.headerHeight=250]
53
+ * @param {number} [props.headerHeight=220]
54
54
  * Visible height of the parallax header image (excludes the top offset).
55
+ * Matches HeroScreenFixed/HeroScreenOverlay's default — kept in
56
+ * sync deliberately so all three variants render identically when
57
+ * no explicit headerHeight is passed.
55
58
  * @param {React.ComponentType<{style?: any, children?: React.ReactNode}>}
56
59
  * [props.ContentComponent=View]
57
60
  * Wrapper used for the content area below the header.
@@ -0,0 +1,97 @@
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 { View, Text, StyleSheet } from 'react-native';
6
+ import Panel from './panel';
7
+
8
+ const defaultStyle = StyleSheet.create({
9
+ container: {
10
+ gap: 16,
11
+ },
12
+ dayHeading: {
13
+ fontWeight: '700',
14
+ marginBottom: 8,
15
+ },
16
+ event: {
17
+ gap: 4,
18
+ marginBottom: 12,
19
+ },
20
+ eventTitle: {
21
+ fontWeight: '700',
22
+ },
23
+ });
24
+
25
+ /**
26
+ * Schedule
27
+ *
28
+ * Renders a multi-day event schedule — one Panel per day. Panels
29
+ * cycle through `washColors` in order (e.g. [primarySoft,
30
+ * secondarySoft] gives primary/secondary/primary/secondary...), so
31
+ * consecutive days are visually distinct without any per-day
32
+ * configuration needed.
33
+ *
34
+ * @param {object} props
35
+ * @param {object} props.schedule Shape: { [dateKey]: Array<{ event: string, description?: string }> }
36
+ * @param {string[]} [props.washColors] Colors cycled through per day, in order. Omit for no alternation (every Panel uses Panel's own default).
37
+ * @param {(dateKey: string) => string} [props.formatDate] Defaults to showing the raw key as-is.
38
+ * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
39
+ * @param {Function} [props.onPressEvent] (event, dateKey) => void
40
+ * @param {boolean} [props.shadow] Passed through to every Panel.
41
+ * @param {object} [props.containerStyle]
42
+ * @param {object} [props.dayHeadingStyle]
43
+ * @param {object} [props.eventTitleStyle]
44
+ * @param {object} [props.eventDescriptionStyle]
45
+ *
46
+ * @returns {JSX.Element}
47
+ */
48
+ export default function Schedule({
49
+ schedule,
50
+ washColors = [],
51
+ formatDate = (dateKey) => dateKey,
52
+ TextComponent = Text,
53
+ onPressEvent,
54
+ shadow,
55
+ containerStyle,
56
+ dayHeadingStyle,
57
+ eventTitleStyle,
58
+ eventDescriptionStyle,
59
+ }) {
60
+ const days = Object.keys(schedule || {});
61
+
62
+ return (
63
+ <View style={[defaultStyle.container, containerStyle]}>
64
+ {days.map((dateKey, dayIndex) => {
65
+ const events = schedule[dateKey] || [];
66
+ const backgroundColor = washColors.length
67
+ ? washColors[dayIndex % washColors.length]
68
+ : undefined;
69
+
70
+ return (
71
+ <Panel key={dateKey} backgroundColor={backgroundColor} shadow={shadow}>
72
+ <TextComponent style={[defaultStyle.dayHeading, dayHeadingStyle]}>
73
+ {formatDate(dateKey)}
74
+ </TextComponent>
75
+
76
+ {events.map((item, eventIndex) => (
77
+ <View key={eventIndex} style={defaultStyle.event}>
78
+ <TextComponent
79
+ style={[defaultStyle.eventTitle, eventTitleStyle]}
80
+ onPress={onPressEvent ? () => onPressEvent(item, dateKey) : undefined}
81
+ >
82
+ {item.event}
83
+ </TextComponent>
84
+
85
+ {item.description ? (
86
+ <TextComponent style={eventDescriptionStyle}>
87
+ {item.description}
88
+ </TextComponent>
89
+ ) : null}
90
+ </View>
91
+ ))}
92
+ </Panel>
93
+ );
94
+ })}
95
+ </View>
96
+ );
97
+ }
package/lib/debug.js ADDED
@@ -0,0 +1,5 @@
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
+ export { debugRouteState } from './utils/route-debug.js'
@@ -0,0 +1,262 @@
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 { useEffect, useRef, useState } from 'react';
6
+
7
+ function getByPath(obj, path) {
8
+ if (!path) return obj;
9
+ return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
10
+ }
11
+
12
+ const DEFAULT_REGULAR_MS = 15 * 60 * 1000;
13
+ const cache = new Map();
14
+
15
+ function getCacheKey(uri, bearerToken) {
16
+ return `${uri}::${bearerToken || ''}`;
17
+ }
18
+
19
+ function isFresh(entry, regularMs) {
20
+ if (!entry) return false;
21
+ return (Date.now() - entry.fetchedAt) < regularMs;
22
+ }
23
+
24
+ /**
25
+ * fetchJson
26
+ *
27
+ * Generic cached JSON fetch with bearer-token support. This is the
28
+ * same logic that used to live as news-specific `fetchNewsJson` —
29
+ * it was always fully generic underneath, just named after its
30
+ * first caller. Shares one in-memory cache across every caller,
31
+ * keyed by uri+bearerToken — two different widgets requesting the
32
+ * same uri within the TTL window share a single cached response
33
+ * rather than both fetching.
34
+ *
35
+ * @param {object} args
36
+ * @param {string} args.uri
37
+ * @param {string} [args.bearerToken]
38
+ * @param {boolean} [args.force] Bypass the cache for this call.
39
+ * @param {number} [args.regularMs] Defaults to 15 minutes.
40
+ *
41
+ * @returns {Promise<any>}
42
+ */
43
+ export async function fetchJson({ uri, bearerToken, force = false, regularMs = DEFAULT_REGULAR_MS }) {
44
+ const key = getCacheKey(uri, bearerToken);
45
+ const entry = cache.get(key);
46
+
47
+ if (!force && isFresh(entry, regularMs)) {
48
+ return entry.data;
49
+ }
50
+
51
+ const headers = {};
52
+ if (bearerToken) {
53
+ headers.Authorization = `Bearer ${bearerToken}`;
54
+ }
55
+
56
+ const res = await fetch(uri, { method: 'GET', headers });
57
+
58
+ if (!res.ok) {
59
+ throw new Error(`Request failed (${res.status})`);
60
+ }
61
+
62
+ const data = await res.json();
63
+ cache.set(key, { data, fetchedAt: Date.now() });
64
+ return data;
65
+ }
66
+
67
+ /**
68
+ * clearJsonCache
69
+ *
70
+ * @param {object} args
71
+ * @param {string} args.uri
72
+ * @param {string} [args.bearerToken]
73
+ */
74
+ export function clearJsonCache({ uri, bearerToken }) {
75
+ cache.delete(getCacheKey(uri, bearerToken));
76
+ }
77
+
78
+ async function defaultFetcher({ uri, bearerToken, force, regularMs }) {
79
+ return fetchJson({ uri, bearerToken, force, regularMs });
80
+ }
81
+
82
+ // ── Manual refresh cooldown ──────────────────────────────────
83
+ // A second, separate tracker from the data cache above — this
84
+ // governs "when is the refresh BUTTON allowed to be pressed again",
85
+ // not "is the data stale". Shared per uri+bearerToken (same key
86
+ // scheme as the data cache), so multiple widgets pointed at the
87
+ // same endpoint share one cooldown rather than each tracking their
88
+ // own independently.
89
+
90
+ const MANUAL_LOCK_MS = 15 * 60 * 1000;
91
+ const ERROR_REFRESH_MS = 5 * 60 * 1000;
92
+ const cooldowns = new Map(); // key -> expiresAt timestamp
93
+
94
+ function getCooldownRemaining(key) {
95
+ const expiresAt = cooldowns.get(key);
96
+ if (!expiresAt) return 0;
97
+ return Math.max(0, expiresAt - Date.now());
98
+ }
99
+
100
+ function setCooldown(key, durationMs) {
101
+ cooldowns.set(key, Date.now() + durationMs);
102
+ }
103
+
104
+ function clearCooldown(key) {
105
+ cooldowns.delete(key);
106
+ }
107
+
108
+ /**
109
+ * useJsonApiData
110
+ *
111
+ * Generic fetch + poll + reshape — the data-fetching half of what
112
+ * used to live inline inside NewsWidget, now reusable by any
113
+ * RemoteDataWidget-style component regardless of what it's actually
114
+ * fetching.
115
+ *
116
+ * Always returns the same shape:
117
+ * { items, loading, error, refresh, manualRefresh, canManualRefresh }
118
+ *
119
+ * `refresh` is unchanged from before — exposed so a component can
120
+ * wire it into useImperativeHandle for an external
121
+ * ref.current.refresh() call (e.g. triggered by a push
122
+ * notification). It always works and is NOT subject to the
123
+ * cooldown below — a real notification shouldn't get silently
124
+ * dropped because someone tapped a refresh button five minutes ago.
125
+ *
126
+ * `manualRefresh` is new — meant for a UI refresh button. It
127
+ * respects a per-endpoint cooldown:
128
+ * - succeeds → button locked for 15 minutes
129
+ * - errors → button locked for only 5 minutes (fail faster,
130
+ * recover faster)
131
+ * - whenever the automatic polling interval fires (success OR
132
+ * error), the cooldown is cleared immediately — the data's
133
+ * fresh either way, no reason to keep the button blocked.
134
+ * `canManualRefresh` reflects whether the button should currently
135
+ * be enabled.
136
+ *
137
+ * Caching behaviour (the underlying data, separate from the above):
138
+ * the initial load may reuse a fresh cached response. Interval
139
+ * ticks and any forced refresh always force a real fetch.
140
+ *
141
+ * @param {object} options
142
+ * @param {string} options.uri
143
+ * @param {string} [options.bearerToken]
144
+ * @param {string} [options.jpath] Dot-path to the array within the response (e.g. 'data.results'). Omit if the response IS the array.
145
+ * @param {(rawItem: any) => any} [options.mapItem] Reshapes each raw item into whatever the consuming widget expects. Defaults to identity (no reshaping).
146
+ * @param {number} [options.refreshIntervalMs] Polling interval. Defaults to 0 (no polling — fetch once on mount).
147
+ * @param {number} [options.regularMs] Cache freshness window, passed to the default fetcher. Defaults to 15 minutes.
148
+ * @param {number} [options.manualLockMs] Defaults to 15 minutes.
149
+ * @param {number} [options.errorRefreshMs] Defaults to 5 minutes.
150
+ * @param {(args: { uri: string, bearerToken?: string, force: boolean }) => Promise<any>} [options.fetcher] Defaults to the cached fetchJson above.
151
+ *
152
+ * @returns {{ items: any[], loading: boolean, error: string|null, refresh: () => Promise<void>, manualRefresh: () => Promise<void>, canManualRefresh: boolean }}
153
+ */
154
+ export function useJsonApiData({
155
+ uri,
156
+ bearerToken,
157
+ jpath,
158
+ mapItem = (item) => item,
159
+ refreshIntervalMs = 0,
160
+ regularMs,
161
+ fetcher = defaultFetcher,
162
+ manualLockMs = MANUAL_LOCK_MS,
163
+ errorRefreshMs = ERROR_REFRESH_MS,
164
+ }) {
165
+ const [items, setItems] = useState([]);
166
+ const [loading, setLoading] = useState(true);
167
+ const [error, setError] = useState(null);
168
+ const intervalRef = useRef(null);
169
+ const cooldownTimeoutRef = useRef(null);
170
+
171
+ const cooldownKey = getCacheKey(uri, bearerToken);
172
+
173
+ const [canManualRefresh, setCanManualRefresh] = useState(
174
+ () => getCooldownRemaining(cooldownKey) <= 0
175
+ );
176
+
177
+ function applyCooldown(durationMs) {
178
+ setCooldown(cooldownKey, durationMs);
179
+ setCanManualRefresh(false);
180
+
181
+ if (cooldownTimeoutRef.current) {
182
+ clearTimeout(cooldownTimeoutRef.current);
183
+ }
184
+ cooldownTimeoutRef.current = setTimeout(() => {
185
+ setCanManualRefresh(true);
186
+ }, durationMs);
187
+ }
188
+
189
+ function releaseCooldown() {
190
+ clearCooldown(cooldownKey);
191
+ if (cooldownTimeoutRef.current) {
192
+ clearTimeout(cooldownTimeoutRef.current);
193
+ cooldownTimeoutRef.current = null;
194
+ }
195
+ setCanManualRefresh(true);
196
+ }
197
+
198
+ async function load(force = false, source = 'initial') {
199
+ setError(null);
200
+ let succeeded = true;
201
+
202
+ try {
203
+ const raw = await fetcher({ uri, bearerToken, force, regularMs });
204
+ const resolved = getByPath(raw, jpath);
205
+
206
+ if (!Array.isArray(resolved)) {
207
+ console.warn(
208
+ jpath
209
+ ? `useJsonApiData: jpath "${jpath}" did not resolve to an array (got ${typeof resolved}). Check that this path actually points at the array in your API's response. Falling back to an empty list.`
210
+ : `useJsonApiData: expected the response to be an array, but got ${typeof resolved}. If your API wraps the array in an object (e.g. { items: [...] }), pass jpath to point at it (e.g. jpath="items"). Falling back to an empty list.`
211
+ );
212
+ }
213
+
214
+ setItems(Array.isArray(resolved) ? resolved.map(mapItem) : []);
215
+ }
216
+ catch (e) {
217
+ succeeded = false;
218
+ setError(e.message || 'Unable to load data.');
219
+ }
220
+
221
+ setLoading(false);
222
+
223
+ if (source === 'manual') {
224
+ applyCooldown(succeeded ? manualLockMs : errorRefreshMs);
225
+ }
226
+ else if (source === 'system') {
227
+ releaseCooldown();
228
+ }
229
+ }
230
+
231
+ useEffect(() => {
232
+ setLoading(true);
233
+ setCanManualRefresh(getCooldownRemaining(cooldownKey) <= 0);
234
+ load(false, 'initial');
235
+
236
+ if (refreshIntervalMs > 0) {
237
+ intervalRef.current = setInterval(() => load(true, 'system'), refreshIntervalMs);
238
+ return () => clearInterval(intervalRef.current);
239
+ }
240
+ // eslint-disable-next-line react-hooks/exhaustive-deps
241
+ }, [uri, bearerToken, jpath, refreshIntervalMs]);
242
+
243
+ useEffect(() => {
244
+ return () => {
245
+ if (cooldownTimeoutRef.current) {
246
+ clearTimeout(cooldownTimeoutRef.current);
247
+ }
248
+ };
249
+ }, []);
250
+
251
+ return {
252
+ items,
253
+ loading,
254
+ error,
255
+ refresh: () => load(true, 'external'),
256
+ manualRefresh: () => {
257
+ if (getCooldownRemaining(cooldownKey) > 0) return;
258
+ load(true, 'manual');
259
+ },
260
+ canManualRefresh,
261
+ };
262
+ }
@@ -0,0 +1,32 @@
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
+ /**
6
+ * useStaticData
7
+ *
8
+ * The "I already have this data" counterpart to useJsonApiData —
9
+ * same returned shape, so DataListWidget (or anything else
10
+ * consuming either hook) never needs to know which one it's
11
+ * actually talking to. No fetch, no polling, no cache, no manual
12
+ * refresh — there's nothing to refresh; the data only changes if
13
+ * the caller passes different `data` on a re-render.
14
+ *
15
+ * @param {object} options
16
+ * @param {any[]} options.data
17
+ * @param {(rawItem: any) => any} [options.mapItem] Reshapes each raw item into whatever the consuming widget expects. Defaults to identity (no reshaping).
18
+ *
19
+ * @returns {{ items: any[], loading: false, error: null, refresh: () => void, manualRefresh: () => void, canManualRefresh: false }}
20
+ */
21
+ export function useStaticData({ data, mapItem = (item) => item }) {
22
+ const items = (data || []).map(mapItem);
23
+
24
+ return {
25
+ items,
26
+ loading: false,
27
+ error: null,
28
+ refresh: () => {},
29
+ manualRefresh: () => {},
30
+ canManualRefresh: false,
31
+ };
32
+ }
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- const VERSION = "0.0.14";
5
+ const VERSION = "0.0.16";
6
6
 
7
7
  // TODO: @opndev/util?
8
8
  export { formatPrice } from './utils/format-price.js';
@@ -0,0 +1,21 @@
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 { useNavigationState } from '@react-navigation/native';
6
+ import { useRouter } from 'expo-router';
7
+
8
+ export function debugRouteState() {
9
+ const router = useRouter();
10
+ const navState = useNavigationState((state) => state);
11
+
12
+ console.log('[news stack]',
13
+ JSON.stringify(navState?.routes?.map((r) => (
14
+ { name: r.name, params: r.params, key: r.key }
15
+ )),
16
+ null,
17
+ 2
18
+ )
19
+ );
20
+
21
+ }