@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.
@@ -0,0 +1,262 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ import { useEffect, useRef, useState } from 'react';
6
+
7
+ function getByPath(obj, path) {
8
+ if (!path) return obj;
9
+ return path.split('.').reduce((acc, key) => (acc == null ? acc : acc[key]), obj);
10
+ }
11
+
12
+ const DEFAULT_REGULAR_MS = 15 * 60 * 1000;
13
+ const cache = new Map();
14
+
15
+ function getCacheKey(uri, bearerToken) {
16
+ return `${uri}::${bearerToken || ''}`;
17
+ }
18
+
19
+ function isFresh(entry, regularMs) {
20
+ if (!entry) return false;
21
+ return (Date.now() - entry.fetchedAt) < regularMs;
22
+ }
23
+
24
+ /**
25
+ * fetchJson
26
+ *
27
+ * Generic cached JSON fetch with bearer-token support. This is the
28
+ * same logic that used to live as news-specific `fetchNewsJson` —
29
+ * it was always fully generic underneath, just named after its
30
+ * first caller. Shares one in-memory cache across every caller,
31
+ * keyed by uri+bearerToken — two different widgets requesting the
32
+ * same uri within the TTL window share a single cached response
33
+ * rather than both fetching.
34
+ *
35
+ * @param {object} args
36
+ * @param {string} args.uri
37
+ * @param {string} [args.bearerToken]
38
+ * @param {boolean} [args.force] Bypass the cache for this call.
39
+ * @param {number} [args.regularMs] Defaults to 15 minutes.
40
+ *
41
+ * @returns {Promise<any>}
42
+ */
43
+ export async function fetchJson({ uri, bearerToken, force = false, regularMs = DEFAULT_REGULAR_MS }) {
44
+ const key = getCacheKey(uri, bearerToken);
45
+ const entry = cache.get(key);
46
+
47
+ if (!force && isFresh(entry, regularMs)) {
48
+ return entry.data;
49
+ }
50
+
51
+ const headers = {};
52
+ if (bearerToken) {
53
+ headers.Authorization = `Bearer ${bearerToken}`;
54
+ }
55
+
56
+ const res = await fetch(uri, { method: 'GET', headers });
57
+
58
+ if (!res.ok) {
59
+ throw new Error(`Request failed (${res.status})`);
60
+ }
61
+
62
+ const data = await res.json();
63
+ cache.set(key, { data, fetchedAt: Date.now() });
64
+ return data;
65
+ }
66
+
67
+ /**
68
+ * clearJsonCache
69
+ *
70
+ * @param {object} args
71
+ * @param {string} args.uri
72
+ * @param {string} [args.bearerToken]
73
+ */
74
+ export function clearJsonCache({ uri, bearerToken }) {
75
+ cache.delete(getCacheKey(uri, bearerToken));
76
+ }
77
+
78
+ async function defaultFetcher({ uri, bearerToken, force, regularMs }) {
79
+ return fetchJson({ uri, bearerToken, force, regularMs });
80
+ }
81
+
82
+ // ── Manual refresh cooldown ──────────────────────────────────
83
+ // A second, separate tracker from the data cache above — this
84
+ // governs "when is the refresh BUTTON allowed to be pressed again",
85
+ // not "is the data stale". Shared per uri+bearerToken (same key
86
+ // scheme as the data cache), so multiple widgets pointed at the
87
+ // same endpoint share one cooldown rather than each tracking their
88
+ // own independently.
89
+
90
+ const MANUAL_LOCK_MS = 15 * 60 * 1000;
91
+ const ERROR_REFRESH_MS = 5 * 60 * 1000;
92
+ const cooldowns = new Map(); // key -> expiresAt timestamp
93
+
94
+ function getCooldownRemaining(key) {
95
+ const expiresAt = cooldowns.get(key);
96
+ if (!expiresAt) return 0;
97
+ return Math.max(0, expiresAt - Date.now());
98
+ }
99
+
100
+ function setCooldown(key, durationMs) {
101
+ cooldowns.set(key, Date.now() + durationMs);
102
+ }
103
+
104
+ function clearCooldown(key) {
105
+ cooldowns.delete(key);
106
+ }
107
+
108
+ /**
109
+ * useJsonApiData
110
+ *
111
+ * Generic fetch + poll + reshape — the data-fetching half of what
112
+ * used to live inline inside NewsWidget, now reusable by any
113
+ * RemoteDataWidget-style component regardless of what it's actually
114
+ * fetching.
115
+ *
116
+ * Always returns the same shape:
117
+ * { items, loading, error, refresh, manualRefresh, canManualRefresh }
118
+ *
119
+ * `refresh` is unchanged from before — exposed so a component can
120
+ * wire it into useImperativeHandle for an external
121
+ * ref.current.refresh() call (e.g. triggered by a push
122
+ * notification). It always works and is NOT subject to the
123
+ * cooldown below — a real notification shouldn't get silently
124
+ * dropped because someone tapped a refresh button five minutes ago.
125
+ *
126
+ * `manualRefresh` is new — meant for a UI refresh button. It
127
+ * respects a per-endpoint cooldown:
128
+ * - succeeds → button locked for 15 minutes
129
+ * - errors → button locked for only 5 minutes (fail faster,
130
+ * recover faster)
131
+ * - whenever the automatic polling interval fires (success OR
132
+ * error), the cooldown is cleared immediately — the data's
133
+ * fresh either way, no reason to keep the button blocked.
134
+ * `canManualRefresh` reflects whether the button should currently
135
+ * be enabled.
136
+ *
137
+ * Caching behaviour (the underlying data, separate from the above):
138
+ * the initial load may reuse a fresh cached response. Interval
139
+ * ticks and any forced refresh always force a real fetch.
140
+ *
141
+ * @param {object} options
142
+ * @param {string} options.uri
143
+ * @param {string} [options.bearerToken]
144
+ * @param {string} [options.jpath] Dot-path to the array within the response (e.g. 'data.results'). Omit if the response IS the array.
145
+ * @param {(rawItem: any) => any} [options.mapItem] Reshapes each raw item into whatever the consuming widget expects. Defaults to identity (no reshaping).
146
+ * @param {number} [options.refreshIntervalMs] Polling interval. Defaults to 0 (no polling — fetch once on mount).
147
+ * @param {number} [options.regularMs] Cache freshness window, passed to the default fetcher. Defaults to 15 minutes.
148
+ * @param {number} [options.manualLockMs] Defaults to 15 minutes.
149
+ * @param {number} [options.errorRefreshMs] Defaults to 5 minutes.
150
+ * @param {(args: { uri: string, bearerToken?: string, force: boolean }) => Promise<any>} [options.fetcher] Defaults to the cached fetchJson above.
151
+ *
152
+ * @returns {{ items: any[], loading: boolean, error: string|null, refresh: () => Promise<void>, manualRefresh: () => Promise<void>, canManualRefresh: boolean }}
153
+ */
154
+ export function useJsonApiData({
155
+ uri,
156
+ bearerToken,
157
+ jpath,
158
+ mapItem = (item) => item,
159
+ refreshIntervalMs = 0,
160
+ regularMs,
161
+ fetcher = defaultFetcher,
162
+ manualLockMs = MANUAL_LOCK_MS,
163
+ errorRefreshMs = ERROR_REFRESH_MS,
164
+ }) {
165
+ const [items, setItems] = useState([]);
166
+ const [loading, setLoading] = useState(true);
167
+ const [error, setError] = useState(null);
168
+ const intervalRef = useRef(null);
169
+ const cooldownTimeoutRef = useRef(null);
170
+
171
+ const cooldownKey = getCacheKey(uri, bearerToken);
172
+
173
+ const [canManualRefresh, setCanManualRefresh] = useState(
174
+ () => getCooldownRemaining(cooldownKey) <= 0
175
+ );
176
+
177
+ function applyCooldown(durationMs) {
178
+ setCooldown(cooldownKey, durationMs);
179
+ setCanManualRefresh(false);
180
+
181
+ if (cooldownTimeoutRef.current) {
182
+ clearTimeout(cooldownTimeoutRef.current);
183
+ }
184
+ cooldownTimeoutRef.current = setTimeout(() => {
185
+ setCanManualRefresh(true);
186
+ }, durationMs);
187
+ }
188
+
189
+ function releaseCooldown() {
190
+ clearCooldown(cooldownKey);
191
+ if (cooldownTimeoutRef.current) {
192
+ clearTimeout(cooldownTimeoutRef.current);
193
+ cooldownTimeoutRef.current = null;
194
+ }
195
+ setCanManualRefresh(true);
196
+ }
197
+
198
+ async function load(force = false, source = 'initial') {
199
+ setError(null);
200
+ let succeeded = true;
201
+
202
+ try {
203
+ const raw = await fetcher({ uri, bearerToken, force, regularMs });
204
+ const resolved = getByPath(raw, jpath);
205
+
206
+ if (!Array.isArray(resolved)) {
207
+ console.warn(
208
+ jpath
209
+ ? `useJsonApiData: jpath "${jpath}" did not resolve to an array (got ${typeof resolved}). Check that this path actually points at the array in your API's response. Falling back to an empty list.`
210
+ : `useJsonApiData: expected the response to be an array, but got ${typeof resolved}. If your API wraps the array in an object (e.g. { items: [...] }), pass jpath to point at it (e.g. jpath="items"). Falling back to an empty list.`
211
+ );
212
+ }
213
+
214
+ setItems(Array.isArray(resolved) ? resolved.map(mapItem) : []);
215
+ }
216
+ catch (e) {
217
+ succeeded = false;
218
+ setError(e.message || 'Unable to load data.');
219
+ }
220
+
221
+ setLoading(false);
222
+
223
+ if (source === 'manual') {
224
+ applyCooldown(succeeded ? manualLockMs : errorRefreshMs);
225
+ }
226
+ else if (source === 'system') {
227
+ releaseCooldown();
228
+ }
229
+ }
230
+
231
+ useEffect(() => {
232
+ setLoading(true);
233
+ setCanManualRefresh(getCooldownRemaining(cooldownKey) <= 0);
234
+ load(false, 'initial');
235
+
236
+ if (refreshIntervalMs > 0) {
237
+ intervalRef.current = setInterval(() => load(true, 'system'), refreshIntervalMs);
238
+ return () => clearInterval(intervalRef.current);
239
+ }
240
+ // eslint-disable-next-line react-hooks/exhaustive-deps
241
+ }, [uri, bearerToken, jpath, refreshIntervalMs]);
242
+
243
+ useEffect(() => {
244
+ return () => {
245
+ if (cooldownTimeoutRef.current) {
246
+ clearTimeout(cooldownTimeoutRef.current);
247
+ }
248
+ };
249
+ }, []);
250
+
251
+ return {
252
+ items,
253
+ loading,
254
+ error,
255
+ refresh: () => load(true, 'external'),
256
+ manualRefresh: () => {
257
+ if (getCooldownRemaining(cooldownKey) > 0) return;
258
+ load(true, 'manual');
259
+ },
260
+ canManualRefresh,
261
+ };
262
+ }
@@ -0,0 +1,20 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ /**
4
+ * PanelContrastContext
5
+ *
6
+ * Provided by Panel — the contrast color automatically derived from
7
+ * its backgroundColor (or a manual override, if Panel was given
8
+ * one). Defaults to null, so anything reading this outside a Panel
9
+ * just gets "no opinion," not a wrong color.
10
+ */
11
+ export const PanelContrastContext = createContext(null);
12
+
13
+ /**
14
+ * usePanelContrast
15
+ *
16
+ * @returns {string|null} The enclosing Panel's contrast color, or null if there isn't one.
17
+ */
18
+ export function usePanelContrast() {
19
+ return useContext(PanelContrastContext);
20
+ }
@@ -0,0 +1,32 @@
1
+ // SPDX-FileCopyrightText: 2026 Wesley Schwengle <wesleys@opperschaap.net>
2
+ //
3
+ // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
+
5
+ /**
6
+ * useStaticData
7
+ *
8
+ * The "I already have this data" counterpart to useJsonApiData —
9
+ * same returned shape, so DataListWidget (or anything else
10
+ * consuming either hook) never needs to know which one it's
11
+ * actually talking to. No fetch, no polling, no cache, no manual
12
+ * refresh — there's nothing to refresh; the data only changes if
13
+ * the caller passes different `data` on a re-render.
14
+ *
15
+ * @param {object} options
16
+ * @param {any[]} options.data
17
+ * @param {(rawItem: any) => any} [options.mapItem] Reshapes each raw item into whatever the consuming widget expects. Defaults to identity (no reshaping).
18
+ *
19
+ * @returns {{ items: any[], loading: false, error: null, refresh: () => void, manualRefresh: () => void, canManualRefresh: false }}
20
+ */
21
+ export function useStaticData({ data, mapItem = (item) => item }) {
22
+ const items = (data || []).map(mapItem);
23
+
24
+ return {
25
+ items,
26
+ loading: false,
27
+ error: null,
28
+ refresh: () => {},
29
+ manualRefresh: () => {},
30
+ canManualRefresh: false,
31
+ };
32
+ }
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later WITH LicenseRef-OPNDEV-exceptions
4
4
 
