@forthtilliath/react-native-kit 0.12.0 → 0.14.0

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.
Files changed (87) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +603 -604
  3. package/dist/components/index.d.ts +0 -1
  4. package/dist/components/index.d.ts.map +1 -1
  5. package/dist/components/index.js +0 -1
  6. package/dist/components/list/ColorDot.d.ts +57 -0
  7. package/dist/components/list/ColorDot.d.ts.map +1 -0
  8. package/dist/components/list/ColorDot.js +48 -0
  9. package/dist/components/list/UndoToast.d.ts +100 -0
  10. package/dist/components/list/UndoToast.d.ts.map +1 -0
  11. package/dist/components/list/UndoToast.js +104 -0
  12. package/dist/components/list/index.d.ts +2 -0
  13. package/dist/components/list/index.d.ts.map +1 -1
  14. package/dist/components/list/index.js +2 -0
  15. package/dist/components/picker/PickerModal.d.ts +2 -29
  16. package/dist/components/picker/PickerModal.d.ts.map +1 -1
  17. package/dist/components/picker/PickerModal.js +10 -112
  18. package/dist/components/picker/PickerModal.styles.d.ts +32 -0
  19. package/dist/components/picker/PickerModal.styles.d.ts.map +1 -0
  20. package/dist/components/picker/PickerModal.styles.js +82 -0
  21. package/dist/components/picker/PickerModalRow.d.ts +17 -0
  22. package/dist/components/picker/PickerModalRow.d.ts.map +1 -0
  23. package/dist/components/picker/PickerModalRow.js +11 -0
  24. package/dist/components/picker/usePickerModalSections.d.ts +7 -0
  25. package/dist/components/picker/usePickerModalSections.d.ts.map +1 -0
  26. package/dist/components/picker/usePickerModalSections.js +28 -0
  27. package/dist/components/settings/UpdateSettingsScreen.d.ts +2 -46
  28. package/dist/components/settings/UpdateSettingsScreen.d.ts.map +1 -1
  29. package/dist/components/settings/UpdateSettingsScreen.js +4 -78
  30. package/dist/components/settings/UpdateSettingsScreen.styles.d.ts +49 -0
  31. package/dist/components/settings/UpdateSettingsScreen.styles.d.ts.map +1 -0
  32. package/dist/components/settings/UpdateSettingsScreen.styles.js +76 -0
  33. package/dist/components/settings/UpdateSettingsScreenHistory.d.ts +20 -0
  34. package/dist/components/settings/UpdateSettingsScreenHistory.d.ts.map +1 -0
  35. package/dist/components/settings/UpdateSettingsScreenHistory.js +10 -0
  36. package/dist/hooks/index.d.ts +0 -1
  37. package/dist/hooks/index.d.ts.map +1 -1
  38. package/dist/hooks/index.js +0 -1
  39. package/dist/index.d.ts +10 -11
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +12 -11
  42. package/dist/utils/helpers/index.d.ts +1 -6
  43. package/dist/utils/helpers/index.d.ts.map +1 -1
  44. package/dist/utils/helpers/index.js +1 -6
  45. package/dist/utils/index.d.ts +0 -1
  46. package/dist/utils/index.d.ts.map +1 -1
  47. package/dist/utils/index.js +0 -1
  48. package/package.json +4 -3
  49. package/dist/components/update/ChangelogNotes.d.ts +0 -22
  50. package/dist/components/update/ChangelogNotes.d.ts.map +0 -1
  51. package/dist/components/update/ChangelogNotes.js +0 -51
  52. package/dist/components/update/UpdateAvailableBanner.d.ts +0 -29
  53. package/dist/components/update/UpdateAvailableBanner.d.ts.map +0 -1
  54. package/dist/components/update/UpdateAvailableBanner.js +0 -63
  55. package/dist/components/update/index.d.ts +0 -3
  56. package/dist/components/update/index.d.ts.map +0 -1
  57. package/dist/components/update/index.js +0 -2
  58. package/dist/hooks/useUpdateCheck.d.ts +0 -35
  59. package/dist/hooks/useUpdateCheck.d.ts.map +0 -1
  60. package/dist/hooks/useUpdateCheck.js +0 -47
  61. package/dist/utils/format/escapeCsvField.d.ts +0 -2
  62. package/dist/utils/format/escapeCsvField.d.ts.map +0 -1
  63. package/dist/utils/format/escapeCsvField.js +0 -8
  64. package/dist/utils/format/escapeHtml.d.ts +0 -2
  65. package/dist/utils/format/escapeHtml.d.ts.map +0 -1
  66. package/dist/utils/format/escapeHtml.js +0 -9
  67. package/dist/utils/format/formatCsvNumber.d.ts +0 -2
  68. package/dist/utils/format/formatCsvNumber.d.ts.map +0 -1
  69. package/dist/utils/format/formatCsvNumber.js +0 -6
  70. package/dist/utils/format/index.d.ts +0 -4
  71. package/dist/utils/format/index.d.ts.map +0 -1
  72. package/dist/utils/format/index.js +0 -3
  73. package/dist/utils/helpers/getMostRecentIds.d.ts +0 -6
  74. package/dist/utils/helpers/getMostRecentIds.d.ts.map +0 -1
  75. package/dist/utils/helpers/getMostRecentIds.js +0 -19
  76. package/dist/utils/helpers/getPeriodStartMs.d.ts +0 -3
  77. package/dist/utils/helpers/getPeriodStartMs.d.ts.map +0 -1
  78. package/dist/utils/helpers/getPeriodStartMs.js +0 -14
  79. package/dist/utils/helpers/nextInCycle.d.ts +0 -2
  80. package/dist/utils/helpers/nextInCycle.d.ts.map +0 -1
  81. package/dist/utils/helpers/nextInCycle.js +0 -9
  82. package/dist/utils/helpers/normalizeForSearch.d.ts +0 -2
  83. package/dist/utils/helpers/normalizeForSearch.d.ts.map +0 -1
  84. package/dist/utils/helpers/normalizeForSearch.js +0 -15
  85. package/dist/utils/helpers/rankByNameMatch.d.ts +0 -2
  86. package/dist/utils/helpers/rankByNameMatch.d.ts.map +0 -1
  87. package/dist/utils/helpers/rankByNameMatch.js +0 -33
