@opndev/react-native-events 0.0.11 → 0.0.13

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,16 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.13 2026-06-26 03:59:55Z
4
+
5
+ * Update news item segment to be included as a widget.
6
+ This infra is highly sus for generic dynamic pages infra. I need some more
7
+ hours in a day to get perhaps get that to work nicely. but getting there.
8
+
9
+ 0.0.12 2026-06-25 23:24:45Z
10
+
11
+ * Add top bar to package
12
+ * Add more HeroScreen types
13
+
3
14
  0.0.11 2026-06-25 04:12:51Z
4
15
 
5
16
  * registry not registery :/
@@ -66,9 +66,6 @@ export default class QRCodeAction {
66
66
  ? this.buildPayload(values)
67
67
  : values;
68
68
 
69
- console.log(this.endpoint);
70
- console.log(payload);
71
-
72
69
  const res = await fetch(this.endpoint.url, {
73
70
  method: this.endpoint.method || 'POST',
74
71
  headers: {
@@ -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
+ import { cloneElement, isValidElement } from 'react';
5
+ import { View, StyleSheet, useWindowDimensions } from 'react-native';
6
+
7
+ export const DEFAULT_TOP_BAR_HEIGHT = 56;
8
+ export const DEFAULT_MAX_WIDTH_PERCENT = 33;
9
+
10
+ const styles = StyleSheet.create({
11
+ bar: {
12
+ flexDirection: 'row',
13
+ alignItems: 'flex-end',
14
+ width: '100%',
15
+ },
16
+ });
17
+
18
+ /**
19
+ * Injects the bar's own height (unless the caller already set an
20
+ * explicit height or width) and a pixel maxWidth clamp into a logo
21
+ * element (e.g. ScaledLogo). The clamp is passed as a `maxWidth`
22
+ * PROP, not a style percentage — ScaledLogo re-derives height from
23
+ * the clamped width itself, so nothing ever letterboxes.
24
+ */
25
+ function applyBarStyling(element, { height, maxWidthPixels }) {
26
+ if (!isValidElement(element)) return element;
27
+
28
+ const props = {};
29
+
30
+ if (element.props.height == null && element.props.width == null) {
31
+ props.height = height;
32
+ }
33
+
34
+ if (maxWidthPixels != null && element.props.maxWidth == null) {
35
+ props.maxWidth = maxWidthPixels;
36
+ }
37
+
38
+ return cloneElement(element, props);
39
+ }
40
+
41
+ /**
42
+ * AppScreenTopBar
43
+ *
44
+ * Purely presentational — the logo / sponsor-logo row. It knows
45
+ * nothing about safe areas, positioning, or stacking order; all of
46
+ * that is owned by AppScreen, which renders this as its `topBar`.
47
+ *
48
+ * `logo` / `sponsorLogo` automatically receive a `height` matching
49
+ * the bar's own height, and a pixel `maxWidth` clamp (default 33%
50
+ * of the screen width) that ScaledLogo applies aspect-ratio-aware —
51
+ * so nothing to keep in sync manually, and no letterboxing if a
52
+ * logo would otherwise render too wide on a narrow screen. Pass an
53
+ * explicit `height` or `width` on the logo element yourself to opt
54
+ * out of the auto-sizing.
55
+ *
56
+ * With no `sponsorLogo`, `logo` is centered rather than left-aligned
57
+ * — there's nothing to space it apart from.
58
+ *
59
+ * @param {object} props
60
+ * @param {React.ReactNode} [props.logo]
61
+ * @param {React.ReactNode} [props.sponsorLogo]
62
+ * @param {number} [props.height] Defaults to DEFAULT_TOP_BAR_HEIGHT (56). If you override this, pass the same value to AppScreen's `topBarHeight` so the reserved offset still matches.
63
+ * @param {number} [props.maxWidthPercent] Defaults to 33 (% of screen width). Pass null to disable the clamp entirely.
64
+ * @param {number} [props.horizontalPadding] Space from the screen edges. Defaults to 24.
65
+ * @param {string} [props.backgroundColor]
66
+ * @param {object} [props.style]
67
+ *
68
+ * @returns {JSX.Element}
69
+ */
70
+ export default function AppScreenTopBar({
71
+ logo,
72
+ sponsorLogo,
73
+ height = DEFAULT_TOP_BAR_HEIGHT,
74
+ maxWidthPercent = DEFAULT_MAX_WIDTH_PERCENT,
75
+ horizontalPadding = 24,
76
+ verticalPadding = 8,
77
+ backgroundColor,
78
+ style,
79
+ }) {
80
+ const { width: windowWidth } = useWindowDimensions();
81
+ const maxWidthPixels = maxWidthPercent != null ? (windowWidth * maxWidthPercent) / 100 : null;
82
+ const justifyContent = sponsorLogo ? 'space-between' : 'center';
83
+
84
+ return (
85
+ <View
86
+ style={[
87
+ styles.bar,
88
+ { height, justifyContent, paddingHorizontal: horizontalPadding, paddingBottom: verticalPadding },
89
+ backgroundColor ? { backgroundColor } : null,
90
+ style,
91
+ ]}
92
+ >
93
+ {applyBarStyling(logo, { height, maxWidthPixels })}
94
+ {sponsorLogo ? applyBarStyling(sponsorLogo, { height, maxWidthPixels }) : null}
95
+ </View>
96
+ );
97
+ }
@@ -0,0 +1,78 @@
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, StyleSheet } from 'react-native';
6
+ import { useSafeAreaTopInset } from '../hooks/use-safe-area-top-inset';
7
+ import { AppScreenOffsetContext } from '../hooks/use-app-screen-offset';
8
+ import { DEFAULT_TOP_BAR_HEIGHT } from './app-screen-top-bar';
9
+
10
+ const defaultStyle = StyleSheet.create({
11
+ container: {
12
+ flex: 1,
13
+ },
14
+ topBarWrap: {
15
+ position: 'absolute',
16
+ top: 0,
17
+ left: 0,
18
+ width: '100%',
19
+ zIndex: 10,
20
+ },
21
+ });
22
+
23
+ /**
24
+ * AppScreen
25
+ *
26
+ * The mandatory root wrapper for every screen in the app. Owns
27
+ * safe-area handling as a baseline — wrap every screen in this,
28
+ * even ones with no visible top bar. Optionally floats a `topBar`
29
+ * (e.g. AppScreenTopBar) above everything, and publishes the
30
+ * combined offset via AppScreenOffsetContext so any nested screen
31
+ * (HeroScreen, HeroScreenParallax, HeroScreenFixed,
32
+ * HeroScreenOverlay, Screen) automatically starts its own
33
+ * content/header below it — no per-screen wiring needed.
34
+ *
35
+ * In practice, you only need ONE AppScreen, at the root of your
36
+ * navigator (e.g. wrapping <Slot/> in app/_layout.tsx), rather
37
+ * than wrapping every individual screen file — context propagates
38
+ * to the whole tree underneath it.
39
+ *
40
+ * Screens rendered WITHOUT an AppScreen ancestor get an offset of
41
+ * 0 (no safe-area protection) — that's intentional now that
42
+ * AppScreen is the single owner of this concern, not a fallback
43
+ * gap. Make sure the root wrap is always in place.
44
+ *
45
+ * @param {object} props
46
+ * @param {React.ReactNode} props.children The screen tree (HeroScreen*, Screen, a navigator, etc.)
47
+ * @param {React.ReactNode} [props.topBar] e.g. <AppScreenTopBar .../>. Omit for no branding bar.
48
+ * @param {number} [props.topBarHeight] Visible height of `topBar`. Defaults to AppScreenTopBar's own default height (56) — only override if you also customized AppScreenTopBar's height.
49
+ *
50
+ * @returns {JSX.Element}
51
+ */
52
+ export default function AppScreen({
53
+ children,
54
+ topBar,
55
+ topBarHeight = DEFAULT_TOP_BAR_HEIGHT,
56
+ }) {
57
+ const insetTop = useSafeAreaTopInset();
58
+ const offset = topBar ? insetTop + topBarHeight : insetTop;
59
+
60
+ return (
61
+ <AppScreenOffsetContext.Provider value={offset}>
62
+ <View style={defaultStyle.container}>
63
+ {children}
64
+
65
+ {topBar ? (
66
+ <View
67
+ style={[
68
+ defaultStyle.topBarWrap,
69
+ { height: offset, paddingTop: insetTop },
70
+ ]}
71
+ >
72
+ {topBar}
73
+ </View>
74
+ ) : null}
75
+ </View>
76
+ </AppScreenOffsetContext.Provider>
77
+ );
78
+ }
@@ -1,10 +1,17 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import { View, ScrollView, StyleSheet } from 'react-native';
2
- import { useSafeAreaTopInset } from '../hooks/use-safe-area-top-inset.js';
6
+ import { useTopOffset } from './hero-screen-header';
3
7
 
4
8
  const defaultStyle = StyleSheet.create({
5
9
  container: {
6
10
  flex: 1,
7
11
  },
12
+ content: {
13
+ flex: 1,
14
+ },
8
15
  });
9
16
 
10
17
  /**
@@ -13,9 +20,9 @@ const defaultStyle = StyleSheet.create({
13
20
  * Plain screen wrapper for content that doesn't have a hero header
14
21
  * image (e.g. NewsListScreen, FoodMenuScreen). Encapsulates the
15
22
  * same background / safe-area / scroll-container concerns as the
16
- * HeroScreen variants, minus the header, so non-hero screens still
17
- * get consistent top-inset behaviour rather than each handling it
18
- * (or forgetting to handle it) separately.
23
+ * HeroScreen variants, minus the header. Automatically starts
24
+ * content below an enclosing AppScreen's bar when present (via
25
+ * useTopOffset), same as the Hero variants do.
19
26
  *
20
27
  * @param {object} props
21
28
  * @param {React.ReactNode} props.children
@@ -23,7 +30,7 @@ const defaultStyle = StyleSheet.create({
23
30
  * @param {React.ComponentType} [props.ContentComponent] Defaults to ScrollView; pass a list component (FlatList/SectionList) if children render as list items
24
31
  * @param {object} [props.containerStyle]
25
32
  * @param {object} [props.contentStyle]
26
- * @param {boolean} [props.useSafeArea] Pad content by the top safe-area inset. Defaults true.
33
+ * @param {boolean} [props.useSafeArea] Pad content by the top offset (safe-area inset, or AppScreen bar height if present). Defaults true.
27
34
  *
28
35
  * @returns {JSX.Element}
29
36
  */
@@ -35,7 +42,7 @@ export default function BaseScreen({
35
42
  contentStyle,
36
43
  useSafeArea = true,
37
44
  }) {
38
- const insetTop = useSafeAreaTopInset();
45
+ const topOffset = useTopOffset();
39
46
 
40
47
  return (
41
48
  <View
@@ -46,8 +53,8 @@ export default function BaseScreen({
46
53
  ]}
47
54
  >
48
55
  <ContentComponent
49
- style={[contentStyle]}
50
- contentContainerStyle={useSafeArea ? { paddingTop: insetTop } : null}
56
+ style={[defaultStyle.content, contentStyle]}
57
+ contentContainerStyle={useSafeArea ? { paddingTop: topOffset } : null}
51
58
  >
52
59
  {children}
53
60
  </ContentComponent>
@@ -1,8 +1,13 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import { View, ScrollView, StyleSheet } from 'react-native';
2
6
  import {
3
7
  renderHeaderImage,
4
8
  HeaderOverlay,
5
- useHeroHeaderHeight
9
+ useHeroHeaderHeight,
10
+ heroContentStyle,
6
11
  } from './hero-screen-header';
7
12
 
8
13
  const defaultStyle = StyleSheet.create({
@@ -28,7 +33,9 @@ const defaultStyle = StyleSheet.create({
28
33
  * Header image stays pinned in place; only the content below it
29
34
  * scrolls. The image never overlaps the status bar — a solid
30
35
  * headerBackgroundColor strip (sized to the safe-area top inset,
31
- * via useHeroHeaderHeight) sits above it.
36
+ * via useHeroHeaderHeight) sits above it. Body content is wrapped
37
+ * the same way ParallaxScrollView wraps its children — padding: 32,
38
+ * gap: 16 — so spacing matches the parallax variant exactly.
32
39
  *
33
40
  * @param {object} props
34
41
  * @param {React.ReactNode} props.children
@@ -85,11 +92,13 @@ export default function HeroScreenFixed({
85
92
  </View>
86
93
 
87
94
  <ContentComponent
88
- style={[contentStyle]}
95
+ style={{ flex: 1 }}
89
96
  contentContainerStyle={{ paddingTop: totalHeaderHeight }}
90
97
  >
91
- <HeaderOverlay headerOverlay={headerOverlay} />
92
- {children}
98
+ <View style={[heroContentStyle, contentStyle]}>
99
+ <HeaderOverlay headerOverlay={headerOverlay} />
100
+ {children}
101
+ </View>
93
102
  </ContentComponent>
94
103
  </View>
95
104
  );
@@ -1,11 +1,59 @@
1
- import { View, Image, StyleSheet } from 'react-native';
2
- import { useSafeAreaTopInset } from '../hooks/use-safe-area-top-inset.js';
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, Image, Pressable, StyleSheet } from 'react-native';
6
+ import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
7
+ import { useAppScreenOffset } from '../hooks/use-app-screen-offset';
3
8
 
4
9
  const styles = StyleSheet.create({
5
10
  headerImage: { width: '100%', height: '100%' },
6
11
  titleWrap: { paddingHorizontal: 8, paddingTop: 8, paddingBottom: 8 },
12
+ // Matches ParallaxScrollView's own `content` style — single source of
13
+ // truth so every Hero variant lines up on body padding/gap.
14
+ heroContent: { padding: 32, gap: 16 },
15
+ backButton: {
16
+ width: 40,
17
+ height: 40,
18
+ borderRadius: 20,
19
+ backgroundColor: 'rgba(0,0,0,0.55)',
20
+ alignItems: 'center',
21
+ justifyContent: 'center',
22
+ elevation: 4,
23
+ shadowColor: '#000',
24
+ shadowOffset: { width: 0, height: 2 },
25
+ shadowOpacity: 0.3,
26
+ shadowRadius: 3,
27
+ },
7
28
  });
8
29
 
30
+ /**
31
+ * BackButton
32
+ *
33
+ * Floating back-chevron button meant to sit over a Hero variant's
34
+ * header image. Router-agnostic — same pattern as onSlidePress
35
+ * elsewhere — the package renders the button, the app decides what
36
+ * tapping it does (typically router.back()).
37
+ *
38
+ * Renders nothing if `onBack` isn't provided, so adding this to a
39
+ * Hero variant is a no-op for screens that don't pass it.
40
+ *
41
+ * @param {object} props
42
+ * @param {Function} [props.onBack]
43
+ * @param {string} [props.iconColor] Defaults to white.
44
+ * @param {number} [props.iconSize] Defaults to 22.
45
+ * @param {object} [props.style] Positioning is the caller's responsibility (e.g. absolute top/left) — this component doesn't position itself.
46
+ */
47
+ export function BackButton({ onBack, iconColor = '#fff', iconSize = 22, style }) {
48
+ if (!onBack) return null;
49
+
50
+ return (
51
+ <Pressable onPress={onBack} style={[styles.backButton, style]}>
52
+ <MaterialCommunityIcons name="arrow-left" size={iconSize} color={iconColor} />
53
+ </Pressable>
54
+ );
55
+ }
56
+
9
57
  export function renderHeaderImage(headerImage) {
10
58
  if (!headerImage) return null;
11
59
  const fit = headerImage.fit || 'cover';
@@ -18,24 +66,42 @@ export function HeaderOverlay({ headerOverlay }) {
18
66
  return <View style={styles.titleWrap}>{headerOverlay}</View>;
19
67
  }
20
68
 
69
+ /**
70
+ * useTopOffset
71
+ *
72
+ * How much vertical space must be reserved at the very top of a
73
+ * screen before its own content begins. AppScreen is now the sole
74
+ * owner of this number — it's always the safe-area inset, plus a
75
+ * top bar's height if AppScreen has one. There's no fallback to a
76
+ * direct safe-area call here anymore: if a screen has no AppScreen
77
+ * ancestor, this is 0, by design.
78
+ *
79
+ * @returns {number}
80
+ */
81
+ export function useTopOffset() {
82
+ return useAppScreenOffset();
83
+ }
84
+
21
85
  /**
22
86
  * useHeroHeaderHeight
23
87
  *
24
- * Shared safe-area calculation for all HeroScreen variants, so the
25
- * "space above the header" behaves identically across Parallax,
26
- * Fixed, and Overlay rather than each computing it separately.
88
+ * Shared header-height calculation for every HeroScreen variant
89
+ * (Parallax, Fixed, Overlay), so the space above the header
90
+ * behaves identically across all three and automatically starts
91
+ * below an AppScreen's top bar when present.
27
92
  *
28
- * @param {number} [headerHeight] Visible image height, as passed by the caller (excludes the inset)
29
- * @returns {{ insetTop: number, headerHeight: number, totalHeaderHeight: number }}
93
+ * @param {number} [headerHeight] Visible image height, as passed by the caller (excludes the top offset)
94
+ * @returns {{ topOffset: number, headerHeight: number, totalHeaderHeight: number }}
30
95
  */
31
96
  export function useHeroHeaderHeight(headerHeight = 220) {
32
- const insetTop = useSafeAreaTopInset();
97
+ const topOffset = useTopOffset();
33
98
 
34
99
  return {
35
- insetTop,
100
+ topOffset,
36
101
  headerHeight,
37
- totalHeaderHeight: insetTop + headerHeight,
102
+ totalHeaderHeight: topOffset + headerHeight,
38
103
  };
39
104
  }
40
105
 
41
106
  export { styles as heroImageStyles };
107
+ export const heroContentStyle = styles.heroContent;
@@ -1,3 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import { View, StyleSheet } from 'react-native';
2
6
  import ParallaxScrollView from './parallax-scroll-view';
3
7
  import { renderHeaderImage, HeaderOverlay } from './hero-screen-header';
@@ -6,7 +6,9 @@ import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
7
  renderHeaderImage,
8
8
  HeaderOverlay,
9
- useHeroHeaderHeight
9
+ BackButton,
10
+ useHeroHeaderHeight,
11
+ heroContentStyle,
10
12
  } from './hero-screen-header';
11
13
 
12
14
  const defaultStyle = StyleSheet.create({
@@ -34,12 +36,18 @@ const defaultStyle = StyleSheet.create({
34
36
  * (visible content gets covered by the image as the user scrolls
35
37
  * up). The image never overlaps the status bar — a solid
36
38
  * headerBackgroundColor strip (sized to the safe-area top inset,
37
- * via useHeroHeaderHeight) sits above it.
39
+ * via useHeroHeaderHeight) sits above it. Body content is wrapped
40
+ * the same way ParallaxScrollView wraps its children — padding: 32,
41
+ * gap: 16 — so spacing matches the parallax variant exactly. The
42
+ * spacer that reserves room for the header stays outside that
43
+ * padded wrapper, same as Parallax keeps its header outside its
44
+ * own padded content area.
38
45
  *
39
46
  * @param {object} props
40
47
  * @param {React.ReactNode} props.children
41
48
  * @param {object} [props.headerImage]
42
49
  * @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.
43
51
  * @param {string} [props.backgroundColor]
44
52
  * @param {string} [props.headerBackgroundColor]
45
53
  * @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
@@ -54,6 +62,7 @@ export default function HeroScreenOverlay({
54
62
  children,
55
63
  headerImage,
56
64
  headerOverlay,
65
+ onBack,
57
66
  backgroundColor,
58
67
  headerBackgroundColor,
59
68
  headerHeight = 220,
@@ -62,7 +71,7 @@ export default function HeroScreenOverlay({
62
71
  headerStyle,
63
72
  contentStyle,
64
73
  }) {
65
- const { insetTop, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
74
+ const { topOffset, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
66
75
 
67
76
  return (
68
77
  <View
@@ -73,12 +82,15 @@ export default function HeroScreenOverlay({
73
82
  ]}
74
83
  >
75
84
  <ContentComponent
76
- style={[contentStyle]}
85
+ style={{ flex: 1 }}
77
86
  contentContainerStyle={{ paddingTop: 0 }}
78
87
  >
79
88
  <View style={{ height: totalHeaderHeight }} />
80
- <HeaderOverlay headerOverlay={headerOverlay} />
81
- {children}
89
+
90
+ <View style={[heroContentStyle, contentStyle]}>
91
+ <HeaderOverlay headerOverlay={headerOverlay} />
92
+ {children}
93
+ </View>
82
94
  </ContentComponent>
83
95
 
84
96
  <View
@@ -92,11 +104,16 @@ export default function HeroScreenOverlay({
92
104
  <View
93
105
  style={[
94
106
  defaultStyle.imageInner,
95
- { top: insetTop, height: headerHeight },
107
+ { top: topOffset, height: headerHeight },
96
108
  ]}
97
109
  >
98
110
  {renderHeaderImage(headerImage)}
99
111
  </View>
112
+
113
+ <BackButton
114
+ onBack={onBack}
115
+ style={{ position: 'absolute', top: topOffset + 12, left: 12 }}
116
+ />
100
117
  </View>
101
118
  </View>
102
119
  );
@@ -0,0 +1,142 @@
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 } from 'react-native';
6
+ import Animated, {
7
+ interpolate,
8
+ useAnimatedRef,
9
+ useAnimatedStyle,
10
+ useScrollOffset,
11
+ } from 'react-native-reanimated';
12
+ import { useTopOffset, heroContentStyle } from './hero-screen-header';
13
+
14
+ const DEFAULT_HEADER_HEIGHT = 250;
15
+
16
+ const defaultStyle = StyleSheet.create({
17
+ container: {
18
+ flex: 1,
19
+ },
20
+ header: {
21
+ overflow: 'hidden',
22
+ zIndex: 1,
23
+ },
24
+ imageInner: {
25
+ position: 'absolute',
26
+ left: 0,
27
+ width: '100%',
28
+ },
29
+ });
30
+
31
+ /**
32
+ * A scroll view with a parallax header image and a pluggable content wrapper.
33
+ *
34
+ * The component is intentionally theme-agnostic. Consumers are expected to pass
35
+ * resolved colors and, if needed, a custom content wrapper component.
36
+ *
37
+ * The header reserves space above the image via useTopOffset() — the
38
+ * safe-area inset, plus an AppScreen top bar's height if one is
39
+ * present — so the image itself always renders at exactly
40
+ * `headerHeight`, with `headerBackgroundColor` as backdrop behind
41
+ * whatever's reserved above it. Same contract as HeroScreenFixed /
42
+ * HeroScreenOverlay.
43
+ *
44
+ * @param {object} props
45
+ * @param {React.ReactNode} props.children
46
+ * Content rendered below the parallax header.
47
+ * @param {React.ReactElement} props.headerImage
48
+ * Element rendered inside the header area, usually an image.
49
+ * @param {string} [props.backgroundColor='#fff']
50
+ * Background color for the scroll view container.
51
+ * @param {string} [props.headerBackgroundColor]
52
+ * Background color shown behind the header image.
53
+ * @param {number} [props.headerHeight=250]
54
+ * Visible height of the parallax header image (excludes the top offset).
55
+ * @param {React.ComponentType<{style?: any, children?: React.ReactNode}>}
56
+ * [props.ContentComponent=View]
57
+ * Wrapper used for the content area below the header.
58
+ * @param {import('react-native').StyleProp<import('react-native').ViewStyle>}
59
+ * [props.containerStyle]
60
+ * Optional style overrides for the outer scroll view container.
61
+ * @param {import('react-native').StyleProp<import('react-native').ViewStyle>}
62
+ * [props.headerStyle]
63
+ * Optional style overrides for the header container.
64
+ * @param {import('react-native').StyleProp<import('react-native').ViewStyle>}
65
+ * [props.contentStyle]
66
+ * Optional style overrides for the content wrapper.
67
+ * @returns {JSX.Element}
68
+ */
69
+ export default function ParallaxScrollView({
70
+ children,
71
+ headerImage,
72
+ headerBackgroundColor,
73
+ backgroundColor = '#fff',
74
+ colorScheme = 'light',
75
+ headerHeight = DEFAULT_HEADER_HEIGHT,
76
+ containerStyle,
77
+ headerStyle,
78
+ contentStyle,
79
+ ContentComponent = View,
80
+ }) {
81
+ const scrollRef = useAnimatedRef();
82
+ const scrollOffset = useScrollOffset(scrollRef);
83
+ const topOffset = useTopOffset();
84
+ const effectiveHeaderHeight = headerHeight + topOffset;
85
+
86
+ const headerAnimatedStyle = useAnimatedStyle(() => {
87
+ return {
88
+ transform: [
89
+ {
90
+ translateY: interpolate(
91
+ scrollOffset.value,
92
+ [-headerHeight, 0, headerHeight],
93
+ [-headerHeight / 2, 0, headerHeight * 0.75]
94
+ ),
95
+ },
96
+ {
97
+ scale: interpolate(
98
+ scrollOffset.value,
99
+ [-headerHeight, 0, headerHeight],
100
+ [2, 1, 1]
101
+ ),
102
+ },
103
+ ],
104
+ };
105
+ });
106
+
107
+ return (
108
+ <Animated.ScrollView
109
+ ref={scrollRef}
110
+ style={[
111
+ defaultStyle.container,
112
+ { backgroundColor },
113
+ containerStyle,
114
+ ]}
115
+ scrollEventThrottle={16}
116
+ >
117
+ <Animated.View
118
+ style={[
119
+ defaultStyle.header,
120
+ {
121
+ height: effectiveHeaderHeight,
122
+ backgroundColor: headerBackgroundColor,
123
+ },
124
+ headerAnimatedStyle,
125
+ headerStyle,
126
+ ]}
127
+ >
128
+ <View
129
+ style={[
130
+ defaultStyle.imageInner,
131
+ { top: topOffset, height: headerHeight },
132
+ ]}
133
+ >
134
+ {headerImage}
135
+ </View>
136
+ </Animated.View>
137
+ <ContentComponent style={[heroContentStyle, contentStyle]}>
138
+ {children}
139
+ </ContentComponent>
140
+ </Animated.ScrollView>
141
+ );
142
+ }
@@ -24,8 +24,8 @@ const defaultStyle = StyleSheet.create({
24
24
  zIndex: 1,
25
25
  },
26
26
  content: {
27
- padding: 32,
28
- gap: 16,
27
+ padding: 16,
28
+ gap: 8,
29
29
  },
30
30
  });
31
31
 
@@ -0,0 +1,82 @@
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 { Image } from 'react-native';
6
+
7
+ /**
8
+ * ScaledLogo
9
+ *
10
+ * Fits a logo image to a fixed height OR width while preserving its
11
+ * real aspect ratio — pass whichever dimension you actually care
12
+ * about, and the other is computed automatically. If both are
13
+ * given, both are used as-is (no aspect-ratio computation).
14
+ *
15
+ * `maxWidth` (in pixels, not a style percentage) is applied AFTER
16
+ * the natural size is computed, and — critically — height is
17
+ * re-derived from the clamped width so the box always matches the
18
+ * aspect ratio. A style-based `maxWidth: '33%'` clamp can't do this:
19
+ * it shrinks width but leaves height untouched, which (with
20
+ * resizeMode="contain") leaves the visible artwork letterboxed
21
+ * inside a too-tall box. Passing `maxWidth` as a prop here avoids
22
+ * that entirely.
23
+ *
24
+ * Deliberately computes explicit width/height rather than relying
25
+ * on the `aspectRatio` style property — Image has special intrinsic
26
+ * sizing behaviour that can override `aspectRatio` and fall back to
27
+ * the image's native pixel size when only one explicit dimension is
28
+ * given via style. Providing both width and height directly
29
+ * sidesteps that entirely.
30
+ *
31
+ * Works out-of-the-box for local assets (require('...')), since RN
32
+ * can read their real dimensions synchronously via
33
+ * Image.resolveAssetSource. For remote ({ uri: ... }) sources, pass
34
+ * an explicit `aspectRatio` instead — those dimensions aren't known
35
+ * until the image actually loads.
36
+ *
37
+ * @param {object} props
38
+ * @param {any} props.source Image source (require(...) or { uri }).
39
+ * @param {number} [props.height] Computed from `width` + aspect ratio if omitted.
40
+ * @param {number} [props.width] Computed from `height` + aspect ratio if omitted. If neither height nor width is given, defaults to height=32.
41
+ * @param {number} [props.maxWidth] Pixel clamp applied after natural sizing; height is re-derived from the clamped width, never letterboxed.
42
+ * @param {number} [props.aspectRatio] Required for remote ({ uri }) sources; ignored for local assets, where it's read automatically.
43
+ * @param {object} [props.style]
44
+ *
45
+ * @returns {JSX.Element}
46
+ */
47
+ export default function ScaledLogo({ source, height, width, maxWidth, aspectRatio, style }) {
48
+ let resolvedAspectRatio = aspectRatio;
49
+
50
+ if (typeof source === 'number') {
51
+ // Local asset — RN can resolve real dimensions synchronously.
52
+ const { width: naturalWidth, height: naturalHeight } = Image.resolveAssetSource(source);
53
+ resolvedAspectRatio = naturalWidth / naturalHeight;
54
+ }
55
+
56
+ let finalWidth = width;
57
+ let finalHeight = height;
58
+
59
+ if (finalWidth != null && finalHeight == null) {
60
+ finalHeight = resolvedAspectRatio ? finalWidth / resolvedAspectRatio : undefined;
61
+ } else if (finalHeight != null && finalWidth == null) {
62
+ finalWidth = resolvedAspectRatio ? finalHeight * resolvedAspectRatio : undefined;
63
+ } else if (finalWidth == null && finalHeight == null) {
64
+ finalHeight = 32;
65
+ finalWidth = resolvedAspectRatio ? finalHeight * resolvedAspectRatio : undefined;
66
+ }
67
+ // If both width and height were explicitly given, use both as-is.
68
+
69
+ // Clamp width if needed, then re-derive height so nothing letterboxes.
70
+ if (maxWidth != null && finalWidth != null && finalWidth > maxWidth) {
71
+ finalWidth = maxWidth;
72
+ finalHeight = resolvedAspectRatio ? finalWidth / resolvedAspectRatio : finalHeight;
73
+ }
74
+
75
+ return (
76
+ <Image
77
+ source={source}
78
+ resizeMode="contain"
79
+ style={[{ height: finalHeight, width: finalWidth }, style]}
80
+ />
81
+ );
82
+ }
package/lib/components.js CHANGED
@@ -10,6 +10,7 @@ export { default as TileBase } from './components/tile-base.jsx';
10
10
  export { default as Tile } from './components/tile.jsx';
11
11
  export { default as GradientTile } from './components/gradient-tile.jsx';
12
12
  export { default as QRCodeForm } from './components/qr-code-form.jsx';
13
+ export { default as ScaledLogo } from './components/scaled-logo.jsx';
13
14
 
14
15
  export { openUrl, openApp, openExternal } from './utils/launch.js';
15
16
 
@@ -0,0 +1,24 @@
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 { createContext, useContext } from 'react';
6
+
7
+ /**
8
+ * AppScreenOffsetContext
9
+ *
10
+ * Provided by AppScreen with the total height of its persistent
11
+ * top bar (safe-area inset + visible bar height). Defaults to 0,
12
+ * so any screen rendered WITHOUT an AppScreen ancestor behaves
13
+ * exactly as before — this is additive, not a breaking change.
14
+ */
15
+ export const AppScreenOffsetContext = createContext(0);
16
+
17
+ /**
18
+ * useAppScreenOffset
19
+ *
20
+ * @returns {number} Height of the enclosing AppScreen's bar, or 0 if none.
21
+ */
22
+ export function useAppScreenOffset() {
23
+ return useContext(AppScreenOffsetContext);
24
+ }
@@ -1,3 +1,7 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
1
5
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
2
6
 
3
7
  /**
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.11";
5
+ const VERSION = "0.0.13";
6
6
 
7
7
  // TODO: @opndev/util?
8
8
  export { formatPrice } from './utils/format-price.js';
@@ -4,10 +4,12 @@
4
4
 
5
5
  import React from 'react';
6
6
 
7
- import BaseScreen from './components/base-screen';
8
7
  import HeroScreen from './components/hero-screen';
9
8
  import HeroScreenParallax from './components/hero-screen-parallax';
10
9
  import HeroScreenFixed from './components/hero-screen-fixed';
10
+ import BaseScreen from './components/base-screen';
11
+ import AppScreen from './components/app-screen';
12
+ import AppScreenTopBar from './components/app-screen-top-bar';
11
13
  import Tile from './components/tile';
12
14
 
13
15
  import FoodMenuScreen from './screens/food-menu-screen';
@@ -65,8 +67,20 @@ export function createScreens({
65
67
  <HeroScreenFixed {...props} />
66
68
  ),
67
69
 
68
- BaseScreen: (props) => (
69
- <BaseScreen {...props} />
70
+ HeroScreenOverlay: (props) => (
71
+ <HeroScreenOverlay {...props} />
72
+ ),
73
+
74
+ Screen: (props) => (
75
+ <Screen {...props} />
76
+ ),
77
+
78
+ AppScreen: (props) => (
79
+ <AppScreen {...props} />
80
+ ),
81
+
82
+ AppScreenTopBar: (props) => (
83
+ <AppScreenTopBar {...props} />
70
84
  ),
71
85
 
72
86
  FoodMenuScreen: (props) => (
@@ -13,7 +13,6 @@ import {
13
13
  View,
14
14
  } from 'react-native';
15
15
  import Markdown from 'react-native-markdown-display';
16
-
17
16
  import HeroScreen from '../components/hero-screen';
18
17
  import { fetchNewsJson } from '../actions/news';
19
18
 
@@ -53,18 +52,16 @@ export default function NewsItemScreen({
53
52
  TextComponent = Text,
54
53
  MarkdownComponent = Markdown,
55
54
  defaultHeaderImage,
56
-
55
+ onBack,
57
56
  backgroundColor,
58
57
  headerBackgroundColor,
59
58
  headerHeight,
60
-
61
59
  containerStyle,
62
60
  headerStyle,
63
61
  contentStyle,
64
62
  summaryStyle,
65
63
  titleStyle,
66
64
  markdownStyle,
67
-
68
65
  loadingLabel = 'Loading...',
69
66
  errorLabel = 'Unable to load news item.',
70
67
  }) {
@@ -100,10 +97,7 @@ export default function NewsItemScreen({
100
97
  setLoading(false);
101
98
  }
102
99
 
103
- if (!item) return;
104
-
105
100
  const headerImage = defaultHeaderImage;
106
-
107
101
  // const headerImage = item?.image
108
102
  // ? {
109
103
  // source: { uri: item.image },
@@ -112,11 +106,47 @@ export default function NewsItemScreen({
112
106
  // }
113
107
  // : defaultHeaderImage;
114
108
 
109
+ // Loading and error states now render through the same HeroScreen
110
+ // shell (so chrome/background/header stay consistent) instead of
111
+ // bailing out to a blank screen before item is set.
112
+ if (loading || error || !item) {
113
+ return (
114
+ <HeroScreen
115
+ TextComponent={TextComponent}
116
+ titleStyle={titleStyle}
117
+ headerImage={headerImage}
118
+ onBack={onBack}
119
+ backgroundColor={backgroundColor}
120
+ headerBackgroundColor={headerBackgroundColor}
121
+ headerHeight={headerHeight}
122
+ containerStyle={containerStyle}
123
+ headerStyle={headerStyle}
124
+ contentStyle={contentStyle}
125
+ >
126
+ <View style={defaultStyle.content}>
127
+ {loading ? (
128
+ <View>
129
+ <ActivityIndicator />
130
+ <TextComponent>{loadingLabel}</TextComponent>
131
+ </View>
132
+ ) : null}
133
+
134
+ {!loading && error ? (
135
+ <TextComponent>
136
+ {error}
137
+ </TextComponent>
138
+ ) : null}
139
+ </View>
140
+ </HeroScreen>
141
+ );
142
+ }
143
+
115
144
  return (
116
145
  <HeroScreen
117
146
  TextComponent={TextComponent}
118
147
  titleStyle={titleStyle}
119
148
  headerImage={headerImage}
149
+ onBack={onBack}
120
150
  headerOverlay={
121
151
  <TextComponent type="title">
122
152
  {item.title}
@@ -130,24 +160,9 @@ export default function NewsItemScreen({
130
160
  contentStyle={contentStyle}
131
161
  >
132
162
  <View style={defaultStyle.content}>
133
- {loading ? (
134
- <View>
135
- <ActivityIndicator />
136
- <TextComponent>{loadingLabel}</TextComponent>
137
- </View>
138
- ) : null}
139
-
140
- {!loading && error ? (
141
- <TextComponent>
142
- {error}
143
- </TextComponent>
144
- ) : null}
145
-
146
- {!loading && !error ? (
147
- <MarkdownComponent style={markdownStyle}>
148
- {item?.content || ''}
149
- </MarkdownComponent>
150
- ) : null}
163
+ <MarkdownComponent style={markdownStyle}>
164
+ {item?.content || ''}
165
+ </MarkdownComponent>
151
166
  </View>
152
167
  </HeroScreen>
153
168
  );
@@ -0,0 +1,171 @@
1
+ import React, {
2
+ forwardRef,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+ import {
9
+ ActivityIndicator,
10
+ Pressable,
11
+ StyleSheet,
12
+ Text,
13
+ View,
14
+ } from 'react-native';
15
+
16
+ import { fetchNewsJson } from '../actions/news';
17
+
18
+ const defaultStyle = StyleSheet.create({
19
+ container: {
20
+ gap: 8,
21
+ },
22
+ title: {
23
+ fontWeight: '700',
24
+ },
25
+ item: {
26
+ flexDirection: 'row',
27
+ gap: 8,
28
+ },
29
+ bullet: {
30
+ opacity: 0.6,
31
+ },
32
+ itemText: {
33
+ flex: 1,
34
+ },
35
+ });
36
+
37
+ /**
38
+ * NewsWidget
39
+ *
40
+ * Compact, embeddable "latest N news items" panel — meant to sit
41
+ * inside another page's body (e.g. as one of HeroScreen's children
42
+ * sections), not to own the screen itself. Unlike NewsListScreen,
43
+ * it has no ScrollView and no pull-to-refresh, since both break
44
+ * once nested inside a parent page that's already scrollable.
45
+ *
46
+ * Refreshes itself automatically every `refreshIntervalMs` (default
47
+ * 15 minutes). For anything else that should trigger a refetch —
48
+ * most notably a push notification once that infra exists — attach
49
+ * a ref and call `ref.current.refresh()` from wherever that handler
50
+ * ends up living:
51
+ *
52
+ * const newsRef = useRef(null);
53
+ * <NewsWidget ref={newsRef} uri={...} />
54
+ * // later, e.g. inside a notification handler:
55
+ * newsRef.current?.refresh();
56
+ *
57
+ * @param {object} props
58
+ * @param {string} props.uri
59
+ * @param {string} [props.bearerToken]
60
+ * @param {number} [props.limit] Max items shown. Defaults to 4.
61
+ * @param {string} [props.title] Defaults to 'News'.
62
+ * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
63
+ * @param {Function} [props.onPressItem]
64
+ * @param {number} [props.refreshIntervalMs] Defaults to 15 minutes.
65
+ * @param {string} [props.backgroundColor]
66
+ * @param {object} [props.containerStyle]
67
+ * @param {object} [props.titleStyle]
68
+ * @param {object} [props.itemStyle]
69
+ * @param {object} [props.itemTextStyle]
70
+ * @param {object} [props.errorTextStyle]
71
+ * @param {string} [props.emptyLabel]
72
+ * @param {string} [props.errorLabel]
73
+ *
74
+ * @returns {JSX.Element}
75
+ */
76
+ const NewsWidget = forwardRef(function NewsWidget(
77
+ {
78
+ uri,
79
+ bearerToken,
80
+ limit = 4,
81
+ title = 'News',
82
+ TextComponent = Text,
83
+ onPressItem,
84
+ refreshIntervalMs = 15 * 60 * 1000,
85
+
86
+ backgroundColor,
87
+ containerStyle,
88
+ titleStyle,
89
+ itemStyle,
90
+ itemTextStyle,
91
+ errorTextStyle,
92
+
93
+ emptyLabel = 'No news items.',
94
+ errorLabel = 'Unable to load news.',
95
+ },
96
+ ref
97
+ ) {
98
+ const [items, setItems] = useState([]);
99
+ const [loading, setLoading] = useState(true);
100
+ const [error, setError] = useState(null);
101
+ const intervalRef = useRef(null);
102
+
103
+ async function load() {
104
+ setError(null);
105
+
106
+ try {
107
+ const data = await fetchNewsJson({ uri, bearerToken, force: true });
108
+ setItems((data.items || []).slice(0, limit));
109
+ }
110
+ catch (e) {
111
+ setError(e.message || errorLabel);
112
+ }
113
+
114
+ setLoading(false);
115
+ }
116
+
117
+ useImperativeHandle(ref, () => ({
118
+ refresh: load,
119
+ }), [uri, bearerToken, limit]);
120
+
121
+ useEffect(() => {
122
+ setLoading(true);
123
+ load();
124
+
125
+ intervalRef.current = setInterval(load, refreshIntervalMs);
126
+
127
+ return () => clearInterval(intervalRef.current);
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [uri, bearerToken, limit, refreshIntervalMs]);
130
+
131
+ return (
132
+ <View
133
+ style={[
134
+ defaultStyle.container,
135
+ backgroundColor ? { backgroundColor } : null,
136
+ containerStyle,
137
+ ]}
138
+ >
139
+ {title ? (
140
+ <TextComponent style={[defaultStyle.title, titleStyle]}>
141
+ {title}
142
+ </TextComponent>
143
+ ) : null}
144
+
145
+ {loading ? <ActivityIndicator /> : null}
146
+
147
+ {!loading && error ? (
148
+ <TextComponent style={errorTextStyle}>{error}</TextComponent>
149
+ ) : null}
150
+
151
+ {!loading && !error && !items.length ? (
152
+ <TextComponent style={errorTextStyle}>{emptyLabel}</TextComponent>
153
+ ) : null}
154
+
155
+ {!loading && !error && items.map((item) => (
156
+ <Pressable
157
+ key={item.id}
158
+ style={[defaultStyle.item, itemStyle]}
159
+ onPress={() => onPressItem?.(item)}
160
+ >
161
+ <TextComponent style={defaultStyle.bullet}>{'\u2022'}</TextComponent>
162
+ <TextComponent style={[defaultStyle.itemText, itemTextStyle]}>
163
+ {item.title}
164
+ </TextComponent>
165
+ </Pressable>
166
+ ))}
167
+ </View>
168
+ );
169
+ });
170
+
171
+ export default NewsWidget;
package/lib/widgets.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 { default as NewsWidget } from './widgets/news-widget.jsx';
package/package.json CHANGED
@@ -13,12 +13,12 @@
13
13
  "./components": "./lib/components.js",
14
14
  "./globals": "./lib/screen-registry.js",
15
15
  "./hero-screen-registry": "./lib/hero-screen-registry.js",
16
- "./hooks/use-safe-area-top-inset": "./lib/hooks/use-safe-area-top-inset.js",
17
16
  "./lite": "./lib/hero-screen-registry.js",
18
17
  "./notifications": "./lib/notifications.js",
19
18
  "./notifications-fcm": "./lib/notifications/fcm.js",
20
19
  "./screen-registry": "./lib/screen-registry.js",
21
- "./screens": "./lib/screens.js"
20
+ "./screens": "./lib/screens.js",
21
+ "./widgets": "./lib/widgets.js"
22
22
  },
23
23
  "keywords": [],
24
24
  "license": "GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions",
@@ -34,5 +34,5 @@
34
34
  },
35
35
  "sideEffects": false,
36
36
  "type": "module",
37
- "version": "0.0.11"
37
+ "version": "0.0.13"
38
38
  }