@opndev/react-native-events 0.0.11 → 0.0.12

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,10 @@
1
1
  Revision history for @opndev/opndev-react-native-events
2
2
 
3
+ 0.0.12 2026-06-25 23:24:45Z
4
+
5
+ * Add top bar to package
6
+ * Add more HeroScreen types
7
+
3
8
  0.0.11 2026-06-25 04:12:51Z
4
9
 
5
10
  * 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,9 +1,16 @@
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, Image, StyleSheet } from 'react-native';
2
- import { useSafeAreaTopInset } from '../hooks/use-safe-area-top-inset.js';
6
+ import { useAppScreenOffset } from '../hooks/use-app-screen-offset.js';
3
7
 
4
8
  const styles = StyleSheet.create({
5
9
  headerImage: { width: '100%', height: '100%' },
6
10
  titleWrap: { paddingHorizontal: 8, paddingTop: 8, paddingBottom: 8 },
11
+ // Matches ParallaxScrollView's own `content` style — single source of
12
+ // truth so every Hero variant lines up on body padding/gap.
13
+ heroContent: { padding: 32, gap: 16 },
7
14
  });
8
15
 
9
16
  export function renderHeaderImage(headerImage) {
@@ -18,24 +25,42 @@ export function HeaderOverlay({ headerOverlay }) {
18
25
  return <View style={styles.titleWrap}>{headerOverlay}</View>;
19
26
  }
20
27
 
28
+ /**
29
+ * useTopOffset
30
+ *
31
+ * How much vertical space must be reserved at the very top of a
32
+ * screen before its own content begins. AppScreen is now the sole
33
+ * owner of this number — it's always the safe-area inset, plus a
34
+ * top bar's height if AppScreen has one. There's no fallback to a
35
+ * direct safe-area call here anymore: if a screen has no AppScreen
36
+ * ancestor, this is 0, by design.
37
+ *
38
+ * @returns {number}
39
+ */
40
+ export function useTopOffset() {
41
+ return useAppScreenOffset();
42
+ }
43
+
21
44
  /**
22
45
  * useHeroHeaderHeight
23
46
  *
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.
47
+ * Shared header-height calculation for every HeroScreen variant
48
+ * (Parallax, Fixed, Overlay), so the space above the header
49
+ * behaves identically across all three and automatically starts
50
+ * below an AppScreen's top bar when present.
27
51
  *
28
- * @param {number} [headerHeight] Visible image height, as passed by the caller (excludes the inset)
29
- * @returns {{ insetTop: number, headerHeight: number, totalHeaderHeight: number }}
52
+ * @param {number} [headerHeight] Visible image height, as passed by the caller (excludes the top offset)
53
+ * @returns {{ topOffset: number, headerHeight: number, totalHeaderHeight: number }}
30
54
  */
31
55
  export function useHeroHeaderHeight(headerHeight = 220) {
32
- const insetTop = useSafeAreaTopInset();
56
+ const topOffset = useTopOffset();
33
57
 
34
58
  return {
35
- insetTop,
59
+ topOffset,
36
60
  headerHeight,
37
- totalHeaderHeight: insetTop + headerHeight,
61
+ totalHeaderHeight: topOffset + headerHeight,
38
62
  };
39
63
  }
40
64
 
41
65
  export { styles as heroImageStyles };
66
+ 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,8 @@ import { View, ScrollView, StyleSheet } from 'react-native';
6
6
  import {
7
7
  renderHeaderImage,
8
8
  HeaderOverlay,
9
- useHeroHeaderHeight
9
+ useHeroHeaderHeight,
10
+ heroContentStyle,
10
11
  } from './hero-screen-header';
11
12
 
12
13
  const defaultStyle = StyleSheet.create({
@@ -34,7 +35,12 @@ const defaultStyle = StyleSheet.create({
34
35
  * (visible content gets covered by the image as the user scrolls
35
36
  * up). The image never overlaps the status bar — a solid
36
37
  * headerBackgroundColor strip (sized to the safe-area top inset,
37
- * via useHeroHeaderHeight) sits above it.
38
+ * via useHeroHeaderHeight) sits above it. Body content is wrapped
39
+ * the same way ParallaxScrollView wraps its children — padding: 32,
40
+ * gap: 8 — so spacing matches the parallax variant exactly. The
41
+ * spacer that reserves room for the header stays outside that
42
+ * padded wrapper, same as Parallax keeps its header outside its
43
+ * own padded content area.
38
44
  *
39
45
  * @param {object} props
40
46
  * @param {React.ReactNode} props.children
@@ -62,7 +68,7 @@ export default function HeroScreenOverlay({
62
68
  headerStyle,
63
69
  contentStyle,
64
70
  }) {
65
- const { insetTop, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
71
+ const { topOffset, totalHeaderHeight } = useHeroHeaderHeight(headerHeight);
66
72
 
67
73
  return (
68
74
  <View
@@ -73,12 +79,15 @@ export default function HeroScreenOverlay({
73
79
  ]}
74
80
  >
75
81
  <ContentComponent
76
- style={[contentStyle]}
82
+ style={{ flex: 1 }}
77
83
  contentContainerStyle={{ paddingTop: 0 }}
78
84
  >
79
85
  <View style={{ height: totalHeaderHeight }} />
80
- <HeaderOverlay headerOverlay={headerOverlay} />
81
- {children}
86
+
87
+ <View style={[heroContentStyle, contentStyle]}>
88
+ <HeaderOverlay headerOverlay={headerOverlay} />
89
+ {children}
90
+ </View>
82
91
  </ContentComponent>
83
92
 
84
93
  <View
@@ -92,7 +101,7 @@ export default function HeroScreenOverlay({
92
101
  <View
93
102
  style={[
94
103
  defaultStyle.imageInner,
95
- { top: insetTop, height: headerHeight },
104
+ { top: topOffset, height: headerHeight },
96
105
  ]}
97
106
  >
98
107
  {renderHeaderImage(headerImage)}
@@ -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.12";
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) => (
package/package.json CHANGED
@@ -34,5 +34,5 @@
34
34
  },
35
35
  "sideEffects": false,
36
36
  "type": "module",
37
- "version": "0.0.11"
37
+ "version": "0.0.12"
38
38
  }