@forthtilliath/react-native-kit 0.12.0 → 0.13.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.
package/README.md CHANGED
@@ -1,604 +1,701 @@
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 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
+ ### `<ColorDot color={...} />`
116
+
117
+ A small round color swatch — the coloured bullet next to a category, a team, a
118
+ tag, or a status. **Renders `null` when `color` is `undefined`**, so a list row
119
+ can bind an optional color field without guarding it at the call site.
120
+
121
+ ```tsx
122
+ import { ColorDot } from "@forthtilliath/react-native-kit/components/list/ColorDot";
123
+
124
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
125
+ <ColorDot color={category.color} />
126
+ <Text>{category.name}</Text>
127
+ </View>;
128
+ ```
129
+
130
+ | Prop | Type | Default | Notes |
131
+ | ------- | ---------------------- | ------- | ----------------------------------------------------------------- |
132
+ | `color` | `string \| undefined` | — | Any RN color string. `undefined` → renders nothing. |
133
+ | `size` | `number` | `10` | Diameter in px; always a full circle (`borderRadius = size / 2`). |
134
+ | `style` | `StyleProp<ViewStyle>` | — | Merged **after** the computed style, so it wins on conflicts. |
135
+
136
+ ```tsx
137
+ // Bigger, with a subtle ring, and some trailing space:
138
+ <ColorDot
139
+ color={team.color}
140
+ size={16}
141
+ style={{ borderWidth: 1, borderColor: "rgba(0,0,0,0.15)", marginRight: 6 }}
142
+ />
143
+ ```
144
+
145
+ ### `<UndoToast message={...} onAction={...} />`
146
+
147
+ A bottom-of-screen snackbar for **undoing an action that already happened** —
148
+ the counterpart to a delete that runs immediately with no confirmation dialog
149
+ (for instance a `SwipeableRow` swipe). Presentational only: it does not time
150
+ itself out, animate, or stack — the host owns the "pending undo" state, the
151
+ commit timer, and any enter/exit animation, which keeps this component free of
152
+ timer or animation-library opinions.
153
+
154
+ ```tsx
155
+ import { UndoToast } from "@forthtilliath/react-native-kit/components/list/UndoToast";
156
+
157
+ {
158
+ recentlyDeleted && (
159
+ <UndoToast
160
+ message={`"${recentlyDeleted.name}" deleted`}
161
+ onAction={restoreLast}
162
+ />
163
+ );
164
+ }
165
+ ```
166
+
167
+ | Prop | Type | Default | Notes |
168
+ | ------------- | ----------------- | -------- | ----------------------------------------------------------------------------------- |
169
+ | `message` | `string` | — | Past tense; single line, truncated with an ellipsis. |
170
+ | `actionLabel` | `string` | `"Undo"` | The tappable label on the right. |
171
+ | `onAction` | `() => void` | — | Reverse the change here. The host then hides the toast. |
172
+ | `styles` | `UndoToastStyles` | — | Per-slot overrides (`toast` / `message` / `action`), each merged after its default. |
173
+
174
+ Full pattern — commit for good after 5s, localized label, re-themed:
175
+
176
+ ```tsx
177
+ const [pending, setPending] = useState<Employee | null>(null);
178
+
179
+ function remove(employee: Employee) {
180
+ setEmployees((list) => list.filter((e) => e.id !== employee.id));
181
+ setPending(employee);
182
+ }
183
+
184
+ useEffect(() => {
185
+ if (!pending) return;
186
+ const id = setTimeout(() => {
187
+ db.deleteEmployee(pending.id); // point of no return
188
+ setPending(null);
189
+ }, 5000);
190
+ return () => clearTimeout(id);
191
+ }, [pending]);
192
+
193
+ {
194
+ pending && (
195
+ <UndoToast
196
+ message={`« ${pending.name} » supprimé`}
197
+ actionLabel="Annuler"
198
+ onAction={() => {
199
+ setEmployees((list) => [...list, pending]);
200
+ setPending(null);
201
+ }}
202
+ styles={{
203
+ toast: { backgroundColor: colors.inverseSurface },
204
+ message: { color: colors.inverseOnSurface },
205
+ action: { color: colors.inversePrimary },
206
+ }}
207
+ />
208
+ );
209
+ }
210
+ ```
211
+
212
+ ### `<VoiceSearchButton onResult={...} />`
213
+
214
+ 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.
215
+
216
+ ```tsx
217
+ import { VoiceSearchButton } from "@forthtilliath/react-native-kit/components/picker/VoiceSearchButton";
218
+
219
+ <VoiceSearchButton onResult={setQuery} lang="en-US" />;
220
+ ```
221
+
222
+ ### `<PhotoPicker photoUri={...} onChange={...} savePhoto={...} photoLabel="..." />`
223
+
224
+ 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.
225
+
226
+ ```tsx
227
+ import { PhotoPicker } from "@forthtilliath/react-native-kit/components/picker/PhotoPicker";
228
+
229
+ <PhotoPicker
230
+ photoUri={container.photoUri}
231
+ onChange={(uri) => setPhotoUri(uri)}
232
+ savePhoto={(sourceUri) => saveContainerPhoto(sourceUri)}
233
+ photoLabel="of the container"
234
+ />;
235
+ ```
236
+
237
+ ### `<PickerModal visible title items onSelect onClose />`
238
+
239
+ Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
240
+
241
+ ```tsx
242
+ import {
243
+ PickerModal,
244
+ type PickerItem,
245
+ } from "@forthtilliath/react-native-kit/components/picker/PickerModal";
246
+
247
+ <PickerModal
248
+ visible={pickerVisible}
249
+ title="Choose a container"
250
+ items={containers.map((c): PickerItem => ({
251
+ id: c.id,
252
+ label: c.name,
253
+ imageUri: c.photoUri,
254
+ }))}
255
+ onSelect={(item) => setContainerId(item.id)}
256
+ onClose={() => setPickerVisible(false)}
257
+ extraActions={[
258
+ {
259
+ label: "Add a new container",
260
+ onPress: () => router.push("/containers/new"),
261
+ },
262
+ ]}
263
+ />;
264
+ ```
265
+
266
+ 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.
267
+
268
+ 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.
269
+
270
+ ### `useSubmitGuard()`
271
+
272
+ 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.
273
+
274
+ ```tsx
275
+ import { useSubmitGuard } from "@forthtilliath/react-native-kit/hooks/useSubmitGuard";
276
+
277
+ const { isSaving, guard } = useSubmitGuard();
278
+
279
+ <Pressable
280
+ disabled={isSaving}
281
+ onPress={() =>
282
+ guard(async () => {
283
+ await save();
284
+ })
285
+ }
286
+ >
287
+ <Text>{isSaving ? "Saving…" : "Save"}</Text>
288
+ </Pressable>;
289
+ ```
290
+
291
+ ### `useDebouncedChange(values, delayMs, callback)`
292
+
293
+ 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.
294
+
295
+ ```tsx
296
+ import { useDebouncedChange } from "@forthtilliath/react-native-kit/hooks/useDebouncedChange";
297
+
298
+ useDebouncedChange([settingsData, itemsData], 5 * 60 * 1000, () => {
299
+ runAutoBackup();
300
+ });
301
+ ```
302
+
303
+ ### `confirmDestructive(title, onConfirm, options?)`
304
+
305
+ Generic destructive-action confirmation (title + message + Cancel/Confirm), for anything irreversible (delete, reset...). Ships with French defaults (`message`, `cancelLabel`, `confirmLabel` all overridable).
306
+
307
+ ```ts
308
+ import { confirmDestructive } from "@forthtilliath/react-native-kit/utils/helpers/confirmDestructive";
309
+
310
+ confirmDestructive("Delete this item?", () => deleteItem(id), {
311
+ message: "This cannot be undone.",
312
+ cancelLabel: "Cancel",
313
+ confirmLabel: "Delete",
314
+ });
315
+ ```
316
+
317
+ ### `useEffectiveColorScheme(preference)`
318
+
319
+ 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.
320
+
321
+ ```tsx
322
+ import { useEffectiveColorScheme } from "@forthtilliath/react-native-kit/hooks/useEffectiveColorScheme";
323
+
324
+ const scheme = useEffectiveColorScheme(themePreference); // "light" | "dark"
325
+ const colors = scheme === "dark" ? darkColors : lightColors;
326
+ ```
327
+
328
+ ### `<ThemeToggle value onChange />`
329
+
330
+ 3-way segmented control for a light/dark/system theme preference.
331
+
332
+ ```tsx
333
+ import { ThemeToggle } from "@forthtilliath/react-native-kit/components/theme/ThemeToggle";
334
+
335
+ <ThemeToggle value={themePreference} onChange={setThemePreference} />;
336
+ ```
337
+
338
+ ### `<ThemeOptionList value onChange />`
339
+
340
+ 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.
341
+
342
+ ```tsx
343
+ import { ThemeOptionList } from "@forthtilliath/react-native-kit/components/theme/ThemeOptionList";
344
+
345
+ <ThemeOptionList value={themePreference} onChange={setThemePreference} />;
346
+ ```
347
+
348
+ ### `useUpdateCheck(options)`
349
+
350
+ 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.
351
+
352
+ ```tsx
353
+ import { useUpdateCheck } from "@forthtilliath/react-native-kit/hooks/useUpdateCheck";
354
+
355
+ const update = useUpdateCheck({
356
+ currentVersion: Constants.expoConfig?.version ?? "0.0.0",
357
+ checkForUpdate: fetchLatestRelease,
358
+ compareVersions,
359
+ getLastCheck: () => ({
360
+ lastCheckedAt: settings?.lastUpdateCheckAt ?? null,
361
+ dismissedVersion: settings?.dismissedUpdateVersion ?? null,
362
+ }),
363
+ onChecked: (lastCheckedAt) =>
364
+ updateSettings({ lastUpdateCheckAt: lastCheckedAt }),
365
+ });
366
+
367
+ if (update.status === "available") {
368
+ // update.release.version / .notes / .apkUrl
369
+ }
370
+ ```
371
+
372
+ ### `<UpdateAvailableBanner version notes onPress onDismiss />`
373
+
374
+ 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.
375
+
376
+ ```tsx
377
+ import { UpdateAvailableBanner } from "@forthtilliath/react-native-kit/components/update/UpdateAvailableBanner";
378
+
379
+ {
380
+ update.status === "available" && (
381
+ <UpdateAvailableBanner
382
+ version={update.release.version}
383
+ notes={update.release.notes}
384
+ onPress={() => router.push("/settings/update")}
385
+ onDismiss={() => {
386
+ dismissUpdateVersion(update.release.version);
387
+ update.dismiss();
388
+ }}
389
+ />
390
+ );
391
+ }
392
+ ```
393
+
394
+ ### Settings screens (`components/settings/`)
395
+
396
+ 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.
397
+
398
+ ```tsx
399
+ // app/(tabs)/settings/index.tsx
400
+ import { router } from "expo-router";
401
+ import { SettingsMenu } from "@forthtilliath/react-native-kit/components/settings/SettingsMenu";
402
+
403
+ export default function SettingsMenuScreen() {
404
+ return (
405
+ <SettingsMenu
406
+ items={[
407
+ // `key` doubles as the lookup into a built-in default icon/label
408
+ // pairing for the 6 sections above — no `emoji`/`icon` needed here.
409
+ {
410
+ key: "backup",
411
+ title: "Sauvegarde",
412
+ hint: "Exporter / importer tes données",
413
+ onPress: () => router.push("/settings/backup"),
414
+ },
415
+ {
416
+ key: "theme",
417
+ title: "Thème",
418
+ hint: "Clair, sombre, ou automatique",
419
+ onPress: () => router.push("/settings/theme"),
420
+ },
421
+ {
422
+ key: "update",
423
+ title: "Mise à jour",
424
+ onPress: () => router.push("/settings/update"),
425
+ },
426
+ {
427
+ key: "about",
428
+ title: "À propos",
429
+ onPress: () => router.push("/settings/about"),
430
+ },
431
+ {
432
+ key: "contact",
433
+ title: "Contact",
434
+ onPress: () => router.push("/settings/contact"),
435
+ },
436
+ {
437
+ key: "privacy",
438
+ title: "Confidentialité",
439
+ onPress: () => router.push("/settings/privacy"),
440
+ },
441
+ // An app-specific section: no default exists for an arbitrary key,
442
+ // so `emoji`/`icon` (and `hint`, if you want one) are yours to set.
443
+ {
444
+ key: "profile",
445
+ emoji: "🙋",
446
+ title: "Mon nom",
447
+ onPress: () => router.push("/settings/profile"),
448
+ },
449
+ ]}
450
+ />
451
+ );
452
+ }
453
+ ```
454
+
455
+ `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.
456
+
457
+ Two independent style knobs, both usable on their own:
458
+
459
+ - **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.
460
+ - **Hints** — set `showHints={false}` to hide every item's `hint` at once, for a denser, icon-only-style menu.
461
+
462
+ ```tsx
463
+ <SettingsMenu
464
+ items={[
465
+ {
466
+ key: "backup",
467
+ title: "Sauvegarde",
468
+ onPress: () => router.push("/settings/backup"),
469
+ },
470
+ ]}
471
+ defaultIconKind="icon"
472
+ showHints={false}
473
+ styles={{
474
+ row: { backgroundColor: colors.primary, borderWidth: 0 },
475
+ iconColor: "#fff",
476
+ title: { color: "#fff" },
477
+ }}
478
+ />
479
+ ```
480
+
481
+ 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:
482
+
483
+ ```tsx
484
+ <SettingsMenu
485
+ groups={[
486
+ {
487
+ title: "Réglages de calcul",
488
+ items: [
489
+ {
490
+ key: "ratio",
491
+ emoji: "🧮",
492
+ title: "Ratio glucidique",
493
+ onPress: () => router.push("/settings/ratio"),
494
+ },
495
+ ],
496
+ },
497
+ {
498
+ // No `title` renders the group with no header at all, e.g. for a
499
+ // leading group of app-specific items above the standard ones.
500
+ items: [
501
+ {
502
+ key: "backup",
503
+ title: "Sauvegarde",
504
+ onPress: () => router.push("/settings/backup"),
505
+ },
506
+ ],
507
+ },
508
+ ]}
509
+ />
510
+ ```
511
+
512
+ #### `<ThemeSettingsScreen value onChange />`
513
+
514
+ 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.
515
+
516
+ ```tsx
517
+ import { ThemeSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/ThemeSettingsScreen";
518
+
519
+ <ThemeSettingsScreen value={themePreference} onChange={setThemePreference} />;
520
+
521
+ // Or, matching an icon-flavored SettingsMenu:
522
+ <ThemeSettingsScreen
523
+ value={themePreference}
524
+ onChange={setThemePreference}
525
+ variant="icon"
526
+ />;
527
+ ```
528
+
529
+ #### `<UpdateSettingsScreen currentVersion checkForUpdate compareVersions downloadAndInstallApk />`
530
+
531
+ 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.
532
+
533
+ ```tsx
534
+ import { UpdateSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/UpdateSettingsScreen";
535
+
536
+ import {
537
+ compareVersions,
538
+ downloadAndInstallApk,
539
+ fetchLatestRelease,
540
+ fetchReleaseHistory,
541
+ } from "@/lib/appUpdate";
542
+
543
+ <UpdateSettingsScreen
544
+ currentVersion={Constants.expoConfig?.version ?? "?"}
545
+ checkForUpdate={fetchLatestRelease}
546
+ compareVersions={compareVersions}
547
+ downloadAndInstallApk={downloadAndInstallApk}
548
+ fetchReleaseHistory={fetchReleaseHistory}
549
+ />;
550
+ ```
551
+
552
+ #### `<BackupSettingsScreen onExport onImport reminder? />`
553
+
554
+ 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.
555
+
556
+ ```tsx
557
+ import { BackupSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/BackupSettingsScreen";
558
+
559
+ <BackupSettingsScreen
560
+ onExport={shareBackup}
561
+ onImport={pickAndImportBackup}
562
+ reminder={{
563
+ enabled: reminderEnabled,
564
+ onToggle: handleToggleReminder,
565
+ intervalDays: BACKUP_REMINDER_INTERVAL_DAYS,
566
+ }}
567
+ />;
568
+ ```
569
+
570
+ Omit `reminder` entirely for an app with no backup-reminder notifications.
571
+
572
+ 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.
573
+
574
+ ```tsx
575
+ <BackupSettingsScreen
576
+ onExport={shareBackup}
577
+ onImport={pickAndImportBackup}
578
+ info={{ label: "Dernière sauvegarde auto", value: lastAutoBackupLabel }}
579
+ layout="sections"
580
+ icons={{ export: "cloud-upload-outline", import: "cloud-download-outline" }}
581
+ />
582
+ ```
583
+
584
+ #### `<AboutSettingsScreen appName version description developerName? sections? />`
585
+
586
+ 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.
587
+
588
+ ```tsx
589
+ import { AboutSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/AboutSettingsScreen";
590
+
591
+ <AboutSettingsScreen
592
+ appName={Constants.expoConfig?.name ?? "My App"}
593
+ version={Constants.expoConfig?.version ?? "?"}
594
+ description="A personal tool to do the thing."
595
+ developerName="Your Name"
596
+ />;
597
+ ```
598
+
599
+ 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:
600
+
601
+ ```tsx
602
+ <AboutSettingsScreen
603
+ appName="GlucoDose"
604
+ version="1.2.0"
605
+ description="Un outil personnel de calcul de dose."
606
+ sections={[
607
+ {
608
+ title: "Avertissement médical",
609
+ paragraphs: ["Cet outil ne remplace pas un avis médical professionnel."],
610
+ },
611
+ ]}
612
+ />
613
+ ```
614
+
615
+ #### `<ContactSettingsScreen email actions? emailIcon? />`
616
+
617
+ A `mailto:` button plus a short "how to report a bug" hint.
618
+
619
+ ```tsx
620
+ import { ContactSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/ContactSettingsScreen";
621
+
622
+ <ContactSettingsScreen email="you@example.com" />;
623
+ ```
624
+
625
+ 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:
626
+
627
+ ```tsx
628
+ <ContactSettingsScreen
629
+ email="you@example.com"
630
+ actions={[
631
+ { icon: "share-outline", label: "Partager l'app", onPress: shareApp },
632
+ { icon: "star-outline", label: "Noter l'app", onPress: openStoreListing },
633
+ ]}
634
+ />
635
+ ```
636
+
637
+ #### `<PrivacySettingsScreen sections? />`
638
+
639
+ 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).
640
+
641
+ ```tsx
642
+ import { PrivacySettingsScreen } from "@forthtilliath/react-native-kit/components/settings/PrivacySettingsScreen";
643
+
644
+ // Zero-config, uses the default copy:
645
+ <PrivacySettingsScreen />;
646
+
647
+ // Or fully custom:
648
+ <PrivacySettingsScreen
649
+ sections={[
650
+ {
651
+ title: "What we collect",
652
+ paragraphs: ["Only what you enter, synced to your account."],
653
+ },
654
+ ]}
655
+ />;
656
+ ```
657
+
658
+ 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.
659
+
660
+ ### Utils (`utils/`)
661
+
662
+ Framework-agnostic pure functions — no React or React Native import, usable from Node/web too.
663
+
664
+ ```ts
665
+ import { getPeriodStartMs } from "@forthtilliath/react-native-kit/utils/helpers/getPeriodStartMs";
666
+ import { getMostRecentIds } from "@forthtilliath/react-native-kit/utils/helpers/getMostRecentIds";
667
+ import { nextInCycle } from "@forthtilliath/react-native-kit/utils/helpers/nextInCycle";
668
+ import { normalizeForSearch } from "@forthtilliath/react-native-kit/utils/helpers/normalizeForSearch";
669
+ import { rankByNameMatch } from "@forthtilliath/react-native-kit/utils/helpers/rankByNameMatch";
670
+ import { escapeCsvField } from "@forthtilliath/react-native-kit/utils/format/escapeCsvField";
671
+ import { formatCsvNumber } from "@forthtilliath/react-native-kit/utils/format/formatCsvNumber";
672
+ import { escapeHtml } from "@forthtilliath/react-native-kit/utils/format/escapeHtml";
673
+ ```
674
+
675
+ | Function | What it does |
676
+ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
677
+ | `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. |
678
+ | `getMostRecentIds(rows, limit?)` | Most recently occurring distinct ids (`{ id, occurredAt }[]`), most recent first, deduplicated, limited (default 5). |
679
+ | `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. |
680
+ | `normalizeForSearch(text)` | Lowercases, trims, strips accents, and expands `œ`/`æ` ligatures (which `normalize("NFD")` alone doesn't decompose) — for accent/case-insensitive search matching. |
681
+ | `rankByNameMatch(items, query, getName)` | Ranks `items` by relevance to `query`: earlier match position first, then shorter name — for a search-as-you-type list. |
682
+ | `escapeCsvField(value)` | Quotes and escapes a CSV field (RFC 4180) only if it contains a `"`, `;`, or newline. |
683
+ | `formatCsvNumber(value, decimals?)` | Formats a number with a comma decimal separator (French-locale spreadsheets) instead of JS's dot. |
684
+ | `escapeHtml(text)` | Basic HTML entity escaping (`&`, `<`, `>`, `"`) for inserting user text into an HTML template. |
685
+
686
+ ## Scripts
687
+
688
+ ```bash
689
+ pnpm run dev # tsc --watch -> dist/
690
+ pnpm run build # tsc -> dist/
691
+ pnpm run check-types # tsc --noEmit
692
+ pnpm run lint # eslint
693
+ pnpm run test # vitest run
694
+ pnpm run test:watch # vitest
695
+ ```
696
+
697
+ 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.
698
+
699
+ ### Testing note
700
+
701
+ `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`).