package/README.md CHANGED
@@ -1,604 +1,603 @@
1
- # @forthtilliath/react-native-kit
2
-
3
- Small React Native building blocks — components, hooks, and framework-agnostic utils — with no opinion on your app's theme, distributed one file per export (grouped under `components/{picker,theme,update,list,settings}/`, `hooks/`, `utils/{format,helpers}/`) so consumers only pull in what they use.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @forthtilliath/react-native-kit react react-native
9
- ```
10
-
11
- Or, from within this monorepo, as a workspace dependency:
12
-
13
- ```json
14
- {
15
- "dependencies": {
16
- "@forthtilliath/react-native-kit": "workspace:*"
17
- }
18
- }
19
- ```
20
-
21
- `react` and `react-native` are peer dependencies — install them in the consuming app if not already present. `@expo/vector-icons`, `expo-image-picker`, `expo-speech-recognition`, and `react-native-gesture-handler` are also peer dependencies, needed only if you import the components that use them (`Thumbnail`/`PickerModal`/`SwipeableRow`, `PhotoPicker`, `VoiceSearchButton`/`PickerModal`, `SwipeableRow`, respectively).
22
-
23
- ## Usage
24
-
25
- Each component is its own module — import the file you need directly:
26
-
27
- ```ts
28
- import { ChangelogNotes } from "@forthtilliath/react-native-kit/components/update/ChangelogNotes";
29
- ```
30
-
31
- This is the recommended way to import: Metro (React Native's bundler) doesn't reliably tree-shake, so pulling from a single deep-import path keeps peer dependencies you don't use (`expo-image-picker`, `expo-speech-recognition`, `react-native-gesture-handler`...) out of your bundle entirely, rather than merely unused.
32
-
33
- A root barrel is also available for convenience:
34
-
35
- ```ts
36
- import {
37
- ChangelogNotes,
38
- useSubmitGuard,
39
- } from "@forthtilliath/react-native-kit";
40
- ```
41
-
42
- **Avoid the barrel under Jest (or any other CommonJS `require` consumer).** `export * from` re-exports are evaluated eagerly on `require()` — unlike Metro's ESM bundling, there's no tree-shaking to skip the unused ones. Requiring the barrel from _any_ file, even one that only wants a framework-agnostic util like `getMostRecentIds`, pulls in every component's module graph, including native-module imports (`expo-speech-recognition` via `VoiceSearchButton`/`PickerModal`) that don't exist in a Jest environment — this throws `Cannot find native module '...'` at require time, not just at runtime for an unrendered component. Deep imports only ever load the one module you asked for, so they don't have this problem in any environment.
43
-
44
- ### `<ChangelogNotes notes={...} styles={...} />`
45
-
46
- Renders GitHub-style release notes — as produced by `@forthtilliath/expo-release-updates`'s `parseChangelogNotes` — as headings, bulleted items, and bold-aware text, instead of showing the raw Markdown syntax in a plain `Text`.
47
-
48
- ```tsx
49
- <ChangelogNotes notes={release.notes} />
50
- ```
51
-
52
- It understands the small subset of Markdown GitHub release notes actually use:
53
-
54
- | `notes` input | Rendered as |
55
- | ----------------------------------------- | --------------------------------------------------------------------------- |
56
- | `### Added` | a heading `Text` — `"Added"` |
57
- | `- Export history to CSV.` | a row (`View`) with a `•` bullet `Text` and an item `Text` |
58
- | `**Auto-backup**: saves every 5 minutes.` | a plain `Text`, with the bold segment split into its own bold-styled `Text` |
59
-
60
- A full example, mixing all three:
61
-
62
- ```tsx
63
- <ChangelogNotes
64
- notes={`### Added\n- **Auto-backup**: saves every 5 minutes.\n- Export history to CSV.\n\nSee the full changelog for details.`}
65
- />
66
- ```
67
-
68
- The component ships with neutral default styles and no opinion on your app's theme. Override any subset via the `styles` prop to match your colors/dark mode:
69
-
70
- ```tsx
71
- <ChangelogNotes
72
- notes={release.notes}
73
- styles={{
74
- heading: { color: colors.text, fontWeight: "700" },
75
- itemRow: { gap: 8 },
76
- bullet: { color: colors.primary },
77
- itemText: { color: colors.textMuted },
78
- text: { color: colors.textMuted },
79
- bold: { color: colors.text, fontWeight: "700" },
80
- }}
81
- />
82
- ```
83
-
84
- Every field of `styles` is optional — pass only the ones you want to override; the rest fall back to the defaults (`ChangelogNotesStyles` in `ChangelogNotes.tsx`).
85
-
86
- ### `<Thumbnail photoUri={...} placeholderIcon="..." />`
87
-
88
- List-row thumbnail: the photo if there is one, otherwise a placeholder icon.
89
-
90
- ```tsx
91
- import { Thumbnail } from "@forthtilliath/react-native-kit/components/list/Thumbnail";
92
-
93
- <Thumbnail
94
- photoUri={container.photoUri}
95
- placeholderIcon="cube-outline"
96
- size={48}
97
- />;
98
- ```
99
-
100
- ### `<SwipeableRow onDelete={...} deleteLabel="...">`
101
-
102
- Swipe a list row left to reveal a delete button, on top of a tap to edit it.
103
-
104
- ```tsx
105
- import { SwipeableRow } from "@forthtilliath/react-native-kit/components/list/SwipeableRow";
106
-
107
- <SwipeableRow
108
- onDelete={() => remove(item.id)}
109
- deleteLabel={`Delete ${item.name}`}
110
- >
111
- <ItemRow item={item} />
112
- </SwipeableRow>;
113
- ```
114
-
115
- ### `<VoiceSearchButton onResult={...} />`
116
-
117
- Microphone button to dictate a search instead of typing it. Safe to mount more than one at a time (e.g. a name field plus a search picker on the same screen) — only the instance that started listening reacts to its result.
118
-
119
- ```tsx
120
- import { VoiceSearchButton } from "@forthtilliath/react-native-kit/components/picker/VoiceSearchButton";
121
-
122
- <VoiceSearchButton onResult={setQuery} lang="en-US" />;
123
- ```
124
-
125
- ### `<PhotoPicker photoUri={...} onChange={...} savePhoto={...} photoLabel="..." />`
126
-
127
- Photo picker (camera or library) with a preview and a remove link. `savePhoto` is injected rather than hard-coded, so where/how the picked image gets persisted is entirely up to the caller.
128
-
129
- ```tsx
130
- import { PhotoPicker } from "@forthtilliath/react-native-kit/components/picker/PhotoPicker";
131
-
132
- <PhotoPicker
133
- photoUri={container.photoUri}
134
- onChange={(uri) => setPhotoUri(uri)}
135
- savePhoto={(sourceUri) => saveContainerPhoto(sourceUri)}
136
- photoLabel="of the container"
137
- />;
138
- ```
139
-
140
- ### `<PickerModal visible title items onSelect onClose />`
141
-
142
- Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
143
-
144
- ```tsx
145
- import {
146
- PickerModal,
147
- type PickerItem,
148
- } from "@forthtilliath/react-native-kit/components/picker/PickerModal";
149
-
150
- <PickerModal
151
- visible={pickerVisible}
152
- title="Choose a container"
153
- items={containers.map((c): PickerItem => ({
154
- id: c.id,
155
- label: c.name,
156
- imageUri: c.photoUri,
157
- }))}
158
- onSelect={(item) => setContainerId(item.id)}
159
- onClose={() => setPickerVisible(false)}
160
- extraActions={[
161
- {
162
- label: "Add a new container",
163
- onPress: () => router.push("/containers/new"),
164
- },
165
- ]}
166
- />;
167
- ```
168
-
169
- Groups results into sections via each item's `group` (e.g. food groups, ingredients vs recipes) — ignored while searching, where the best global matches are shown instead. Pass `filterItems` for custom ranking (e.g. `rankByNameMatch` from this same package's `utils/`) instead of the default case-insensitive substring match.
170
-
171
- For all 5 components above, styling and (where relevant) copy work the same way as `ChangelogNotes`: an optional `styles` prop (all fields optional, neutral defaults) and, for `PhotoPicker`/`PickerModal`, an optional `labels` prop for the built-in French copy.
172
-
173
- ### `useSubmitGuard()`
174
-
175
- Prevents a second call while a first one is still pending — e.g. a double-tap on a "Save" button before it's had time to disable, which would otherwise create duplicate submissions.
176
-
177
- ```tsx
178
- import { useSubmitGuard } from "@forthtilliath/react-native-kit/hooks/useSubmitGuard";
179
-
180
- const { isSaving, guard } = useSubmitGuard();
181
-
182
- <Pressable
183
- disabled={isSaving}
184
- onPress={() =>
185
- guard(async () => {
186
- await save();
187
- })
188
- }
189
- >
190
- <Text>{isSaving ? "Saving…" : "Save"}</Text>
191
- </Pressable>;
192
- ```
193
-
194
- ### `useDebouncedChange(values, delayMs, callback)`
195
-
196
- Calls `callback` `delayMs` after the last change among `values`, ignoring renders where any value is still `undefined` (not loaded yet) and the very first render where they're all defined (no trigger on mount). A new change before the delay elapses resets the timer — a real debounce, not a throttle.
197
-
198
- ```tsx
199
- import { useDebouncedChange } from "@forthtilliath/react-native-kit/hooks/useDebouncedChange";
200
-
201
- useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
202
- runAutoBackup();
203
- });
204
- ```
205
-
206
- ### `confirmDestructive(title, onConfirm, options?)`
207
-
208
- Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults (`message`, `cancelLabel`, `confirmLabel` all overridable).
209
-
210
- ```ts
211
- import { confirmDestructive } from "@forthtilliath/react-native-kit/utils/helpers/confirmDestructive";
212
-
213
- confirmDestructive("Delete this item?", () => deleteItem(id), {
214
- message: "This cannot be undone.",
215
- cancelLabel: "Cancel",
216
- confirmLabel: "Delete",
217
- });
218
- ```
219
-
220
- ### `useEffectiveColorScheme(preference)`
221
-
222
- Resolves a `"light" | "dark" | "system"` theme preference against the device's color scheme: `"system"` follows the device, `"light"`/`"dark"` override it regardless of what the device is set to.
223
-
224
- ```tsx
225
- import { useEffectiveColorScheme } from "@forthtilliath/react-native-kit/hooks/useEffectiveColorScheme";
226
-
227
- const scheme = useEffectiveColorScheme(themePreference); // "light" | "dark"
228
- const colors = scheme === "dark" ? darkColors : lightColors;
229
- ```
230
-
231
- ### `<ThemeToggle value onChange />`
232
-
233
- 3-way segmented control for a light/dark/system theme preference.
234
-
235
- ```tsx
236
- import { ThemeToggle } from "@forthtilliath/react-native-kit/components/theme/ThemeToggle";
237
-
238
- <ThemeToggle value={themePreference} onChange={setThemePreference} />;
239
- ```
240
-
241
- ### `<ThemeOptionList value onChange />`
242
-
243
- Same light/dark/system data contract as `ThemeToggle`, but rendered as a list of full-width rows (icon + label + checkmark on the active one) instead of a segmented control.
244
-
245
- ```tsx
246
- import { ThemeOptionList } from "@forthtilliath/react-native-kit/components/theme/ThemeOptionList";
247
-
248
- <ThemeOptionList value={themePreference} onChange={setThemePreference} />;
249
- ```
250
-
251
- ### `useUpdateCheck(options)`
252
-
253
- Checks once per mount (e.g. app launch) whether a newer release is available, throttled to at most one real check per `minIntervalMs` (default 12h) and silent for a release the user already dismissed. Has no opinion on where "when did we last check" / "which version did the user dismiss" are persisted — both are read/written entirely through the options you pass in.
254
-
255
- ```tsx
256
- import { useUpdateCheck } from "@forthtilliath/react-native-kit/hooks/useUpdateCheck";
257
-
258
- const update = useUpdateCheck({
259
- currentVersion: Constants.expoConfig?.version ?? "0.0.0",
260
- checkForUpdate: fetchLatestRelease,
261
- compareVersions,
262
- getLastCheck: () => ({
263
- lastCheckedAt: settings?.lastUpdateCheckAt ?? null,
264
- dismissedVersion: settings?.dismissedUpdateVersion ?? null,
265
- }),
266
- onChecked: (lastCheckedAt) =>
267
- updateSettings({ lastUpdateCheckAt: lastCheckedAt }),
268
- });
269
-
270
- if (update.status === "available") {
271
- // update.release.version / .notes / .apkUrl
272
- }
273
- ```
274
-
275
- ### `<UpdateAvailableBanner version notes onPress onDismiss />`
276
-
277
- Dismissible banner announcing an available update: version, release notes (rendered via `ChangelogNotes`), an action button and a dismiss button. Has no opinion on what the action does (e.g. navigate to an update screen) or on how/whether dismissal is persisted.
278
-
279
- ```tsx
280
- import { UpdateAvailableBanner } from "@forthtilliath/react-native-kit/components/update/UpdateAvailableBanner";
281
-
282
- {
283
- update.status === "available" && (
284
- <UpdateAvailableBanner
285
- version={update.release.version}
286
- notes={update.release.notes}
287
- onPress={() => router.push("/settings/update")}
288
- onDismiss={() => {
289
- dismissUpdateVersion(update.release.version);
290
- update.dismiss();
291
- }}
292
- />
293
- );
294
- }
295
- ```
296
-
297
- ### Settings screens (`components/settings/`)
298
-
299
- The building blocks of a typical app "Settings" tab — menu, theme, update check, backup, about, contact, privacy — factored out so each new app stops rewriting the same ~7 screens from scratch. Expo Router (or any file-based router) still needs one physical route file per screen in your app; these just give each route file almost nothing left to write beyond rendering the matching component. None of them add new peer dependencies beyond what's already listed above.
300
-
301
- ```tsx
302
- // app/(tabs)/settings/index.tsx
303
- import { router } from "expo-router";
304
- import { SettingsMenu } from "@forthtilliath/react-native-kit/components/settings/SettingsMenu";
305
-
306
- export default function SettingsMenuScreen() {
307
- return (
308
- <SettingsMenu
309
- items={[
310
- // `key` doubles as the lookup into a built-in default icon/label
311
- // pairing for the 6 sections above — no `emoji`/`icon` needed here.
312
- {
313
- key: "backup",
314
- title: "Sauvegarde",
315
- hint: "Exporter / importer tes données",
316
- onPress: () => router.push("/settings/backup"),
317
- },
318
- {
319
- key: "theme",
320
- title: "Thème",
321
- hint: "Clair, sombre, ou automatique",
322
- onPress: () => router.push("/settings/theme"),
323
- },
324
- {
325
- key: "update",
326
- title: "Mise à jour",
327
- onPress: () => router.push("/settings/update"),
328
- },
329
- {
330
- key: "about",
331
- title: "À propos",
332
- onPress: () => router.push("/settings/about"),
333
- },
334
- {
335
- key: "contact",
336
- title: "Contact",
337
- onPress: () => router.push("/settings/contact"),
338
- },
339
- {
340
- key: "privacy",
341
- title: "Confidentialité",
342
- onPress: () => router.push("/settings/privacy"),
343
- },
344
- // An app-specific section: no default exists for an arbitrary key,
345
- // so `emoji`/`icon` (and `hint`, if you want one) are yours to set.
346
- {
347
- key: "profile",
348
- emoji: "🙋",
349
- title: "Mon nom",
350
- onPress: () => router.push("/settings/profile"),
351
- },
352
- ]}
353
- />
354
- );
355
- }
356
- ```
357
-
358
- `SettingsMenu` doesn't depend on expo-router (or any other navigator) itself — each item carries its own `onPress`, so it works the same with any navigation setup.
359
-
360
- Two independent style knobs, both usable on their own:
361
-
362
- - **Icon flavor** — each item can set its own `emoji` (naturally multicolor) or `icon` (an `Ionicons` glyph, name in `styles.iconColor`) to override its default; `defaultIconKind` (`"emoji"` by default, or `"icon"`) picks which flavor of the _default_ table applies to items that don't set either.
363
- - **Hints** — set `showHints={false}` to hide every item's `hint` at once, for a denser, icon-only-style menu.
364
-
365
- ```tsx
366
- <SettingsMenu
367
- items={[
368
- {
369
- key: "backup",
370
- title: "Sauvegarde",
371
- onPress: () => router.push("/settings/backup"),
372
- },
373
- ]}
374
- defaultIconKind="icon"
375
- showHints={false}
376
- styles={{
377
- row: { backgroundColor: colors.primary, borderWidth: 0 },
378
- iconColor: "#fff",
379
- title: { color: "#fff" },
380
- }}
381
- />
382
- ```
383
-
384
- Pass `groups` instead of `items` to render rows under optional section headers (e.g. separating app-specific settings from the standard ones) — the two are mutually exclusive:
385
-
386
- ```tsx
387
- <SettingsMenu
388
- groups={[
389
- {
390
- title: "Réglages de calcul",
391
- items: [
392
- {
393
- key: "ratio",
394
- emoji: "🧮",
395
- title: "Ratio glucidique",
396
- onPress: () => router.push("/settings/ratio"),
397
- },
398
- ],
399
- },
400
- {
401
- // No `title` renders the group with no header at all, e.g. for a
402
- // leading group of app-specific items above the standard ones.
403
- items: [
404
- {
405
- key: "backup",
406
- title: "Sauvegarde",
407
- onPress: () => router.push("/settings/backup"),
408
- },
409
- ],
410
- },
411
- ]}
412
- />
413
- ```
414
-
415
- #### `<ThemeSettingsScreen value onChange />`
416
-
417
- Same light/dark/system data contract as `ThemeToggle`/`ThemeOptionList`. Two visuals, picked via `variant`: `"emoji"` (default) — emoji + label + a plain-text `✓` on the active row — or `"icon"` — `Ionicons` glyphs in one shared color (`styles.iconColor`/`iconColorActive`) + an `Ionicons` checkmark, matching `SettingsMenu`'s `defaultIconKind="icon"` look. `showHint={false}` hides the hint above the list.
418
-
419
- ```tsx
420
- import { ThemeSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/ThemeSettingsScreen";
421
-
422
- <ThemeSettingsScreen value={themePreference} onChange={setThemePreference} />;
423
-
424
- // Or, matching an icon-flavored SettingsMenu:
425
- <ThemeSettingsScreen
426
- value={themePreference}
427
- onChange={setThemePreference}
428
- variant="icon"
429
- />;
430
- ```
431
-
432
- #### `<UpdateSettingsScreen currentVersion checkForUpdate compareVersions downloadAndInstallApk />`
433
-
434
- The full "check for update" screen: installed version, a manual check button, the available-update box (changelog + install button) or an up-to-date/error message, download progress, and — if `fetchReleaseHistory` is passed — a list of past releases. Checks automatically once on mount. Pairs naturally with `@forthtilliath/expo-release-updates` and `useUpdateCheck`/`UpdateAvailableBanner` above (same `checkForUpdate`/`compareVersions` you already pass those), but takes plain functions so it isn't tied to that package's exact shape.
435
-
436
- ```tsx
437
- import { UpdateSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/UpdateSettingsScreen";
438
-
439
- import {
440
- compareVersions,
441
- downloadAndInstallApk,
442
- fetchLatestRelease,
443
- fetchReleaseHistory,
444
- } from "@/lib/appUpdate";
445
-
446
- <UpdateSettingsScreen
447
- currentVersion={Constants.expoConfig?.version ?? "?"}
448
- checkForUpdate={fetchLatestRelease}
449
- compareVersions={compareVersions}
450
- downloadAndInstallApk={downloadAndInstallApk}
451
- fetchReleaseHistory={fetchReleaseHistory}
452
- />;
453
- ```
454
-
455
- #### `<BackupSettingsScreen onExport onImport reminder? />`
456
-
457
- Export/import buttons plus an optional reminder-notification switch. Deliberately just a shell: what actually gets exported/imported is entirely app-specific (your own database tables), so `onExport`/`onImport` are required — this package can't have a sensible default for "how do I export my schema". Import asks for confirmation first (via `confirmDestructive`, from `utils/helpers/`) since it replaces existing data; `onImport` itself doesn't need to.
458
-
459
- ```tsx
460
- import { BackupSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/BackupSettingsScreen";
461
-
462
- <BackupSettingsScreen
463
- onExport={shareBackup}
464
- onImport={pickAndImportBackup}
465
- reminder={{
466
- enabled: reminderEnabled,
467
- onToggle: handleToggleReminder,
468
- intervalDays: BACKUP_REMINDER_INTERVAL_DAYS,
469
- }}
470
- />;
471
- ```
472
-
473
- Omit `reminder` entirely for an app with no backup-reminder notifications.
474
-
475
- Three more knobs for apps whose backup section doesn't fit the compact 2-button shell: an `info` box above everything (e.g. "last auto-backup: ..."), `layout="sections"` for a titled, full-width, stacked layout instead of side-by-side buttons, and `icons` for a leading `Ionicons` glyph on either button.
476
-
477
- ```tsx
478
- <BackupSettingsScreen
479
- onExport={shareBackup}
480
- onImport={pickAndImportBackup}
481
- info={{ label: "Dernière sauvegarde auto", value: lastAutoBackupLabel }}
482
- layout="sections"
483
- icons={{ export: "cloud-upload-outline", import: "cloud-download-outline" }}
484
- />
485
- ```
486
-
487
- #### `<AboutSettingsScreen appName version description developerName? sections? />`
488
-
489
- App name, version, one or more description paragraphs, and an optional "developed by" credit. `appName`/`version` are required rather than defaulted internally (keeps this package framework-agnostic) — pass `Constants.expoConfig?.name`/`.version` from the caller.
490
-
491
- ```tsx
492
- import { AboutSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/AboutSettingsScreen";
493
-
494
- <AboutSettingsScreen
495
- appName={Constants.expoConfig?.name ?? "My App"}
496
- version={Constants.expoConfig?.version ?? "?"}
497
- description="A personal tool to do the thing."
498
- developerName="Your Name"
499
- />;
500
- ```
501
-
502
- Omit `developerName` for an app with no credit line to show. Pass `sections` for extra titled blocks after the description — e.g. a medical/legal disclaimer:
503
-
504
- ```tsx
505
- <AboutSettingsScreen
506
- appName="GlucoDose"
507
- version="1.2.0"
508
- description="Un outil personnel de calcul de dose."
509
- sections={[
510
- {
511
- title: "Avertissement médical",
512
- paragraphs: ["Cet outil ne remplace pas un avis médical professionnel."],
513
- },
514
- ]}
515
- />
516
- ```
517
-
518
- #### `<ContactSettingsScreen email actions? emailIcon? />`
519
-
520
- A `mailto:` button plus a short "how to report a bug" hint.
521
-
522
- ```tsx
523
- import { ContactSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/ContactSettingsScreen";
524
-
525
- <ContactSettingsScreen email="you@example.com" />;
526
- ```
527
-
528
- Pass `actions` for extra rows below the email one (share the app, a donation link...) — switches the whole section from the single bordered button to a rows layout, email included:
529
-
530
- ```tsx
531
- <ContactSettingsScreen
532
- email="you@example.com"
533
- actions={[
534
- { icon: "share-outline", label: "Partager l'app", onPress: shareApp },
535
- { icon: "star-outline", label: "Noter l'app", onPress: openStoreListing },
536
- ]}
537
- />
538
- ```
539
-
540
- #### `<PrivacySettingsScreen sections? />`
541
-
542
- Ships with the standard "everything stays on this device" three sections (no data collected, local storage only, sharing is opt-in) as the default — the right fit for most of these small personal-data apps. Pass your own `sections` to fully replace it for an app with different data handling (e.g. one that syncs to a server).
543
-
544
- ```tsx
545
- import { PrivacySettingsScreen } from "@forthtilliath/react-native-kit/components/settings/PrivacySettingsScreen";
546
-
547
- // Zero-config, uses the default copy:
548
- <PrivacySettingsScreen />;
549
-
550
- // Or fully custom:
551
- <PrivacySettingsScreen
552
- sections={[
553
- {
554
- title: "What we collect",
555
- paragraphs: ["Only what you enter, synced to your account."],
556
- },
557
- ]}
558
- />;
559
- ```
560
-
561
- For all 7 components above, styling works the same way as `ChangelogNotes`: an optional `styles` prop (all fields optional, neutral defaults), and where relevant an optional `labels` prop for the built-in French copy.
562
-
563
- ### Utils (`utils/`)
564
-
565
- Framework-agnostic pure functions — no React or React Native import, usable from Node/web too.
566
-
567
- ```ts
568
- import { getPeriodStartMs } from "@forthtilliath/react-native-kit/utils/helpers/getPeriodStartMs";
569
- import { getMostRecentIds } from "@forthtilliath/react-native-kit/utils/helpers/getMostRecentIds";
570
- import { nextInCycle } from "@forthtilliath/react-native-kit/utils/helpers/nextInCycle";
571
- import { normalizeForSearch } from "@forthtilliath/react-native-kit/utils/helpers/normalizeForSearch";
572
- import { rankByNameMatch } from "@forthtilliath/react-native-kit/utils/helpers/rankByNameMatch";
573
- import { escapeCsvField } from "@forthtilliath/react-native-kit/utils/format/escapeCsvField";
574
- import { formatCsvNumber } from "@forthtilliath/react-native-kit/utils/format/formatCsvNumber";
575
- import { escapeHtml } from "@forthtilliath/react-native-kit/utils/format/escapeHtml";
576
- ```
577
-
578
- | Function | What it does |
579
- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
580
- | `getPeriodStartMs(period, now?)` | Start timestamp (ms) for a `"today" \| "7d" \| "30d" \| "all"` period filter, or `null` for `"all"`. `"today"` is the current calendar day, not a rolling 24h. |
581
- | `getMostRecentIds(rows, limit?)` | Most recently occurring distinct ids (`{ id, occurredAt }[]`), most recent first, deduplicated, limited (default 5). |
582
- | `nextInCycle(ids, currentId)` | Next id in a short list (e.g. cycling through recents on tap) — wraps to the first if `currentId` is at the end or no longer in the list. |
583
- | `normalizeForSearch(text)` | Lowercases, trims, strips accents, and expands `œ`/`æ` ligatures (which `normalize("NFD")` alone doesn't decompose) — for accent/case-insensitive search matching. |
584
- | `rankByNameMatch(items, query, getName)` | Ranks `items` by relevance to `query`: earlier match position first, then shorter name — for a search-as-you-type list. |
585
- | `escapeCsvField(value)` | Quotes and escapes a CSV field (RFC 4180) only if it contains a `"`, `;`, or newline. |
586
- | `formatCsvNumber(value, decimals?)` | Formats a number with a comma decimal separator (French-locale spreadsheets) instead of JS's dot. |
587
- | `escapeHtml(text)` | Basic HTML entity escaping (`&`, `<`, `>`, `"`) for inserting user text into an HTML template. |
588
-
589
- ## Scripts
590
-
591
- ```bash
592
- pnpm run dev # tsc --watch -> dist/
593
- pnpm run build # tsc -> dist/
594
- pnpm run check-types # tsc --noEmit
595
- pnpm run lint # eslint
596
- pnpm run test # vitest run
597
- pnpm run test:watch # vitest
598
- ```
599
-
600
- Built to `dist/` (see the `exports` field in `package.json`), so run `pnpm run build` (or `dev`) after source changes for consumers to see them.
601
-
602
- ### Testing note
603
-
604
- `react-native`'s package entry uses Flow syntax that `@babel/parser`'s flow plugin can't parse (`as Cast` casts aren't supported there), so Vitest can never load the real package directly. Tests alias `react-native` to a minimal stub (`src/__mocks__/react-native.tsx`) instead — see that file for details. `react-native-gesture-handler` ships the same kind of Flow syntax, and `@expo/vector-icons` has its own unrelated ESM-resolution issue under Vite/Rollup — both are stubbed the same way (`src/__mocks__/react-native-gesture-handler.tsx`, `src/__mocks__/expo-vector-icons.tsx`). `expo-speech-recognition` and `expo-image-picker` wrap native modules unavailable under Vitest regardless, so they're mocked with plain spies (`src/__mocks__/expo-speech-recognition.tsx`, `src/__mocks__/expo-image-picker.ts`).
1
+ # @forthtilliath/react-native-kit
2
+
3
+ Small React Native building blocks — components, hooks, and a framework-agnostic util — with no opinion on your app's theme, distributed one file per export (grouped under `components/{picker,theme,list,settings}/`, `hooks/`, `utils/helpers/`) so consumers only pull in what they use. Update-checking UI (`ChangelogNotes`, `UpdateAvailableBanner`, `useUpdateCheck`) lives in the sibling `@forthtilliath/expo-release-updates-ui` package instead.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @forthtilliath/react-native-kit react react-native
9
+ ```
10
+
11
+ Or, from within this monorepo, as a workspace dependency:
12
+
13
+ ```json
14
+ {
15
+ "dependencies": {
16
+ "@forthtilliath/react-native-kit": "workspace:*"
17
+ }
18
+ }
19
+ ```
20
+
21
+ `react` and `react-native` are peer dependencies — install them in the consuming app if not already present. `@expo/vector-icons`, `expo-image-picker`, `expo-speech-recognition`, and `react-native-gesture-handler` are also peer dependencies, needed only if you import the components that use them (`Thumbnail`/`PickerModal`/`SwipeableRow`, `PhotoPicker`, `VoiceSearchButton`/`PickerModal`, `SwipeableRow`, respectively).
22
+
23
+ ## Usage
24
+
25
+ Each component is its own module — import the file you need directly:
26
+
27
+ ```ts
28
+ import { PhotoPicker } from "@forthtilliath/react-native-kit/components/picker/PhotoPicker";
29
+ ```
30
+
31
+ This is the recommended way to import: Metro (React Native's bundler) doesn't reliably tree-shake, so pulling from a single deep-import path keeps peer dependencies you don't use (`expo-image-picker`, `expo-speech-recognition`, `react-native-gesture-handler`...) out of your bundle entirely, rather than merely unused.
32
+
33
+ A root barrel is also available for convenience:
34
+
35
+ ```ts
36
+ import { PhotoPicker, useSubmitGuard } from "@forthtilliath/react-native-kit";
37
+ ```
38
+
39
+ **Avoid the barrel under Jest (or any other CommonJS `require` consumer).** `export * from` re-exports are evaluated eagerly on `require()` — unlike Metro's ESM bundling, there's no tree-shaking to skip the unused ones. Requiring the barrel from _any_ file, even one that only wants a framework-agnostic util like `getMostRecentIds`, pulls in every component's module graph, including native-module imports (`expo-speech-recognition` via `VoiceSearchButton`/`PickerModal`) that don't exist in a Jest environment — this throws `Cannot find native module '...'` at require time, not just at runtime for an unrendered component. Deep imports only ever load the one module you asked for, so they don't have this problem in any environment.
40
+
41
+ ### `<Thumbnail photoUri={...} placeholderIcon="..." />`
42
+
43
+ List-row thumbnail: the photo if there is one, otherwise a placeholder icon.
44
+
45
+ ```tsx
46
+ import { Thumbnail } from "@forthtilliath/react-native-kit/components/list/Thumbnail";
47
+
48
+ <Thumbnail
49
+ photoUri={container.photoUri}
50
+ placeholderIcon="cube-outline"
51
+ size={48}
52
+ />;
53
+ ```
54
+
55
+ ### `<SwipeableRow onDelete={...} deleteLabel="...">`
56
+
57
+ Swipe a list row left to reveal a delete button, on top of a tap to edit it.
58
+
59
+ ```tsx
60
+ import { SwipeableRow } from "@forthtilliath/react-native-kit/components/list/SwipeableRow";
61
+
62
+ <SwipeableRow
63
+ onDelete={() => remove(item.id)}
64
+ deleteLabel={`Delete ${item.name}`}
65
+ >
66
+ <ItemRow item={item} />
67
+ </SwipeableRow>;
68
+ ```
69
+
70
+ ### `<ColorDot color={...} />`
71
+
72
+ A small round color swatch — the coloured bullet next to a category, a team, a
73
+ tag, or a status. **Renders `null` when `color` is `undefined`**, so a list row
74
+ can bind an optional color field without guarding it at the call site.
75
+
76
+ ```tsx
77
+ import { ColorDot } from "@forthtilliath/react-native-kit/components/list/ColorDot";
78
+
79
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
80
+ <ColorDot color={category.color} />
81
+ <Text>{category.name}</Text>
82
+ </View>;
83
+ ```
84
+
85
+ | Prop | Type | Default | Notes |
86
+ | ------- | ---------------------- | ------- | ----------------------------------------------------------------- |
87
+ | `color` | `string \| undefined` | — | Any RN color string. `undefined` → renders nothing. |
88
+ | `size` | `number` | `10` | Diameter in px; always a full circle (`borderRadius = size / 2`). |
89
+ | `style` | `StyleProp<ViewStyle>` | — | Merged **after** the computed style, so it wins on conflicts. |
90
+
91
+ ```tsx
92
+ // Bigger, with a subtle ring, and some trailing space:
93
+ <ColorDot
94
+ color={team.color}
95
+ size={16}
96
+ style={{ borderWidth: 1, borderColor: "rgba(0,0,0,0.15)", marginRight: 6 }}
97
+ />
98
+ ```
99
+
100
+ ### `<UndoToast message={...} onAction={...} />`
101
+
102
+ A bottom-of-screen snackbar for **undoing an action that already happened** —
103
+ the counterpart to a delete that runs immediately with no confirmation dialog
104
+ (for instance a `SwipeableRow` swipe). Presentational only: it does not time
105
+ itself out, animate, or stack — the host owns the "pending undo" state, the
106
+ commit timer, and any enter/exit animation, which keeps this component free of
107
+ timer or animation-library opinions.
108
+
109
+ ```tsx
110
+ import { UndoToast } from "@forthtilliath/react-native-kit/components/list/UndoToast";
111
+
112
+ {
113
+ recentlyDeleted && (
114
+ <UndoToast
115
+ message={`"${recentlyDeleted.name}" deleted`}
116
+ onAction={restoreLast}
117
+ />
118
+ );
119
+ }
120
+ ```
121
+
122
+ | Prop | Type | Default | Notes |
123
+ | ------------- | ----------------- | -------- | ----------------------------------------------------------------------------------- |
124
+ | `message` | `string` | — | Past tense; single line, truncated with an ellipsis. |
125
+ | `actionLabel` | `string` | `"Undo"` | The tappable label on the right. |
126
+ | `onAction` | `() => void` | — | Reverse the change here. The host then hides the toast. |
127
+ | `styles` | `UndoToastStyles` | — | Per-slot overrides (`toast` / `message` / `action`), each merged after its default. |
128
+
129
+ Full pattern — commit for good after 5s, localized label, re-themed:
130
+
131
+ ```tsx
132
+ const [pending, setPending] = useState<Employee | null>(null);
133
+
134
+ function remove(employee: Employee) {
135
+ setEmployees((list) => list.filter((e) => e.id !== employee.id));
136
+ setPending(employee);
137
+ }
138
+
139
+ useEffect(() => {
140
+ if (!pending) return;
141
+ const id = setTimeout(() => {
142
+ db.deleteEmployee(pending.id); // point of no return
143
+ setPending(null);
144
+ }, 5000);
145
+ return () => clearTimeout(id);
146
+ }, [pending]);
147
+
148
+ {
149
+ pending && (
150
+ <UndoToast
151
+ message={`« ${pending.name} » supprimé`}
152
+ actionLabel="Annuler"
153
+ onAction={() => {
154
+ setEmployees((list) => [...list, pending]);
155
+ setPending(null);
156
+ }}
157
+ styles={{
158
+ toast: { backgroundColor: colors.inverseSurface },
159
+ message: { color: colors.inverseOnSurface },
160
+ action: { color: colors.inversePrimary },
161
+ }}
162
+ />
163
+ );
164
+ }
165
+ ```
166
+
167
+ ### `<VoiceSearchButton onResult={...} />`
168
+
169
+ Microphone button to dictate a search instead of typing it. Safe to mount more than one at a time (e.g. a name field plus a search picker on the same screen) — only the instance that started listening reacts to its result.
170
+
171
+ ```tsx
172
+ import { VoiceSearchButton } from "@forthtilliath/react-native-kit/components/picker/VoiceSearchButton";
173
+
174
+ <VoiceSearchButton onResult={setQuery} lang="en-US" />;
175
+ ```
176
+
177
+ ### `<PhotoPicker photoUri={...} onChange={...} savePhoto={...} photoLabel="..." />`
178
+
179
+ Photo picker (camera or library) with a preview and a remove link. `savePhoto` is injected rather than hard-coded, so where/how the picked image gets persisted is entirely up to the caller.
180
+
181
+ ```tsx
182
+ import { PhotoPicker } from "@forthtilliath/react-native-kit/components/picker/PhotoPicker";
183
+
184
+ <PhotoPicker
185
+ photoUri={container.photoUri}
186
+ onChange={(uri) => setPhotoUri(uri)}
187
+ savePhoto={(sourceUri) => saveContainerPhoto(sourceUri)}
188
+ photoLabel="of the container"
189
+ />;
190
+ ```
191
+
192
+ ### `<PickerModal visible title items onSelect onClose />`
193
+
194
+ Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
195
+
196
+ ```tsx
197
+ import {
198
+ PickerModal,
199
+ type PickerItem,
200
+ } from "@forthtilliath/react-native-kit/components/picker/PickerModal";
201
+
202
+ <PickerModal
203
+ visible={pickerVisible}
204
+ title="Choose a container"
205
+ items={containers.map((c): PickerItem => ({
206
+ id: c.id,
207
+ label: c.name,
208
+ imageUri: c.photoUri,
209
+ }))}
210
+ onSelect={(item) => setContainerId(item.id)}
211
+ onClose={() => setPickerVisible(false)}
212
+ extraActions={[
213
+ {
214
+ label: "Add a new container",
215
+ onPress: () => router.push("/containers/new"),
216
+ },
217
+ ]}
218
+ />;
219
+ ```
220
+
221
+ Groups results into sections via each item's `group` (e.g. food groups, ingredients vs recipes) — ignored while searching, where the best global matches are shown instead. Pass `filterItems` for custom ranking (e.g. `rankByNameMatch` from this same package's `utils/`) instead of the default case-insensitive substring match.
222
+
223
+ For all 5 components above, styling and (where relevant) copy work the same way as `ChangelogNotes`: an optional `styles` prop (all fields optional, neutral defaults) and, for `PhotoPicker`/`PickerModal`, an optional `labels` prop for the built-in French copy.
224
+
225
+ ### `useSubmitGuard()`
226
+
227
+ Prevents a second call while a first one is still pending — e.g. a double-tap on a "Save" button before it's had time to disable, which would otherwise create duplicate submissions.
228
+
229
+ ```tsx
230
+ import { useSubmitGuard } from "@forthtilliath/react-native-kit/hooks/useSubmitGuard";
231
+
232
+ const { isSaving, guard } = useSubmitGuard();
233
+
234
+ <Pressable
235
+ disabled={isSaving}
236
+ onPress={() =>
237
+ guard(async () => {
238
+ await save();
239
+ })
240
+ }
241
+ >
242
+ <Text>{isSaving ? "Saving…" : "Save"}</Text>
243
+ </Pressable>;
244
+ ```
245
+
246
+ ### `useDebouncedChange(values, delayMs, callback)`
247
+
248
+ Calls `callback` `delayMs` after the last change among `values`, ignoring renders where any value is still `undefined` (not loaded yet) and the very first render where they're all defined (no trigger on mount). A new change before the delay elapses resets the timer — a real debounce, not a throttle.
249
+
250
+ ```tsx
251
+ import { useDebouncedChange } from "@forthtilliath/react-native-kit/hooks/useDebouncedChange";
252
+
253
+ useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
254
+ runAutoBackup();
255
+ });
256
+ ```
257
+
258
+ ### `confirmDestructive(title, onConfirm, options?)`
259
+
260
+ Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults (`message`, `cancelLabel`, `confirmLabel` all overridable).
261
+
262
+ ```ts
263
+ import { confirmDestructive } from "@forthtilliath/react-native-kit/utils/helpers/confirmDestructive";
264
+
265
+ confirmDestructive("Delete this item?", () => deleteItem(id), {
266
+ message: "This cannot be undone.",
267
+ cancelLabel: "Cancel",
268
+ confirmLabel: "Delete",
269
+ });
270
+ ```
271
+
272
+ ### `useEffectiveColorScheme(preference)`
273
+
274
+ Resolves a `"light" | "dark" | "system"` theme preference against the device's color scheme: `"system"` follows the device, `"light"`/`"dark"` override it regardless of what the device is set to.
275
+
276
+ ```tsx
277
+ import { useEffectiveColorScheme } from "@forthtilliath/react-native-kit/hooks/useEffectiveColorScheme";
278
+
279
+ const scheme = useEffectiveColorScheme(themePreference); // "light" | "dark"
280
+ const colors = scheme === "dark" ? darkColors : lightColors;
281
+ ```
282
+
283
+ ### `<ThemeToggle value onChange />`
284
+
285
+ 3-way segmented control for a light/dark/system theme preference.
286
+
287
+ ```tsx
288
+ import { ThemeToggle } from "@forthtilliath/react-native-kit/components/theme/ThemeToggle";
289
+
290
+ <ThemeToggle value={themePreference} onChange={setThemePreference} />;
291
+ ```
292
+
293
+ ### `<ThemeOptionList value onChange />`
294
+
295
+ Same light/dark/system data contract as `ThemeToggle`, but rendered as a list of full-width rows (icon + label + checkmark on the active one) instead of a segmented control.
296
+
297
+ ```tsx
298
+ import { ThemeOptionList } from "@forthtilliath/react-native-kit/components/theme/ThemeOptionList";
299
+
300
+ <ThemeOptionList value={themePreference} onChange={setThemePreference} />;
301
+ ```
302
+
303
+ > `useUpdateCheck`, `<UpdateAvailableBanner>` and `<ChangelogNotes>` moved to
304
+ > [`@forthtilliath/expo-release-updates-ui`](../expo-release-updates-ui#readme) —
305
+ > install that package alongside this one if you need them.
306
+
307
+ ### Settings screens (`components/settings/`)
308
+
309
+ The building blocks of a typical app "Settings" tab — menu, theme, update check, backup, about, contact, privacy — factored out so each new app stops rewriting the same ~7 screens from scratch. Expo Router (or any file-based router) still needs one physical route file per screen in your app; these just give each route file almost nothing left to write beyond rendering the matching component. None of them add new peer dependencies beyond what's already listed above.
310
+
311
+ ```tsx
312
+ // app/(tabs)/settings/index.tsx
313
+ import { router } from "expo-router";
314
+ import { SettingsMenu } from "@forthtilliath/react-native-kit/components/settings/SettingsMenu";
315
+
316
+ export default function SettingsMenuScreen() {
317
+ return (
318
+ <SettingsMenu
319
+ items={[
320
+ // `key` doubles as the lookup into a built-in default icon/label
321
+ // pairing for the 6 sections above — no `emoji`/`icon` needed here.
322
+ {
323
+ key: "backup",
324
+ title: "Sauvegarde",
325
+ hint: "Exporter / importer tes données",
326
+ onPress: () => router.push("/settings/backup"),
327
+ },
328
+ {
329
+ key: "theme",
330
+ title: "Thème",
331
+ hint: "Clair, sombre, ou automatique",
332
+ onPress: () => router.push("/settings/theme"),
333
+ },
334
+ {
335
+ key: "update",
336
+ title: "Mise à jour",
337
+ onPress: () => router.push("/settings/update"),
338
+ },
339
+ {
340
+ key: "about",
341
+ title: "À propos",
342
+ onPress: () => router.push("/settings/about"),
343
+ },
344
+ {
345
+ key: "contact",
346
+ title: "Contact",
347
+ onPress: () => router.push("/settings/contact"),
348
+ },
349
+ {
350
+ key: "privacy",
351
+ title: "Confidentialité",
352
+ onPress: () => router.push("/settings/privacy"),
353
+ },
354
+ // An app-specific section: no default exists for an arbitrary key,
355
+ // so `emoji`/`icon` (and `hint`, if you want one) are yours to set.
356
+ {
357
+ key: "profile",
358
+ emoji: "🙋",
359
+ title: "Mon nom",
360
+ onPress: () => router.push("/settings/profile"),
361
+ },
362
+ ]}
363
+ />
364
+ );
365
+ }
366
+ ```
367
+
368
+ `SettingsMenu` doesn't depend on expo-router (or any other navigator) itself — each item carries its own `onPress`, so it works the same with any navigation setup.
369
+
370
+ Two independent style knobs, both usable on their own:
371
+
372
+ - **Icon flavor** — each item can set its own `emoji` (naturally multicolor) or `icon` (an `Ionicons` glyph, name in `styles.iconColor`) to override its default; `defaultIconKind` (`"emoji"` by default, or `"icon"`) picks which flavor of the _default_ table applies to items that don't set either.
373
+ - **Hints** — set `showHints={false}` to hide every item's `hint` at once, for a denser, icon-only-style menu.
374
+
375
+ ```tsx
376
+ <SettingsMenu
377
+ items={[
378
+ {
379
+ key: "backup",
380
+ title: "Sauvegarde",
381
+ onPress: () => router.push("/settings/backup"),
382
+ },
383
+ ]}
384
+ defaultIconKind="icon"
385
+ showHints={false}
386
+ styles={{
387
+ row: { backgroundColor: colors.primary, borderWidth: 0 },
388
+ iconColor: "#fff",
389
+ title: { color: "#fff" },
390
+ }}
391
+ />
392
+ ```
393
+
394
+ Pass `groups` instead of `items` to render rows under optional section headers (e.g. separating app-specific settings from the standard ones) — the two are mutually exclusive:
395
+
396
+ ```tsx
397
+ <SettingsMenu
398
+ groups={[
399
+ {
400
+ title: "Réglages de calcul",
401
+ items: [
402
+ {
403
+ key: "ratio",
404
+ emoji: "🧮",
405
+ title: "Ratio glucidique",
406
+ onPress: () => router.push("/settings/ratio"),
407
+ },
408
+ ],
409
+ },
410
+ {
411
+ // No `title` renders the group with no header at all, e.g. for a
412
+ // leading group of app-specific items above the standard ones.
413
+ items: [
414
+ {
415
+ key: "backup",
416
+ title: "Sauvegarde",
417
+ onPress: () => router.push("/settings/backup"),
418
+ },
419
+ ],
420
+ },
421
+ ]}
422
+ />
423
+ ```
424
+
425
+ #### `<ThemeSettingsScreen value onChange />`
426
+
427
+ Same light/dark/system data contract as `ThemeToggle`/`ThemeOptionList`. Two visuals, picked via `variant`: `"emoji"` (default) — emoji + label + a plain-text `✓` on the active row — or `"icon"` — `Ionicons` glyphs in one shared color (`styles.iconColor`/`iconColorActive`) + an `Ionicons` checkmark, matching `SettingsMenu`'s `defaultIconKind="icon"` look. `showHint={false}` hides the hint above the list.
428
+
429
+ ```tsx
430
+ import { ThemeSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/ThemeSettingsScreen";
431
+
432
+ <ThemeSettingsScreen value={themePreference} onChange={setThemePreference} />;
433
+
434
+ // Or, matching an icon-flavored SettingsMenu:
435
+ <ThemeSettingsScreen
436
+ value={themePreference}
437
+ onChange={setThemePreference}
438
+ variant="icon"
439
+ />;
440
+ ```
441
+
442
+ #### `<UpdateSettingsScreen currentVersion checkForUpdate compareVersions downloadAndInstallApk />`
443
+
444
+ The full "check for update" screen: installed version, a manual check button, the available-update box (changelog + install button) or an up-to-date/error message, download progress, and — if `fetchReleaseHistory` is passed — a list of past releases. Checks automatically once on mount. Renders release notes via `@forthtilliath/expo-release-updates-ui`'s `ChangelogNotes`, and pairs naturally with that package's `useUpdateCheck`/`UpdateAvailableBanner` (same `checkForUpdate`/`compareVersions` you already pass those), but takes plain functions so it isn't tied to that package's exact shape.
445
+
446
+ ```tsx
447
+ import { UpdateSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/UpdateSettingsScreen";
448
+
449
+ import {
450
+ compareVersions,
451
+ downloadAndInstallApk,
452
+ fetchLatestRelease,
453
+ fetchReleaseHistory,
454
+ } from "@/lib/appUpdate";
455
+
456
+ <UpdateSettingsScreen
457
+ currentVersion={Constants.expoConfig?.version ?? "?"}
458
+ checkForUpdate={fetchLatestRelease}
459
+ compareVersions={compareVersions}
460
+ downloadAndInstallApk={downloadAndInstallApk}
461
+ fetchReleaseHistory={fetchReleaseHistory}
462
+ />;
463
+ ```
464
+
465
+ #### `<BackupSettingsScreen onExport onImport reminder? />`
466
+
467
+ Export/import buttons plus an optional reminder-notification switch. Deliberately just a shell: what actually gets exported/imported is entirely app-specific (your own database tables), so `onExport`/`onImport` are required — this package can't have a sensible default for "how do I export my schema". Import asks for confirmation first (via `confirmDestructive`, from `utils/helpers/`) since it replaces existing data; `onImport` itself doesn't need to.
468
+
469
+ ```tsx
470
+ import { BackupSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/BackupSettingsScreen";
471
+
472
+ <BackupSettingsScreen
473
+ onExport={shareBackup}
474
+ onImport={pickAndImportBackup}
475
+ reminder={{
476
+ enabled: reminderEnabled,
477
+ onToggle: handleToggleReminder,
478
+ intervalDays: BACKUP_REMINDER_INTERVAL_DAYS,
479
+ }}
480
+ />;
481
+ ```
482
+
483
+ Omit `reminder` entirely for an app with no backup-reminder notifications.
484
+
485
+ Three more knobs for apps whose backup section doesn't fit the compact 2-button shell: an `info` box above everything (e.g. "last auto-backup: ..."), `layout="sections"` for a titled, full-width, stacked layout instead of side-by-side buttons, and `icons` for a leading `Ionicons` glyph on either button.
486
+
487
+ ```tsx
488
+ <BackupSettingsScreen
489
+ onExport={shareBackup}
490
+ onImport={pickAndImportBackup}
491
+ info={{ label: "Dernière sauvegarde auto", value: lastAutoBackupLabel }}
492
+ layout="sections"
493
+ icons={{ export: "cloud-upload-outline", import: "cloud-download-outline" }}
494
+ />
495
+ ```
496
+
497
+ #### `<AboutSettingsScreen appName version description developerName? sections? />`
498
+
499
+ App name, version, one or more description paragraphs, and an optional "developed by" credit. `appName`/`version` are required rather than defaulted internally (keeps this package framework-agnostic) — pass `Constants.expoConfig?.name`/`.version` from the caller.
500
+
501
+ ```tsx
502
+ import { AboutSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/AboutSettingsScreen";
503
+
504
+ <AboutSettingsScreen
505
+ appName={Constants.expoConfig?.name ?? "My App"}
506
+ version={Constants.expoConfig?.version ?? "?"}
507
+ description="A personal tool to do the thing."
508
+ developerName="Your Name"
509
+ />;
510
+ ```
511
+
512
+ Omit `developerName` for an app with no credit line to show. Pass `sections` for extra titled blocks after the description — e.g. a medical/legal disclaimer:
513
+
514
+ ```tsx
515
+ <AboutSettingsScreen
516
+ appName="GlucoDose"
517
+ version="1.2.0"
518
+ description="Un outil personnel de calcul de dose."
519
+ sections={[
520
+ {
521
+ title: "Avertissement médical",
522
+ paragraphs: ["Cet outil ne remplace pas un avis médical professionnel."],
523
+ },
524
+ ]}
525
+ />
526
+ ```
527
+
528
+ #### `<ContactSettingsScreen email actions? emailIcon? />`
529
+
530
+ A `mailto:` button plus a short "how to report a bug" hint.
531
+
532
+ ```tsx
533
+ import { ContactSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/ContactSettingsScreen";
534
+
535
+ <ContactSettingsScreen email="you@example.com" />;
536
+ ```
537
+
538
+ Pass `actions` for extra rows below the email one (share the app, a donation link...) — switches the whole section from the single bordered button to a rows layout, email included:
539
+
540
+ ```tsx
541
+ <ContactSettingsScreen
542
+ email="you@example.com"
543
+ actions={[
544
+ { icon: "share-outline", label: "Partager l'app", onPress: shareApp },
545
+ { icon: "star-outline", label: "Noter l'app", onPress: openStoreListing },
546
+ ]}
547
+ />
548
+ ```
549
+
550
+ #### `<PrivacySettingsScreen sections? />`
551
+
552
+ Ships with the standard "everything stays on this device" three sections (no data collected, local storage only, sharing is opt-in) as the default — the right fit for most of these small personal-data apps. Pass your own `sections` to fully replace it for an app with different data handling (e.g. one that syncs to a server).
553
+
554
+ ```tsx
555
+ import { PrivacySettingsScreen } from "@forthtilliath/react-native-kit/components/settings/PrivacySettingsScreen";
556
+
557
+ // Zero-config, uses the default copy:
558
+ <PrivacySettingsScreen />;
559
+
560
+ // Or fully custom:
561
+ <PrivacySettingsScreen
562
+ sections={[
563
+ {
564
+ title: "What we collect",
565
+ paragraphs: ["Only what you enter, synced to your account."],
566
+ },
567
+ ]}
568
+ />;
569
+ ```
570
+
571
+ For all 7 components above, styling works the same way as `ChangelogNotes`: an optional `styles` prop (all fields optional, neutral defaults), and where relevant an optional `labels` prop for the built-in French copy.
572
+
573
+ ### Utils (`utils/helpers/`)
574
+
575
+ `confirmDestructive` is the only remaining util here — it wraps React Native's `Alert`, so it isn't framework-agnostic (see its own section above).
576
+
577
+ The framework-agnostic helpers that used to live here (`getPeriodStartMs`, `getMostRecentIds`, `nextInCycle`, `normalizeForSearch`, `rankByNameMatch`, `escapeCsvField`, `formatCsvNumber`, `escapeHtml`) moved to [`@forthtilliath/ts-kit`](../ts-kit#readme) — no React Native dependency on them, so they're usable from Node/web too. They're still re-exported from this package's root barrel for convenience:
578
+
579
+ ```ts
580
+ import {
581
+ getPeriodStartMs,
582
+ getMostRecentIds,
583
+ } from "@forthtilliath/react-native-kit";
584
+ ```
585
+
586
+ Prefer importing straight from `ts-kit` (deep import, see its README) if you don't otherwise need this package.
587
+
588
+ ## Scripts
589
+
590
+ ```bash
591
+ pnpm run dev # tsc --watch -> dist/
592
+ pnpm run build # tsc -> dist/
593
+ pnpm run check-types # tsc --noEmit
594
+ pnpm run lint # eslint
595
+ pnpm run test # vitest run
596
+ pnpm run test:watch # vitest
597
+ ```
598
+
599
+ Built to `dist/` (see the `exports` field in `package.json`), so run `pnpm run build` (or `dev`) after source changes for consumers to see them.
600
+
601
+ ### Testing note
602
+
603
+ `react-native`'s package entry uses Flow syntax that `@babel/parser`'s flow plugin can't parse (`as Cast` casts aren't supported there), so Vitest can never load the real package directly. Tests alias `react-native` to a minimal stub (`src/__mocks__/react-native.tsx`) instead — see that file for details. `react-native-gesture-handler` ships the same kind of Flow syntax, and `@expo/vector-icons` has its own unrelated ESM-resolution issue under Vite/Rollup — both are stubbed the same way (`src/__mocks__/react-native-gesture-handler.tsx`, `src/__mocks__/expo-vector-icons.tsx`). `expo-speech-recognition` and `expo-image-picker` wrap native modules unavailable under Vitest regardless, so they're mocked with plain spies (`src/__mocks__/expo-speech-recognition.tsx`, `src/__mocks__/expo-image-picker.ts`).