5
- const VERSION = "0.0.15";
5
+ const VERSION = "0.0.17";
6
6
 
7
7
  // TODO: @opndev/util?
8
8
  export { formatPrice } from './utils/format-price.js';
@@ -18,19 +18,15 @@
18
18
  export function mixHexColors(a, b, amount = 0.5) {
19
19
  const ah = a.replace('#', '');
20
20
  const bh = b.replace('#', '');
21
-
22
21
  const ar = parseInt(ah.slice(0, 2), 16);
23
22
  const ag = parseInt(ah.slice(2, 4), 16);
24
23
  const ab = parseInt(ah.slice(4, 6), 16);
25
-
26
24
  const br = parseInt(bh.slice(0, 2), 16);
27
25
  const bg = parseInt(bh.slice(2, 4), 16);
28
26
  const bb = parseInt(bh.slice(4, 6), 16);
29
-
30
27
  const r = Math.round(ar + (br - ar) * amount);
31
28
  const g = Math.round(ag + (bg - ag) * amount);
32
29
  const b2 = Math.round(ab + (bb - ab) * amount);
33
-
34
30
  return (
35
31
  '#' +
36
32
  r.toString(16).padStart(2, '0') +
@@ -40,27 +36,116 @@ export function mixHexColors(a, b, amount = 0.5) {
40
36
  }
41
37
 
42
38
  /**
43
- * Return black or white depending on which has better contrast.
39
+ * Convert a hex color to HSL.
40
+ *
41
+ * @param {string} hex
42
+ * Color as "#rrggbb".
43
+ *
44
+ * @returns {{h: number, s: number, l: number}}
45
+ * Each in the 0-1 range (h is fraction of 360°, not degrees).
46
+ */
47
+ function hexToHsl(hex) {
48
+ const value = hex.replace('#', '');
49
+ const r = parseInt(value.slice(0, 2), 16) / 255;
50
+ const g = parseInt(value.slice(2, 4), 16) / 255;
51
+ const b = parseInt(value.slice(4, 6), 16) / 255;
52
+
53
+ const max = Math.max(r, g, b);
54
+ const min = Math.min(r, g, b);
55
+ const l = (max + min) / 2;
56
+
57
+ let h = 0;
58
+ let s = 0;
59
+
60
+ if (max !== min) {
61
+ const d = max - min;
62
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
63
+
64
+ switch (max) {
65
+ case r:
66
+ h = (g - b) / d + (g < b ? 6 : 0);
67
+ break;
68
+ case g:
69
+ h = (b - r) / d + 2;
70
+ break;
71
+ default:
72
+ h = (r - g) / d + 4;
73
+ break;
74
+ }
75
+ h /= 6;
76
+ }
77
+
78
+ return { h, s, l };
79
+ }
80
+
81
+ /**
82
+ * Convert HSL back to a hex color.
83
+ *
84
+ * @param {{h: number, s: number, l: number}} hsl
85
+ * Each in the 0-1 range (h is fraction of 360°, not degrees).
86
+ *
87
+ * @returns {string}
88
+ * Color as "#rrggbb".
89
+ */
90
+ function hslToHex({ h, s, l }) {
91
+ function hueToRgb(p, q, t) {
92
+ if (t < 0) t += 1;
93
+ if (t > 1) t -= 1;
94
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
95
+ if (t < 1 / 2) return q;
96
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
97
+ return p;
98
+ }
99
+
100
+ let r;
101
+ let g;
102
+ let b;
103
+
104
+ if (s === 0) {
105
+ r = g = b = l;
106
+ }
107
+ else {
108
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
109
+ const p = 2 * l - q;
110
+ r = hueToRgb(p, q, h + 1 / 3);
111
+ g = hueToRgb(p, q, h);
112
+ b = hueToRgb(p, q, h - 1 / 3);
113
+ }
114
+
115
+ const toHex = (c) => Math.round(c * 255).toString(16).padStart(2, '0');
116
+ return '#' + toHex(r) + toHex(g) + toHex(b);
117
+ }
118
+
119
+ /**
120
+ * Return a contrasting color in the SAME hue/saturation family as
121
+ * the input, just pushed to a very different lightness — the same
122
+ * technique CSS color-ramp design systems use (Tailwind/Radix-style
123
+ * 50-950 scales): same color family, different shade, rather than
124
+ * jumping to flat black/white. A light, desaturated input still
125
+ * gets a colored (just very dark) result; a fully neutral input
126
+ * (pure white/black/gray, s=0) naturally falls back to plain
127
+ * gray at the target lightness, since there's no hue to preserve.
44
128
  *
45
129
  * @param {string} hex
46
130
  * Background color as "#rrggbb".
47
131
  *
48
132
  * @returns {string}
49
- * "#000000" or "#ffffff".
133
+ * A same-hue, contrasting-lightness hex color.
50
134
  */
51
135
  export function contrastColor(hex) {
52
136
  const value = hex.replace('#', '');
53
-
54
137
  const r = parseInt(value.slice(0, 2), 16) / 255;
55
138
  const g = parseInt(value.slice(2, 4), 16) / 255;
56
139
  const b = parseInt(value.slice(4, 6), 16) / 255;
57
-
58
140
  const luminance =
59
141
  0.2126 * toLinear(r) +
60
142
  0.7152 * toLinear(g) +
61
143
  0.0722 * toLinear(b);
62
144
 
63
- return luminance > 0.179 ? '#000000' : '#ffffff';
145
+ const { h, s } = hexToHsl(hex);
146
+ const targetLightness = luminance > 0.179 ? 0.15 : 0.92;
147
+
148
+ return hslToHex({ h, s, l: targetLightness });
64
149
  }
65
150
 
66
151
  /**
@@ -76,7 +161,5 @@ export function toLinear(channel) {
76
161
  if (channel <= 0.03928) {
77
162
  return channel / 12.92;
78
163
  }
79
-
80
164
  return ((channel + 0.055) / 1.055) ** 2.4;
81
165
  }
82
-
@@ -0,0 +1,202 @@
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 {
6
+ ActivityIndicator,
7
+ Pressable,
8
+ StyleSheet,
9
+ Text,
10
+ View,
11
+ } from 'react-native';
12
+ import MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
13
+ import { usePanelContrast } from '../hooks/use-panel-contrast';
14
+
15
+ const defaultStyle = StyleSheet.create({
16
+ container: {
17
+ gap: 8,
18
+ },
19
+ titleRow: {
20
+ flexDirection: 'row',
21
+ alignItems: 'center',
22
+ },
23
+ title: {
24
+ fontWeight: '700',
25
+ },
26
+ refreshButton: {
27
+ padding: 4,
28
+ },
29
+ divider: {
30
+ height: 3,
31
+ backgroundColor: 'rgba(0,0,0,0.15)',
32
+ marginTop: 4,
33
+ marginBottom: 4,
34
+ },
35
+ item: {
36
+ flexDirection: 'row',
37
+ gap: 8,
38
+ },
39
+ bullet: {
40
+ opacity: 0.6,
41
+ },
42
+ itemBody: {
43
+ flex: 1,
44
+ gap: 2,
45
+ },
46
+ });
47
+
48
+ /**
49
+ * DataListWidget
50
+ *
51
+ * The actual "title + divider + bullet list (+ optional second
52
+ * line) + refresh button" rendering — shared by RemoteDataWidget
53
+ * and StaticDataWidget, which differ only in WHERE `items` comes
54
+ * from (a polled API vs. data you already have). Neither of those
55
+ * two components renders anything of its own; both just call a
56
+ * data hook and hand the result to this component.
57
+ *
58
+ * Not meant to be used directly in app code — it has no data
59
+ * source of its own. Use RemoteDataWidget or StaticDataWidget.
60
+ *
61
+ * @param {object} props
62
+ * @param {Array<{id: any, title: string, description?: string}>} props.items
63
+ * @param {boolean} props.loading
64
+ * @param {string|null} props.error
65
+ * @param {Function} [props.manualRefresh]
66
+ * @param {boolean} [props.canManualRefresh]
67
+ * @param {string} [props.title] Defaults to null (no heading shown).
68
+ * @param {boolean} [props.showDivider] Defaults to true. Ignored if title is falsy.
69
+ * @param {object} [props.dividerStyle]
70
+ * @param {boolean} [props.showRefreshButton] Defaults to true.
71
+ * @param {boolean} [props.showBullet] Defaults to true.
72
+ * @param {string} [props.refreshIconColor] Defaults to '#000'.
73
+ * @param {string} [props.refreshIconDisabledColor] Color while on cooldown. Defaults to 'rgba(0,0,0,0.3)'.
74
+ * @param {React.ComponentType} [props.TextComponent] Defaults to Text.
75
+ * @param {Function} [props.onPressItem]
76
+ * @param {string} [props.emptyLabel] Defaults to 'No items.'
77
+ * @param {string} [props.errorLabel] Defaults to 'Unable to load data.'
78
+ * @param {object} [props.containerStyle]
79
+ * @param {object} [props.titleStyle]
80
+ * @param {object} [props.itemStyle]
81
+ * @param {object} [props.itemTextStyle]
82
+ * @param {object} [props.descriptionStyle]
83
+ * @param {object} [props.errorTextStyle]
84
+ *
85
+ * @returns {JSX.Element}
86
+ */
87
+ export default function DataListWidget({
88
+ items,
89
+ loading,
90
+ error,
91
+ manualRefresh,
92
+ canManualRefresh,
93
+
94
+ title = null,
95
+ showDivider = true,
96
+ dividerStyle,
97
+ showRefreshButton = true,
98
+ showBullet = true,
99
+ refreshIconColor = '#000',
100
+ refreshIconDisabledColor = 'rgba(0,0,0,0.3)',
101
+ TextComponent = Text,
102
+ onPressItem,
103
+
104
+ emptyLabel = 'No items.',
105
+ errorLabel = 'Unable to load data.',
106
+
107
+ containerStyle,
108
+ titleStyle,
109
+ itemStyle,
110
+ itemTextStyle,
111
+ descriptionStyle,
112
+ errorTextStyle,
113
+ }) {
114
+ const showTitleRow = title || showRefreshButton;
115
+ const panelContrast = usePanelContrast();
116
+ const contrastTextStyle = panelContrast ? { color: panelContrast } : null;
117
+
118
+ if (__DEV__ && !loading && !error && items.length) {
119
+ items.forEach((item, index) => {
120
+ if (item == null || item.id == null || item.title == null) {
121
+ throw new Error(
122
+ `DataListWidget: item at index ${index} is missing "id" or "title". ` +
123
+ `Check that mapItem returns { id, title, description? } — got: ${JSON.stringify(item)}`
124
+ );
125
+ }
126
+ });
127
+ }
128
+
129
+ return (
130
+ <View
131
+ style={[
132
+ defaultStyle.container,
133
+ containerStyle,
134
+ ]}
135
+ >
136
+ {showTitleRow ? (
137
+ <View
138
+ style={[
139
+ defaultStyle.titleRow,
140
+ { justifyContent: title ? 'space-between' : 'flex-end' },
141
+ ]}
142
+ >
143
+ {title ? (
144
+ <TextComponent style={[defaultStyle.title, contrastTextStyle, titleStyle]}>
145
+ {title}
146
+ </TextComponent>
147
+ ) : null}
148
+
149
+ {showRefreshButton ? (
150
+ <Pressable
151
+ onPress={manualRefresh}
152
+ disabled={!canManualRefresh}
153
+ style={defaultStyle.refreshButton}
154
+ >
155
+ <MaterialCommunityIcons
156
+ name="refresh"
157
+ size={18}
158
+ color={canManualRefresh ? refreshIconColor : refreshIconDisabledColor}
159
+ />
160
+ </Pressable>
161
+ ) : null}
162
+ </View>
163
+ ) : null}
164
+
165
+ {title && showDivider ? (
166
+ <View style={[defaultStyle.divider, dividerStyle]} />
167
+ ) : null}
168
+
169
+ {loading ? <ActivityIndicator /> : null}
170
+
171
+ {!loading && error ? (
172
+ <TextComponent style={errorTextStyle}>{error || errorLabel}</TextComponent>
173
+ ) : null}
174
+
175
+ {!loading && !error && !items.length ? (
176
+ <TextComponent style={errorTextStyle}>{emptyLabel}</TextComponent>
177
+ ) : null}
178
+
179
+ {!loading && !error && items.map((item) => (
180
+ <Pressable
181
+ key={item.id}
182
+ style={[defaultStyle.item, itemStyle]}
183
+ onPress={() => onPressItem?.(item)}
184
+ >
185
+ {showBullet ? (
186
+ <TextComponent style={[defaultStyle.bullet, contrastTextStyle, itemTextStyle]}>{'\u2022'}</TextComponent>
187
+ ) : null}
188
+ <View style={defaultStyle.itemBody}>
189
+ <TextComponent style={[contrastTextStyle, itemTextStyle]}>
190
+ {item.title}
191
+ </TextComponent>
192
+ {item.description ? (
193
+ <TextComponent style={[contrastTextStyle, descriptionStyle]}>
194
+ {item.description}
195
+ </TextComponent>
196
+ ) : null}
197
+ </View>
198
+ </Pressable>
199
+ ))}
200
+ </View>
201
+ );
202
+ }