@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 +11 -0
- package/lib/actions/qrcode.js +0 -3
- package/lib/components/app-screen-top-bar.jsx +97 -0
- package/lib/components/app-screen.jsx +78 -0
- package/lib/components/base-screen.jsx +15 -8
- package/lib/components/hero-screen-fixed.jsx +14 -5
- package/lib/components/hero-screen-header.jsx +76 -10
- package/lib/components/hero-screen-parallax.jsx +4 -0
- package/lib/components/hero-screen.jsx +24 -7
- package/lib/components/parallax-scroll-view.js +142 -0
- package/lib/components/parallax-scroll-view.jsx +2 -2
- package/lib/components/scaled-logo.jsx +82 -0
- package/lib/components.js +1 -0
- package/lib/hooks/use-app-screen-offset.js +24 -0
- package/lib/hooks/use-safe-area-top-inset.js +4 -0
- package/lib/index.js +1 -1
- package/lib/screen-registry.js +17 -3
- package/lib/screens/news-item-screen.jsx +40 -25
- package/lib/widgets/news-widget.jsx +171 -0
- package/lib/widgets.js +5 -0
- package/package.json +3 -3
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 :/
|
package/lib/actions/qrcode.js
CHANGED
|
@@ -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 {
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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:
|
|
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={
|
|
95
|
+
style={{ flex: 1 }}
|
|
89
96
|
contentContainerStyle={{ paddingTop: totalHeaderHeight }}
|
|
90
97
|
>
|
|
91
|
-
<
|
|
92
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
29
|
-
* @returns {{
|
|
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
|
|
97
|
+
const topOffset = useTopOffset();
|
|
33
98
|
|
|
34
99
|
return {
|
|
35
|
-
|
|
100
|
+
topOffset,
|
|
36
101
|
headerHeight,
|
|
37
|
-
totalHeaderHeight:
|
|
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
|
-
|
|
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 {
|
|
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={
|
|
85
|
+
style={{ flex: 1 }}
|
|
77
86
|
contentContainerStyle={{ paddingTop: 0 }}
|
|
78
87
|
>
|
|
79
88
|
<View style={{ height: totalHeaderHeight }} />
|
|
80
|
-
|
|
81
|
-
{
|
|
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:
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/index.js
CHANGED
package/lib/screen-registry.js
CHANGED
|
@@ -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
|
-
|
|
69
|
-
<
|
|
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
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
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
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.
|
|
37
|
+
"version": "0.0.13"
|
|
38
38
|
}
|