@opndev/react-native-events 0.0.15 → 0.0.17
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 +13 -0
- package/README.md +2 -2
- package/lib/actions/news.js +15 -93
- package/lib/components/hero-screen-fixed.jsx +27 -23
- package/lib/components/hero-screen-header.jsx +4 -0
- package/lib/components/panel-gradient.jsx +150 -0
- package/lib/components/panel.jsx +21 -4
- package/lib/components/schedule.jsx +97 -0
- package/lib/components.js +1 -0
- package/lib/hooks/use-json-api-data.js +262 -0
- package/lib/hooks/use-panel-contrast.js +20 -0
- package/lib/hooks/use-static-data.js +32 -0
- package/lib/index.js +1 -1
- package/lib/utils/colors.js +94 -11
- package/lib/widgets/data-list-widget.jsx +202 -0
- package/lib/widgets/remote-data-widget.jsx +88 -0
- package/lib/widgets/static-data-widget.jsx +50 -0
- package/lib/widgets.js +5 -1
- package/package.json +1 -1
- package/lib/widgets/news-widget.jsx +0 -190
package/Changes
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
Revision history for @opndev/opndev-react-native-events
|
|
2
2
|
|
|
3
|
+
0.0.17 2026-06-28 04:29:54Z
|
|
4
|
+
|
|
5
|
+
* Add gradient panel and proper implementation of contrast color, not just
|
|
6
|
+
"pick white or black"
|
|
7
|
+
* Fix HeroScreenFixed to be actually fixed to the content
|
|
8
|
+
|
|
9
|
+
0.0.16 2026-06-27 23:25:11Z
|
|
10
|
+
|
|
11
|
+
* Add widgets for dynamic and static content:
|
|
12
|
+
news now is dynamic and some other bits are static because we don't need to
|
|
13
|
+
do call to a backend to decide what scheduling or rules there are. Offline
|
|
14
|
+
mode is cool too
|
|
15
|
+
|
|
3
16
|
0.0.15 2026-06-26 22:56:26Z
|
|
4
17
|
|
|
5
18
|
* Add debugRouteState() to debug export to debug route states more easily
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
|
5
5
|
SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
6
6
|
-->
|
|
7
7
|
|
|
8
|
-
# Welcome to @opndev/
|
|
8
|
+
# Welcome to @opndev/react-native-events
|
|
9
9
|
|
|
10
10
|
Reusable React Native / Expo components for event-style apps.
|
|
11
11
|
|
|
@@ -26,7 +26,7 @@ This package provides a small set of building blocks for:
|
|
|
26
26
|
## Installation
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
npm install @opndev/
|
|
29
|
+
npm install @opndev/react-native-events
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
Peer dependencies are expected to be installed by the consuming app.
|
package/lib/actions/news.js
CHANGED
|
@@ -2,99 +2,21 @@
|
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
* @param {string} uri
|
|
19
|
-
* @param {string} [bearerToken]
|
|
20
|
-
*
|
|
21
|
-
* @returns {string}
|
|
22
|
-
*/
|
|
23
|
-
function getCacheKey(uri, bearerToken) {
|
|
24
|
-
return `${uri}::${bearerToken || ''}`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Returns true when a cache entry is still fresh.
|
|
29
|
-
*
|
|
30
|
-
* @param {object} entry
|
|
31
|
-
*
|
|
32
|
-
* @returns {boolean}
|
|
33
|
-
*/
|
|
34
|
-
function isFresh(entry) {
|
|
35
|
-
if (!entry) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return (Date.now() - entry.fetchedAt) < TTL_MS;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Fetches JSON from the news API with optional bearer token support.
|
|
44
|
-
*
|
|
45
|
-
* Cached responses are reused for 15 minutes unless `force` is set.
|
|
46
|
-
*
|
|
47
|
-
* @param {object} args
|
|
48
|
-
* @param {string} args.uri
|
|
49
|
-
* @param {string} [args.bearerToken]
|
|
50
|
-
* @param {boolean} [args.force]
|
|
51
|
-
*
|
|
52
|
-
* @returns {Promise<any>}
|
|
53
|
-
*/
|
|
54
|
-
export async function fetchNewsJson({
|
|
55
|
-
uri,
|
|
56
|
-
bearerToken,
|
|
57
|
-
force = false,
|
|
58
|
-
}) {
|
|
59
|
-
const key = getCacheKey(uri, bearerToken);
|
|
60
|
-
const entry = cache.get(key);
|
|
61
|
-
|
|
62
|
-
if (!force && isFresh(entry)) {
|
|
63
|
-
return entry.data;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const headers = {};
|
|
67
|
-
if (bearerToken) {
|
|
68
|
-
headers.Authorization = `Bearer ${bearerToken}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const res = await fetch(uri, {
|
|
72
|
-
method: 'GET',
|
|
73
|
-
headers,
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
const data = await res.json();
|
|
77
|
-
|
|
78
|
-
cache.set(key, {
|
|
79
|
-
data,
|
|
80
|
-
fetchedAt: Date.now(),
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
return data;
|
|
5
|
+
// This used to be its own standalone cache implementation. The
|
|
6
|
+
// logic was always fully generic underneath (just a TTL cache
|
|
7
|
+
// around fetch + bearer auth) — it's been moved to
|
|
8
|
+
// @opndev/react-native-events as useJsonApiData's default fetcher,
|
|
9
|
+
// shared with RemoteDataWidget. This file now just re-exports under
|
|
10
|
+
// the original names so NewsListScreen/NewsItemScreen don't need to
|
|
11
|
+
// change, and there's only one actual cache implementation rather
|
|
12
|
+
// than two that could drift apart.
|
|
13
|
+
|
|
14
|
+
import { fetchJson, clearJsonCache } from '@opndev/react-native-events/widgets';
|
|
15
|
+
|
|
16
|
+
export function fetchNewsJson({ uri, bearerToken, force }) {
|
|
17
|
+
return fetchJson({ uri, bearerToken, force });
|
|
84
18
|
}
|
|
85
19
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
*
|
|
89
|
-
* @param {object} args
|
|
90
|
-
* @param {string} args.uri
|
|
91
|
-
* @param {string} [args.bearerToken]
|
|
92
|
-
*
|
|
93
|
-
* @returns {void}
|
|
94
|
-
*/
|
|
95
|
-
export function clearNewsCache({
|
|
96
|
-
uri,
|
|
97
|
-
bearerToken,
|
|
98
|
-
}) {
|
|
99
|
-
cache.delete(getCacheKey(uri, bearerToken));
|
|
20
|
+
export function clearNewsCache({ uri, bearerToken }) {
|
|
21
|
+
return clearJsonCache({ uri, bearerToken });
|
|
100
22
|
}
|
|
@@ -15,11 +15,8 @@ const defaultStyle = StyleSheet.create({
|
|
|
15
15
|
container: {
|
|
16
16
|
flex: 1,
|
|
17
17
|
},
|
|
18
|
-
|
|
18
|
+
header: {
|
|
19
19
|
width: '100%',
|
|
20
|
-
position: 'absolute',
|
|
21
|
-
top: 0,
|
|
22
|
-
left: 0,
|
|
23
20
|
},
|
|
24
21
|
imageInner: {
|
|
25
22
|
position: 'absolute',
|
|
@@ -31,12 +28,24 @@ const defaultStyle = StyleSheet.create({
|
|
|
31
28
|
/**
|
|
32
29
|
* HeroScreenFixed
|
|
33
30
|
*
|
|
34
|
-
* Header image
|
|
35
|
-
* scrolls
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
31
|
+
* Header image is a normal, in-flow element at the top of the
|
|
32
|
+
* scroll — it scrolls away together with the content below it, at
|
|
33
|
+
* the same rate, with no pinning and no animation. Same structural
|
|
34
|
+
* shape as HeroScreenParallax, just without the scroll-driven
|
|
35
|
+
* scale/translateY transform — "parallax minus the animation."
|
|
36
|
+
*
|
|
37
|
+
* "Fixed" means fixed to the content below it, not fixed to the
|
|
38
|
+
* screen — the previous version of this file pinned the header via
|
|
39
|
+
* position:absolute, which was actually the same idea as
|
|
40
|
+
* HeroScreenOverlay, not what "Fixed" was meant to mean. Rebuilt
|
|
41
|
+
* from scratch; this file had no usages anywhere yet.
|
|
42
|
+
*
|
|
43
|
+
* ContentComponent's role matches HeroScreenParallax's: it wraps
|
|
44
|
+
* the body content for layout purposes (defaults to View, NOT a
|
|
45
|
+
* scroll container) — the actual scrolling is owned by the outer
|
|
46
|
+
* ScrollView, hardcoded, not swappable. If you were expecting
|
|
47
|
+
* ContentComponent to default to ScrollView (the old behavior),
|
|
48
|
+
* that's the one real API change here.
|
|
40
49
|
*
|
|
41
50
|
* @param {object} props
|
|
42
51
|
* @param {React.ReactNode} props.children Normal body content, and/or <CarouselScreen> elements for slides.
|
|
@@ -48,7 +57,7 @@ const defaultStyle = StyleSheet.create({
|
|
|
48
57
|
* @param {string} [props.backgroundColor]
|
|
49
58
|
* @param {string} [props.headerBackgroundColor]
|
|
50
59
|
* @param {number} [props.headerHeight] Visible image height (excludes the safe-area inset)
|
|
51
|
-
* @param {React.ComponentType} [props.ContentComponent]
|
|
60
|
+
* @param {React.ComponentType} [props.ContentComponent] Defaults to View — wraps body content, does NOT own scrolling.
|
|
52
61
|
* @param {object} [props.containerStyle]
|
|
53
62
|
* @param {object} [props.headerStyle]
|
|
54
63
|
* @param {object} [props.contentStyle]
|
|
@@ -65,7 +74,7 @@ export default function HeroScreenFixed({
|
|
|
65
74
|
backgroundColor,
|
|
66
75
|
headerBackgroundColor,
|
|
67
76
|
headerHeight = 220,
|
|
68
|
-
ContentComponent =
|
|
77
|
+
ContentComponent = View,
|
|
69
78
|
containerStyle,
|
|
70
79
|
headerStyle,
|
|
71
80
|
contentStyle,
|
|
@@ -74,7 +83,7 @@ export default function HeroScreenFixed({
|
|
|
74
83
|
const { slideElements, bodyChildren } = splitCarouselChildren(children);
|
|
75
84
|
|
|
76
85
|
return (
|
|
77
|
-
<
|
|
86
|
+
<ScrollView
|
|
78
87
|
style={[
|
|
79
88
|
defaultStyle.container,
|
|
80
89
|
backgroundColor ? { backgroundColor } : null,
|
|
@@ -83,7 +92,7 @@ export default function HeroScreenFixed({
|
|
|
83
92
|
>
|
|
84
93
|
<View
|
|
85
94
|
style={[
|
|
86
|
-
defaultStyle.
|
|
95
|
+
defaultStyle.header,
|
|
87
96
|
{ height: totalHeaderHeight },
|
|
88
97
|
headerBackgroundColor ? { backgroundColor: headerBackgroundColor } : null,
|
|
89
98
|
headerStyle,
|
|
@@ -105,15 +114,10 @@ export default function HeroScreenFixed({
|
|
|
105
114
|
</View>
|
|
106
115
|
</View>
|
|
107
116
|
|
|
108
|
-
<ContentComponent
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
>
|
|
112
|
-
<View style={[heroContentStyle, contentStyle]}>
|
|
113
|
-
<HeaderOverlay headerOverlay={headerOverlay} />
|
|
114
|
-
{bodyChildren}
|
|
115
|
-
</View>
|
|
117
|
+
<ContentComponent style={[heroContentStyle, contentStyle]}>
|
|
118
|
+
<HeaderOverlay headerOverlay={headerOverlay} />
|
|
119
|
+
{bodyChildren}
|
|
116
120
|
</ContentComponent>
|
|
117
|
-
</
|
|
121
|
+
</ScrollView>
|
|
118
122
|
);
|
|
119
123
|
}
|
|
@@ -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 { useState, isValidElement, Children } from 'react';
|
|
2
6
|
import { View, Image, Pressable, StyleSheet } from 'react-native';
|
|
3
7
|
import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
import { LinearGradient } from 'expo-linear-gradient';
|
|
4
|
+
import { contrastColor } from '../utils/colors';
|
|
5
|
+
import { PanelContrastContext } from '../hooks/use-panel-contrast';
|
|
6
|
+
|
|
7
|
+
// Six named directions — end is always the geometric opposite of
|
|
8
|
+
// start, so only one end of each axis needs naming. Want the
|
|
9
|
+
// reverse (e.g. bottom-left to top-right)? Swap the order of
|
|
10
|
+
// `colors` instead of asking for a 7th/8th direction keyword.
|
|
11
|
+
const DIRECTIONS = {
|
|
12
|
+
top: { start: { x: 0, y: 0 }, end: { x: 0, y: 1 } },
|
|
13
|
+
bottom: { start: { x: 0, y: 1 }, end: { x: 0, y: 0 } },
|
|
14
|
+
left: { start: { x: 0, y: 0 }, end: { x: 1, y: 0 } },
|
|
15
|
+
right: { start: { x: 1, y: 0 }, end: { x: 0, y: 0 } },
|
|
16
|
+
'top-left': { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } },
|
|
17
|
+
'top-right': { start: { x: 1, y: 0 }, end: { x: 0, y: 1 } },
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Local for now, not added to lib/utils/colors.js since I haven't
|
|
21
|
+
// seen that file's full current content — move it there later if
|
|
22
|
+
// you want it shared. Only handles 6-digit hex (#RRGGBB); named
|
|
23
|
+
// colors ('white') or rgba() strings aren't parsed.
|
|
24
|
+
function hexToRgb(hex) {
|
|
25
|
+
const clean = hex.replace('#', '');
|
|
26
|
+
const value = parseInt(clean, 16);
|
|
27
|
+
return { r: (value >> 16) & 255, g: (value >> 8) & 255, b: value & 255 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rgbToHex({ r, g, b }) {
|
|
31
|
+
return '#' + [r, g, b].map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function averageColor(colors) {
|
|
35
|
+
const rgbs = colors.map(hexToRgb);
|
|
36
|
+
const sum = rgbs.reduce((acc, c) => ({ r: acc.r + c.r, g: acc.g + c.g, b: acc.b + c.b }), { r: 0, g: 0, b: 0 });
|
|
37
|
+
const n = rgbs.length;
|
|
38
|
+
return rgbToHex({ r: sum.r / n, g: sum.g / n, b: sum.b / n });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const defaultStyle = StyleSheet.create({
|
|
42
|
+
shadowWrap: {
|
|
43
|
+
borderRadius: 16,
|
|
44
|
+
},
|
|
45
|
+
shadowOn: {
|
|
46
|
+
elevation: 4,
|
|
47
|
+
shadowColor: '#000',
|
|
48
|
+
shadowOffset: { width: 0, height: 2 },
|
|
49
|
+
shadowOpacity: 0.15,
|
|
50
|
+
shadowRadius: 6,
|
|
51
|
+
},
|
|
52
|
+
surface: {
|
|
53
|
+
borderRadius: 16,
|
|
54
|
+
overflow: 'hidden',
|
|
55
|
+
},
|
|
56
|
+
content: {
|
|
57
|
+
padding: 16,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* GradientPanel
|
|
63
|
+
*
|
|
64
|
+
* Same shape and behavior as Panel — sizes to its content, no
|
|
65
|
+
* forced layout on children, same shadow-split structure (an outer
|
|
66
|
+
* shadow-casting view, an inner clipping view, for the same reason
|
|
67
|
+
* Panel needs it: a shadow renders outside a view's own bounds, so
|
|
68
|
+
* it can't live on the same view that clips with overflow:hidden).
|
|
69
|
+
*
|
|
70
|
+
* The only difference: a gradient background instead of a flat
|
|
71
|
+
* `backgroundColor`. Stays theme-agnostic like Panel — pass raw
|
|
72
|
+
* resolved colors (e.g. ['#FFFFFF', theme.primary]), this component
|
|
73
|
+
* doesn't reach into your theme itself.
|
|
74
|
+
*
|
|
75
|
+
* Automatically derives a contrast text color from the AVERAGE of
|
|
76
|
+
* its `colors` stops (via contrastColor — same hue/saturation,
|
|
77
|
+
* contrasting lightness, not flat black/white), made available to
|
|
78
|
+
* anything rendered inside via PanelContrastContext — same
|
|
79
|
+
* mechanism Panel uses. This is a heuristic, not exact — a fade
|
|
80
|
+
* from white to a dark color averages to something mid-tone, which
|
|
81
|
+
* may not actually read well at either literal end of the gradient.
|
|
82
|
+
* Override with `contrastTextColor` if the heuristic guesses wrong.
|
|
83
|
+
*
|
|
84
|
+
* @param {object} props
|
|
85
|
+
* @param {React.ReactNode} props.children
|
|
86
|
+
* @param {string[]} [props.colors] Gradient stops, e.g. ['#FFFFFF', theme.primary]. Defaults to ['#FFFFFF', '#FFFFFF'] (solid white) if omitted.
|
|
87
|
+
* @param {'top'|'bottom'|'left'|'right'|'top-left'|'top-right'} [props.direction] Defaults to 'top'. Takes precedence over start/end if both are given.
|
|
88
|
+
* @param {{x: number, y: number}} [props.start] Raw escape hatch, ignored if `direction` is set.
|
|
89
|
+
* @param {{x: number, y: number}} [props.end] Raw escape hatch, ignored if `direction` is set.
|
|
90
|
+
* @param {number[]} [props.locations] Where each color stop lands (0-1). Optional — omit for an even spread.
|
|
91
|
+
* @param {string} [props.contrastTextColor] Overrides the automatically-derived (average-based) contrast color.
|
|
92
|
+
* @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
|
|
93
|
+
* @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
|
|
94
|
+
* @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
|
|
95
|
+
* @param {object} [props.contentStyle] Style for the inner content wrapper (default padding: 16).
|
|
96
|
+
*
|
|
97
|
+
* @returns {JSX.Element}
|
|
98
|
+
*/
|
|
99
|
+
export default function GradientPanel({
|
|
100
|
+
children,
|
|
101
|
+
colors = ['#FFFFFF', '#FFFFFF'],
|
|
102
|
+
direction = 'top',
|
|
103
|
+
start,
|
|
104
|
+
end,
|
|
105
|
+
locations,
|
|
106
|
+
contrastTextColor,
|
|
107
|
+
shadow = false,
|
|
108
|
+
onPress,
|
|
109
|
+
style,
|
|
110
|
+
contentStyle,
|
|
111
|
+
}) {
|
|
112
|
+
const resolvedDirection = DIRECTIONS[direction] ?? {
|
|
113
|
+
start: start ?? DIRECTIONS.top.start,
|
|
114
|
+
end: end ?? DIRECTIONS.top.end,
|
|
115
|
+
};
|
|
116
|
+
const resolvedContrast = contrastTextColor ?? contrastColor(averageColor(colors));
|
|
117
|
+
|
|
118
|
+
const content = (
|
|
119
|
+
<LinearGradient
|
|
120
|
+
colors={colors}
|
|
121
|
+
start={resolvedDirection.start}
|
|
122
|
+
end={resolvedDirection.end}
|
|
123
|
+
locations={locations}
|
|
124
|
+
style={defaultStyle.surface}
|
|
125
|
+
>
|
|
126
|
+
<PanelContrastContext.Provider value={resolvedContrast}>
|
|
127
|
+
<View style={[defaultStyle.content, contentStyle]}>
|
|
128
|
+
{children}
|
|
129
|
+
</View>
|
|
130
|
+
</PanelContrastContext.Provider>
|
|
131
|
+
</LinearGradient>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const wrapperStyle = [
|
|
135
|
+
defaultStyle.shadowWrap,
|
|
136
|
+
shadow ? defaultStyle.shadowOn : null,
|
|
137
|
+
shadow ? { backgroundColor: colors[0] } : null,
|
|
138
|
+
style,
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
if (onPress) {
|
|
142
|
+
return (
|
|
143
|
+
<Pressable onPress={onPress} style={wrapperStyle}>
|
|
144
|
+
{content}
|
|
145
|
+
</Pressable>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return <View style={wrapperStyle}>{content}</View>;
|
|
150
|
+
}
|
package/lib/components/panel.jsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
2
|
//
|
|
3
3
|
// SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
4
|
-
|
|
5
4
|
import React from 'react';
|
|
6
5
|
import { View, Pressable, StyleSheet } from 'react-native';
|
|
6
|
+
import { contrastColor } from '../utils/colors';
|
|
7
|
+
import { PanelContrastContext } from '../hooks/use-panel-contrast';
|
|
7
8
|
|
|
8
9
|
const defaultStyle = StyleSheet.create({
|
|
9
10
|
shadowWrap: {
|
|
@@ -43,9 +44,19 @@ const defaultStyle = StyleSheet.create({
|
|
|
43
44
|
* split means `shadow` can be toggled on/off with no other changes
|
|
44
45
|
* needed, and panels that don't use it are unaffected.
|
|
45
46
|
*
|
|
47
|
+
* Automatically derives a contrast text color from `backgroundColor`
|
|
48
|
+
* (via contrastColor) and makes it available to anything rendered
|
|
49
|
+
* inside, via PanelContrastContext — so e.g. DataListWidget picks up
|
|
50
|
+
* a correct default text color for whatever Panel it's sitting in,
|
|
51
|
+
* with zero per-usage color wiring. Pass `contrastTextColor`
|
|
52
|
+
* yourself to override the automatic guess; anything inside can
|
|
53
|
+
* still override further for individual pieces of text (e.g.
|
|
54
|
+
* DataListWidget's own itemTextStyle/titleStyle still win over this).
|
|
55
|
+
*
|
|
46
56
|
* @param {object} props
|
|
47
57
|
* @param {React.ReactNode} props.children
|
|
48
58
|
* @param {string} [props.backgroundColor]
|
|
59
|
+
* @param {string} [props.contrastTextColor] Overrides the automatically-derived contrast color.
|
|
49
60
|
* @param {boolean} [props.shadow] Floating-card drop shadow. Defaults to false.
|
|
50
61
|
* @param {Function} [props.onPress] If provided, the whole panel becomes pressable.
|
|
51
62
|
* @param {object} [props.style] Style for the outer (shadow-casting) wrapper.
|
|
@@ -56,20 +67,26 @@ const defaultStyle = StyleSheet.create({
|
|
|
56
67
|
export default function Panel({
|
|
57
68
|
children,
|
|
58
69
|
backgroundColor,
|
|
70
|
+
contrastTextColor,
|
|
59
71
|
shadow = false,
|
|
60
72
|
onPress,
|
|
61
73
|
style,
|
|
62
74
|
contentStyle,
|
|
63
75
|
}) {
|
|
76
|
+
const resolvedContrast = contrastTextColor
|
|
77
|
+
?? (backgroundColor ? contrastColor(backgroundColor) : null);
|
|
78
|
+
|
|
64
79
|
const surfaceStyle = [
|
|
65
80
|
defaultStyle.surface,
|
|
66
81
|
backgroundColor ? { backgroundColor } : null,
|
|
67
82
|
];
|
|
68
83
|
|
|
69
84
|
const content = (
|
|
70
|
-
<
|
|
71
|
-
{
|
|
72
|
-
|
|
85
|
+
<PanelContrastContext.Provider value={resolvedContrast}>
|
|
86
|
+
<View style={[defaultStyle.content, contentStyle]}>
|
|
87
|
+
{children}
|
|
88
|
+
</View>
|
|
89
|
+
</PanelContrastContext.Provider>
|
|
73
90
|
);
|
|
74
91
|
|
|
75
92
|
const inner = onPress ? (
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
|
|
4
|
+
|
|
5
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
6
|
+
import Panel from './panel';
|
|
7
|
+
|
|
8
|
+
const defaultStyle = StyleSheet.create({
|
|
9
|
+
container: {
|
|
10
|
+
gap: 16,
|
|
11
|
+
},
|
|
12
|
+
dayHeading: {
|
|
13
|
+
fontWeight: '700',
|
|
14
|
+
marginBottom: 8,
|
|
15
|
+
},
|
|
16
|
+
event: {
|
|
17
|
+
gap: 4,
|
|
18
|
+
marginBottom: 12,
|
|
19
|
+
},
|
|
20
|
+
eventTitle: {
|
|
21
|
+
fontWeight: '700',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Schedule
|
|
27
|
+
*
|
|
28
|
+
* Renders a multi-day event schedule — one Panel per day. Panels
|
|
29
|
+
* cycle through `washColors` in order (e.g. [primarySoft,
|
|
30
|
+
* secondarySoft] gives primary/secondary/primary/secondary...), so
|
|
31
|
+
* consecutive days are visually distinct without any per-day
|
|
32
|
+
* configuration needed.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} props
|
|
35
|
+
* @param {object} props.schedule Shape: { [dateKey]: Array<{ event: string, description?: string }> }
|
|
36
|
+
* @param {string[]} [props.washColors] Colors cycled through per day, in order. Omit for no alternation (every Panel uses Panel's own default).
|
|
37
|
+
* @param {(dateKey: string) => string} [props.formatDate] Defaults to showing the raw key as-is.
|
|
38
|
+
* @param {React.ComponentType} [props.TextComponent] Defaults to Text.
|
|
39
|
+
* @param {Function} [props.onPressEvent] (event, dateKey) => void
|
|
40
|
+
* @param {boolean} [props.shadow] Passed through to every Panel.
|
|
41
|
+
* @param {object} [props.containerStyle]
|
|
42
|
+
* @param {object} [props.dayHeadingStyle]
|
|
43
|
+
* @param {object} [props.eventTitleStyle]
|
|
44
|
+
* @param {object} [props.eventDescriptionStyle]
|
|
45
|
+
*
|
|
46
|
+
* @returns {JSX.Element}
|
|
47
|
+
*/
|
|
48
|
+
export default function Schedule({
|
|
49
|
+
schedule,
|
|
50
|
+
washColors = [],
|
|
51
|
+
formatDate = (dateKey) => dateKey,
|
|
52
|
+
TextComponent = Text,
|
|
53
|
+
onPressEvent,
|
|
54
|
+
shadow,
|
|
55
|
+
containerStyle,
|
|
56
|
+
dayHeadingStyle,
|
|
57
|
+
eventTitleStyle,
|
|
58
|
+
eventDescriptionStyle,
|
|
59
|
+
}) {
|
|
60
|
+
const days = Object.keys(schedule || {});
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<View style={[defaultStyle.container, containerStyle]}>
|
|
64
|
+
{days.map((dateKey, dayIndex) => {
|
|
65
|
+
const events = schedule[dateKey] || [];
|
|
66
|
+
const backgroundColor = washColors.length
|
|
67
|
+
? washColors[dayIndex % washColors.length]
|
|
68
|
+
: undefined;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Panel key={dateKey} backgroundColor={backgroundColor} shadow={shadow}>
|
|
72
|
+
<TextComponent style={[defaultStyle.dayHeading, dayHeadingStyle]}>
|
|
73
|
+
{formatDate(dateKey)}
|
|
74
|
+
</TextComponent>
|
|
75
|
+
|
|
76
|
+
{events.map((item, eventIndex) => (
|
|
77
|
+
<View key={eventIndex} style={defaultStyle.event}>
|
|
78
|
+
<TextComponent
|
|
79
|
+
style={[defaultStyle.eventTitle, eventTitleStyle]}
|
|
80
|
+
onPress={onPressEvent ? () => onPressEvent(item, dateKey) : undefined}
|
|
81
|
+
>
|
|
82
|
+
{item.event}
|
|
83
|
+
</TextComponent>
|
|
84
|
+
|
|
85
|
+
{item.description ? (
|
|
86
|
+
<TextComponent style={eventDescriptionStyle}>
|
|
87
|
+
{item.description}
|
|
88
|
+
</TextComponent>
|
|
89
|
+
) : null}
|
|
90
|
+
</View>
|
|
91
|
+
))}
|
|
92
|
+
</Panel>
|
|
93
|
+
);
|
|
94
|
+
})}
|
|
95
|
+
</View>
|
|
96
|
+
);
|
|
97
|
+
}
|
package/lib/components.js
CHANGED
|
@@ -12,6 +12,7 @@ export { default as GradientTile } from './components/gradient-tile.jsx';
|
|
|
12
12
|
export { default as QRCodeForm } from './components/qr-code-form.jsx';
|
|
13
13
|
export { default as ScaledLogo } from './components/scaled-logo.jsx';
|
|
14
14
|
export { default as Panel } from './components/panel.jsx';
|
|
15
|
+
export { default as GradientPanel } from './components/panel-gradient.jsx';
|
|
15
16
|
|
|
16
17
|
export { openUrl, openApp, openExternal } from './utils/launch.js';
|
|
17
18
|
|