@forthtilliath/react-native-kit 0.18.0 → 0.19.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 (28) hide show
  1. package/README.md +39 -15
  2. package/dist/components/picker/PhotoPicker.d.ts +16 -29
  3. package/dist/components/picker/PhotoPicker.d.ts.map +1 -1
  4. package/dist/components/picker/PhotoPicker.js +48 -81
  5. package/dist/components/picker/PhotoPicker.styles.d.ts +63 -0
  6. package/dist/components/picker/PhotoPicker.styles.d.ts.map +1 -0
  7. package/dist/components/picker/PhotoPicker.styles.js +59 -0
  8. package/dist/components/picker/QuantityStepper.d.ts +26 -31
  9. package/dist/components/picker/QuantityStepper.d.ts.map +1 -1
  10. package/dist/components/picker/QuantityStepper.js +37 -67
  11. package/dist/components/picker/QuantityStepper.styles.d.ts +59 -0
  12. package/dist/components/picker/QuantityStepper.styles.d.ts.map +1 -0
  13. package/dist/components/picker/QuantityStepper.styles.js +46 -0
  14. package/dist/components/settings/UpdateSettingsScreen.d.ts +5 -1
  15. package/dist/components/settings/UpdateSettingsScreen.d.ts.map +1 -1
  16. package/dist/components/settings/UpdateSettingsScreen.js +3 -3
  17. package/dist/components/settings/UpdateSettingsScreen.styles.d.ts +2 -0
  18. package/dist/components/settings/UpdateSettingsScreen.styles.d.ts.map +1 -1
  19. package/dist/components/theme/BackgroundPicker.d.ts +46 -0
  20. package/dist/components/theme/BackgroundPicker.d.ts.map +1 -0
  21. package/dist/components/theme/BackgroundPicker.js +79 -0
  22. package/dist/components/theme/findBackground.d.ts +20 -0
  23. package/dist/components/theme/findBackground.d.ts.map +1 -0
  24. package/dist/components/theme/findBackground.js +12 -0
  25. package/dist/components/theme/index.d.ts +2 -0
  26. package/dist/components/theme/index.d.ts.map +1 -1
  27. package/dist/components/theme/index.js +2 -0
  28. package/package.json +6 -6
package/README.md CHANGED
@@ -211,6 +211,10 @@ import { PhotoPicker } from "@forthtilliath/react-native-kit/components/picker/P
211
211
  />;
212
212
  ```
213
213
 
214
+ - `renderPreview(photoUri, saving)` replaces the square preview and the "Add a photo" placeholder (e.g. a round avatar with initials and a small camera badge); the press, permission and action sheet stay the same.
215
+ - While `savePhoto` runs, the picker ignores presses (`saving` is `true`, `accessibilityState.busy`).
216
+ - A failure (camera, picker or `savePhoto` rejecting) goes to `onError(error)`; without it, an alert says the photo was not saved (`labels.saveErrorTitle` / `saveErrorMessage`).
217
+
214
218
  ### `<PickerModal visible title items onSelect onClose />`
215
219
 
216
220
  Full-screen picker: search (typed or dictated via `VoiceSearchButton`), optional sections, and "add" actions always visible above the results.
@@ -336,6 +340,26 @@ import { ThemeOptionList } from "@forthtilliath/react-native-kit/components/them
336
340
  <ThemeOptionList value={themePreference} onChange={setThemePreference} />;
337
341
  ```
338
342
 
343
+ ### `<BackgroundPicker options value onChange />` + `findBackground(options, id)`
344
+
345
+ Radio group of background swatches, each drawing the page and a card in the colours on offer (`{ id, label, page, card, cardBorder? }`), the chosen one outlined with a check mark. `onChange` receives the whole option (extra fields kept: the full palette to apply). `findBackground` reads a saved id back, falling back to the first option (the original background) for an unknown id. Check each palette's text contrast in a test with `contrastRatio` from `@forthtilliath/ts-kit/color/contrastRatio`.
346
+
347
+ ```tsx
348
+ import { BackgroundPicker } from "@forthtilliath/react-native-kit/components/theme/BackgroundPicker";
349
+ import { findBackground } from "@forthtilliath/react-native-kit/components/theme/findBackground";
350
+
351
+ const background = findBackground(DARK_BACKGROUNDS, settings.background);
352
+
353
+ <BackgroundPicker
354
+ options={DARK_BACKGROUNDS}
355
+ value={background.id}
356
+ onChange={(option) => {
357
+ selectFeedback();
358
+ saveBackground(option.id);
359
+ }}
360
+ />;
361
+ ```
362
+
339
363
  ### `<ThemePreferenceProvider load save>` + `useThemePreference()` / `useResolvedScheme()`
340
364
 
341
365
  Holds the light/dark/system preference in a context: loaded once on mount through `load`, changed from anywhere with `useThemePreference().setPreference`, persisted through `save`. Storage-agnostic. A choice made before `load` resolves isn't overwritten by the stored value. `useResolvedScheme()` returns the scheme actually applied (`"light" | "dark"`), to pick the palette.
@@ -358,20 +382,20 @@ const colors = Colors[useResolvedScheme()];
358
382
 
359
383
  Every component below has neutral default colors and a `styles` prop whose slots are each merged **after** their default (a partial override keeps the rest of the slot) — pass the app's palette there.
360
384
 
361
- | Component | Import path | What it is |
362
- | ----------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
363
- | `<BottomSheet visible onClose bottomInset?>` | `components/overlay/BottomSheet` | Modal sliding up from the bottom, content scrollable past 85 % of the screen. Pass `useSafeAreaInsets().bottom` as `bottomInset` with Android edge-to-edge. |
364
- | `<OptionsModal visible onClose options onSelect>` | `components/picker/OptionsModal` | Centered popup listing `{ value, label }` options; a tap selects and closes. |
365
- | `<SelectField label valueLabel options onSelect>` | `components/picker/SelectField` | Native-looking "select": captioned button + `OptionsModal` (styled through `styles.optionsModal`). |
366
- | `<SegmentedToggle options value onChange label?>` | `components/picker/SegmentedToggle` | Row of equal-width buttons, one active (Calendar / List…), announced as a radio group; each option may carry a smaller `hint` line under its label. |
367
- | `<QuantityStepper value onChange label min? max?>` | `components/picker/QuantityStepper` | Whole-number counter `min…max` (`0…999`, negatives allowed with a negative `min`): −/+ buttons (disabled at the bounds, `longPressStep` to jump on long press, or `repeatOnHold` to repeat faster and faster while held) and a numeric field; screen-reader labels in French or English (`a11yLabels` to override). |
368
- | `<SwitchRow label description? value onChange>` | `components/settings/SwitchRow` | Settings line "label + description + switch", the whole row pressable and read as one switch; `colors` for the track and thumb. |
369
- | `<SettingsCard title icon?>` + `SettingsCardStatus`, `SettingsCardHint` | `components/settings/SettingsCard` | Settings card: icon + title header, then free content — status lines (small icon + text, `iconColor` per state) and a quieter hint. |
370
- | `<CheckboxGroup options values onChange label?>` | `components/picker/CheckboxGroup` | Wrapping chips that each check and uncheck independently (extensions, tags…), each announced as a checkbox. |
371
- | `<MonthDayGrid days selected onToggle>` | `components/picker/MonthDayGrid` | Monday-first month grid whose days toggle on and off (holidays, days off…); `YYYY-MM-DD` days. |
372
- | `<AddButton label onPress>` | `components/list/AddButton` | Dashed "+ Add …" button closing a list. |
373
- | `<DragHandle onPressIn accessibilityLabel>` | `components/list/DragHandle` | "⠿" grip for `react-native-draggable-flatlist` rows (gesture-handler's `Pressable`). |
374
- | `<ErrorFallback error retry>` | `components/feedback/ErrorFallback` | Readable crash screen with a retry button, for Expo Router's `ErrorBoundary`; texts in French or English (`labels` to override). |
385
+ | Component | Import path | What it is |
386
+ | ----------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
387
+ | `<BottomSheet visible onClose bottomInset?>` | `components/overlay/BottomSheet` | Modal sliding up from the bottom, content scrollable past 85 % of the screen. Pass `useSafeAreaInsets().bottom` as `bottomInset` with Android edge-to-edge. |
388
+ | `<OptionsModal visible onClose options onSelect>` | `components/picker/OptionsModal` | Centered popup listing `{ value, label }` options; a tap selects and closes. |
389
+ | `<SelectField label valueLabel options onSelect>` | `components/picker/SelectField` | Native-looking "select": captioned button + `OptionsModal` (styled through `styles.optionsModal`). |
390
+ | `<SegmentedToggle options value onChange label?>` | `components/picker/SegmentedToggle` | Row of equal-width buttons, one active (Calendar / List…), announced as a radio group; each option may carry a smaller `hint` line under its label. |
391
+ | `<QuantityStepper value onChange label min? max?>` | `components/picker/QuantityStepper` | Whole-number counter `min…max` (`0…999`, negatives allowed with a negative `min`, `-Infinity`/`Infinity` for no bound): −/+ buttons moving by `step` (1; disabled at the bounds, `longPressStep` to jump on long press, or `repeatOnHold` to repeat faster and faster while held) and a numeric field; `onStep(value)` fires on each −/+ change (e.g. `onStep={tapFeedback}`), not on typing; screen-reader labels in French or English (`a11yLabels` to override). |
392
+ | `<SwitchRow label description? value onChange>` | `components/settings/SwitchRow` | Settings line "label + description + switch", the whole row pressable and read as one switch; `colors` for the track and thumb. |
393
+ | `<SettingsCard title icon?>` + `SettingsCardStatus`, `SettingsCardHint` | `components/settings/SettingsCard` | Settings card: icon + title header, then free content — status lines (small icon + text, `iconColor` per state) and a quieter hint. |
394
+ | `<CheckboxGroup options values onChange label?>` | `components/picker/CheckboxGroup` | Wrapping chips that each check and uncheck independently (extensions, tags…), each announced as a checkbox. |
395
+ | `<MonthDayGrid days selected onToggle>` | `components/picker/MonthDayGrid` | Monday-first month grid whose days toggle on and off (holidays, days off…); `YYYY-MM-DD` days. |
396
+ | `<AddButton label onPress>` | `components/list/AddButton` | Dashed "+ Add …" button closing a list. |
397
+ | `<DragHandle onPressIn accessibilityLabel>` | `components/list/DragHandle` | "⠿" grip for `react-native-draggable-flatlist` rows (gesture-handler's `Pressable`). |
398
+ | `<ErrorFallback error retry>` | `components/feedback/ErrorFallback` | Readable crash screen with a retry button, for Expo Router's `ErrorBoundary`; texts in French or English (`labels` to override). |
375
399
 
376
400
  `SwipeableRow` also takes `fullSwipeToDelete`: swiping past half the row deletes right away (the row slides out over a full-width delete band) instead of revealing a button — pair it with an `UndoToast`.
377
401
 
@@ -516,7 +540,7 @@ import { ThemeSettingsScreen } from "@forthtilliath/react-native-kit/components/
516
540
 
517
541
  #### `<UpdateSettingsScreen currentVersion checkForUpdate compareVersions downloadAndInstallApk />`
518
542
 
519
- 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. `@forthtilliath/expo-release-updates-ui` is an optional peer dependency: install it to use this screen.
543
+ 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. `@forthtilliath/expo-release-updates-ui` is an optional peer dependency: install it to use this screen. `downloadAndInstallApk(apkUrl, onProgress, apkSha256)` receives the release's `apkSha256` (from `fetchLatestRelease`): pass it on as `expectedSha256`.
520
544
 
521
545
  ```tsx
522
546
  import { UpdateSettingsScreen } from "@forthtilliath/react-native-kit/components/settings/UpdateSettingsScreen";
@@ -1,38 +1,25 @@
1
- import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
1
+ import { type ReactNode } from "react";
2
2
  import { type KitLocale } from "../../i18n/locale.js";
3
- export interface PhotoPickerStyles {
4
- photoPicker?: StyleProp<ViewStyle>;
5
- photoPreview?: StyleProp<ImageStyle>;
6
- photoPlaceholder?: StyleProp<ViewStyle>;
7
- photoPlaceholderText?: StyleProp<TextStyle>;
8
- clearLink?: StyleProp<TextStyle>;
9
- iconColor?: string;
10
- }
11
- export interface PhotoPickerLabels {
12
- photoTitle?: (photoLabel: string) => string;
13
- editAccessibilityLabel?: (photoLabel: string) => string;
14
- addAccessibilityLabel?: (photoLabel: string) => string;
15
- addPlaceholderText?: string;
16
- cameraPermissionTitle?: string;
17
- cameraPermissionMessage?: string;
18
- /**
19
- * @deprecated Unused: choosing from the library needs no permission (the
20
- * system photo picker), so no "access denied" alert is ever shown.
21
- */
22
- libraryPermissionTitle?: string;
23
- /** @deprecated Unused — see `libraryPermissionTitle`. */
24
- libraryPermissionMessage?: string;
25
- takePhoto?: string;
26
- chooseFromLibrary?: string;
27
- cancel?: string;
28
- removePhoto?: string;
29
- }
3
+ import { type PhotoPickerLabels, type PhotoPickerStyles } from "./PhotoPicker.styles.js";
4
+ export type { PhotoPickerLabels, PhotoPickerStyles, } from "./PhotoPicker.styles.js";
30
5
  export interface PhotoPickerProps {
31
6
  photoUri: string | null;
32
7
  onChange: (uri: string | null) => void;
33
8
  savePhoto: (sourceUri: string) => Promise<string>;
34
9
  /** Used as-is in titles/accessibility labels, e.g. "of the container". */
35
10
  photoLabel: string;
11
+ /**
12
+ * Replaces the square preview and the "Add a photo" placeholder, e.g. a
13
+ * round avatar with a small camera badge. `saving` is `true` while
14
+ * `savePhoto` runs (the picker ignores presses meanwhile).
15
+ */
16
+ renderPreview?: (photoUri: string | null, saving: boolean) => ReactNode;
17
+ /**
18
+ * Called when taking, picking or saving the photo fails (`savePhoto`
19
+ * rejected…). Unset, an alert says the photo was not saved
20
+ * (`labels.saveErrorTitle` / `saveErrorMessage`).
21
+ */
22
+ onError?: (error: unknown) => void;
36
23
  labels?: PhotoPickerLabels;
37
24
  /**
38
25
  * Language of the built-in labels — French by default, or the nearest
@@ -41,5 +28,5 @@ export interface PhotoPickerProps {
41
28
  locale?: KitLocale;
42
29
  styles?: PhotoPickerStyles;
43
30
  }
44
- export declare function PhotoPicker({ photoUri, onChange, savePhoto, photoLabel, labels, locale, styles, }: PhotoPickerProps): import("react").JSX.Element;
31
+ export declare function PhotoPicker({ photoUri, onChange, savePhoto, photoLabel, renderPreview, onError, labels, locale, styles, }: PhotoPickerProps): import("react").JSX.Element;
45
32
  //# sourceMappingURL=PhotoPicker.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"PhotoPicker.d.ts","sourceRoot":"","sources":["../../../src/components/picker/PhotoPicker.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAKhF,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAGpE,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,YAAY,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,sBAAsB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,qBAAqB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,yDAAyD;IACzD,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvC,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAyED,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,MAAM,EACN,MAAM,EACN,MAAM,GACP,EAAE,gBAAgB,+BAqGlB"}
1
+ {"version":3,"file":"PhotoPicker.d.ts","sourceRoot":"","sources":["../../../src/components/picker/PhotoPicker.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAoB,MAAM,OAAO,CAAC;AAKzD,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAGpE,OAAO,EAGL,KAAK,iBAAiB,EACtB,KAAK,iBAAiB,EACvB,MAAM,yBAAyB,CAAC;AAEjC,YAAY,EACV,iBAAiB,EACjB,iBAAiB,GAClB,MAAM,yBAAyB,CAAC;AAEjC,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;IACvC,SAAS,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAClD,0EAA0E;IAC1E,UAAU,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,EAAE,MAAM,EAAE,OAAO,KAAK,SAAS,CAAC;IACxE;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AAUD,wBAAgB,WAAW,CAAC,EAC1B,QAAQ,EACR,QAAQ,EACR,SAAS,EACT,UAAU,EACV,aAAa,EACb,OAAO,EACP,MAAM,EACN,MAAM,EACN,MAAM,GACP,EAAE,gBAAgB,+BAmHlB"}
@@ -1,121 +1,88 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useState } from "react";
2
3
  import { Alert, Image, Pressable, Text, View } from "react-native";
3
4
  import { Ionicons } from "@expo/vector-icons";
4
5
  import * as ImagePicker from "expo-image-picker";
5
6
  import { useKitLocale } from "../../i18n/locale.js";
6
7
  import { mergeSlotStyles } from "../../utils/helpers/mergeSlotStyles.js";
7
- const defaultStyles = {
8
- photoPicker: { alignSelf: "center", marginTop: 8 },
9
- photoPreview: {
10
- width: 140,
11
- height: 140,
12
- borderRadius: 16,
13
- backgroundColor: "#f9fafb",
14
- },
15
- photoPlaceholder: {
16
- width: 140,
17
- height: 140,
18
- borderRadius: 16,
19
- borderWidth: 1,
20
- borderStyle: "dashed",
21
- borderColor: "#e5e7eb",
22
- backgroundColor: "#f9fafb",
23
- alignItems: "center",
24
- justifyContent: "center",
25
- gap: 6,
26
- },
27
- photoPlaceholderText: { fontSize: 12, color: "#6b7280" },
28
- clearLink: {
29
- color: "#2563eb",
30
- fontSize: 13,
31
- textAlign: "center",
32
- marginTop: 8,
33
- },
34
- iconColor: "#6b7280",
35
- };
36
- const defaultLabels = {
37
- fr: {
38
- photoTitle: (photoLabel) => `Photo ${photoLabel}`,
39
- editAccessibilityLabel: (photoLabel) => `Photo ${photoLabel}. Modifier.`,
40
- addAccessibilityLabel: (photoLabel) => `Ajouter une photo ${photoLabel}`,
41
- addPlaceholderText: "Ajouter une photo",
42
- cameraPermissionTitle: "Accès à l'appareil photo refusé",
43
- cameraPermissionMessage: "Autorise l'accès dans les réglages du téléphone pour prendre une photo.",
44
- takePhoto: "Prendre une photo",
45
- chooseFromLibrary: "Choisir dans la galerie",
46
- cancel: "Annuler",
47
- removePhoto: "Retirer la photo",
48
- },
49
- en: {
50
- photoTitle: (photoLabel) => `Photo ${photoLabel}`,
51
- editAccessibilityLabel: (photoLabel) => `Photo ${photoLabel}. Edit.`,
52
- addAccessibilityLabel: (photoLabel) => `Add a photo ${photoLabel}`,
53
- addPlaceholderText: "Add a photo",
54
- cameraPermissionTitle: "Camera access denied",
55
- cameraPermissionMessage: "Allow access in the phone's settings to take a photo.",
56
- takePhoto: "Take a photo",
57
- chooseFromLibrary: "Choose from library",
58
- cancel: "Cancel",
59
- removePhoto: "Remove photo",
60
- },
8
+ import { defaultLabels, defaultStyles, } from "./PhotoPicker.styles.js";
9
+ const PICKER_OPTIONS = {
10
+ mediaTypes: "images",
11
+ quality: 0.5,
12
+ allowsEditing: true,
13
+ aspect: [1, 1],
61
14
  };
62
15
  // Photo picker (camera or library) with a preview.
63
- export function PhotoPicker({ photoUri, onChange, savePhoto, photoLabel, labels, locale, styles, }) {
16
+ export function PhotoPicker({ photoUri, onChange, savePhoto, photoLabel, renderPreview, onError, labels, locale, styles, }) {
64
17
  const mergedStyles = {
65
18
  ...mergeSlotStyles(defaultStyles, styles),
66
19
  iconColor: styles?.iconColor ?? defaultStyles.iconColor,
67
20
  };
68
21
  const t = { ...defaultLabels[useKitLocale(locale)], ...labels };
69
- async function pickFromCamera() {
22
+ const [saving, setSaving] = useState(false);
23
+ // Ahead of `saving`, which only changes on the next render: a second press
24
+ // in the same frame must not start a second save.
25
+ const busyRef = useRef(false);
26
+ async function pick(launch) {
27
+ if (busyRef.current)
28
+ return;
29
+ busyRef.current = true;
30
+ try {
31
+ const result = await launch();
32
+ const asset = result && !result.canceled ? result.assets[0] : undefined;
33
+ if (!asset)
34
+ return;
35
+ setSaving(true);
36
+ onChange(await savePhoto(asset.uri));
37
+ }
38
+ catch (error) {
39
+ if (onError)
40
+ onError(error);
41
+ else
42
+ Alert.alert(t.saveErrorTitle, t.saveErrorMessage);
43
+ }
44
+ finally {
45
+ busyRef.current = false;
46
+ setSaving(false);
47
+ }
48
+ }
49
+ async function launchCamera() {
70
50
  const permission = await ImagePicker.requestCameraPermissionsAsync();
71
51
  if (!permission.granted) {
72
52
  Alert.alert(t.cameraPermissionTitle, t.cameraPermissionMessage);
73
- return;
74
- }
75
- const result = await ImagePicker.launchCameraAsync({
76
- mediaTypes: "images",
77
- quality: 0.5,
78
- allowsEditing: true,
79
- aspect: [1, 1],
80
- });
81
- if (!result.canceled && result.assets[0]) {
82
- onChange(await savePhoto(result.assets[0].uri));
53
+ return null;
83
54
  }
55
+ return ImagePicker.launchCameraAsync(PICKER_OPTIONS);
84
56
  }
85
57
  // No permission request: the library is opened through the system photo
86
58
  // picker, which needs none — so apps don't have to keep the storage
87
59
  // permissions just for this.
88
- async function pickFromLibrary() {
89
- const result = await ImagePicker.launchImageLibraryAsync({
90
- mediaTypes: "images",
91
- quality: 0.5,
92
- allowsEditing: true,
93
- aspect: [1, 1],
94
- });
95
- if (!result.canceled && result.assets[0]) {
96
- onChange(await savePhoto(result.assets[0].uri));
97
- }
60
+ function launchLibrary() {
61
+ return ImagePicker.launchImageLibraryAsync(PICKER_OPTIONS);
98
62
  }
99
63
  function handlePickPhoto() {
64
+ if (busyRef.current)
65
+ return;
100
66
  Alert.alert(t.photoTitle(photoLabel), undefined, [
101
67
  {
102
68
  text: t.takePhoto,
103
69
  onPress: () => {
104
- void pickFromCamera();
70
+ void pick(launchCamera);
105
71
  },
106
72
  },
107
73
  {
108
74
  text: t.chooseFromLibrary,
109
75
  onPress: () => {
110
- void pickFromLibrary();
76
+ void pick(launchLibrary);
111
77
  },
112
78
  },
113
79
  { text: t.cancel, style: "cancel" },
114
80
  ]);
115
81
  }
116
- return (_jsxs(_Fragment, { children: [_jsx(Pressable, { style: mergedStyles.photoPicker, onPress: handlePickPhoto, accessibilityRole: "button", accessibilityLabel: photoUri
82
+ const defaultPreview = photoUri ? (_jsx(Image, { source: { uri: photoUri }, style: mergedStyles.photoPreview, accessibilityIgnoresInvertColors: true })) : (_jsxs(View, { style: mergedStyles.photoPlaceholder, children: [_jsx(Ionicons, { name: "camera-outline", size: 28, color: mergedStyles.iconColor }), _jsx(Text, { style: mergedStyles.photoPlaceholderText, children: t.addPlaceholderText })] }));
83
+ return (_jsxs(_Fragment, { children: [_jsx(Pressable, { style: mergedStyles.photoPicker, onPress: handlePickPhoto, disabled: saving, accessibilityRole: "button", accessibilityState: { busy: saving, disabled: saving }, accessibilityLabel: photoUri
117
84
  ? t.editAccessibilityLabel(photoLabel)
118
- : t.addAccessibilityLabel(photoLabel), children: photoUri ? (_jsx(Image, { source: { uri: photoUri }, style: mergedStyles.photoPreview, accessibilityIgnoresInvertColors: true })) : (_jsxs(View, { style: mergedStyles.photoPlaceholder, children: [_jsx(Ionicons, { name: "camera-outline", size: 28, color: mergedStyles.iconColor }), _jsx(Text, { style: mergedStyles.photoPlaceholderText, children: t.addPlaceholderText })] })) }), photoUri ? (_jsx(Pressable, { onPress: () => {
85
+ : t.addAccessibilityLabel(photoLabel), children: renderPreview ? renderPreview(photoUri, saving) : defaultPreview }), photoUri ? (_jsx(Pressable, { onPress: () => {
119
86
  onChange(null);
120
- }, accessibilityRole: "button", accessibilityLabel: t.removePhoto, children: _jsx(Text, { style: mergedStyles.clearLink, children: t.removePhoto }) })) : null] }));
87
+ }, disabled: saving, accessibilityRole: "button", accessibilityLabel: t.removePhoto, children: _jsx(Text, { style: mergedStyles.clearLink, children: t.removePhoto }) })) : null] }));
121
88
  }
@@ -0,0 +1,63 @@
1
+ import type { ImageStyle, StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ export interface PhotoPickerStyles {
3
+ photoPicker?: StyleProp<ViewStyle>;
4
+ photoPreview?: StyleProp<ImageStyle>;
5
+ photoPlaceholder?: StyleProp<ViewStyle>;
6
+ photoPlaceholderText?: StyleProp<TextStyle>;
7
+ clearLink?: StyleProp<TextStyle>;
8
+ iconColor?: string;
9
+ }
10
+ export interface PhotoPickerLabels {
11
+ photoTitle?: (photoLabel: string) => string;
12
+ editAccessibilityLabel?: (photoLabel: string) => string;
13
+ addAccessibilityLabel?: (photoLabel: string) => string;
14
+ addPlaceholderText?: string;
15
+ cameraPermissionTitle?: string;
16
+ cameraPermissionMessage?: string;
17
+ /**
18
+ * @deprecated Unused: choosing from the library needs no permission (the
19
+ * system photo picker), so no "access denied" alert is ever shown.
20
+ */
21
+ libraryPermissionTitle?: string;
22
+ /** @deprecated Unused — see `libraryPermissionTitle`. */
23
+ libraryPermissionMessage?: string;
24
+ takePhoto?: string;
25
+ chooseFromLibrary?: string;
26
+ cancel?: string;
27
+ removePhoto?: string;
28
+ /** Alert shown when the photo could not be taken or saved (no `onError`). */
29
+ saveErrorTitle?: string;
30
+ saveErrorMessage?: string;
31
+ }
32
+ export declare const defaultStyles: Required<PhotoPickerStyles>;
33
+ export declare const defaultLabels: {
34
+ fr: {
35
+ photoTitle: (photoLabel: string) => string;
36
+ editAccessibilityLabel: (photoLabel: string) => string;
37
+ addAccessibilityLabel: (photoLabel: string) => string;
38
+ addPlaceholderText: string;
39
+ cameraPermissionTitle: string;
40
+ cameraPermissionMessage: string;
41
+ takePhoto: string;
42
+ chooseFromLibrary: string;
43
+ cancel: string;
44
+ removePhoto: string;
45
+ saveErrorTitle: string;
46
+ saveErrorMessage: string;
47
+ };
48
+ en: {
49
+ photoTitle: (photoLabel: string) => string;
50
+ editAccessibilityLabel: (photoLabel: string) => string;
51
+ addAccessibilityLabel: (photoLabel: string) => string;
52
+ addPlaceholderText: string;
53
+ cameraPermissionTitle: string;
54
+ cameraPermissionMessage: string;
55
+ takePhoto: string;
56
+ chooseFromLibrary: string;
57
+ cancel: string;
58
+ removePhoto: string;
59
+ saveErrorTitle: string;
60
+ saveErrorMessage: string;
61
+ };
62
+ };
63
+ //# sourceMappingURL=PhotoPicker.styles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PhotoPicker.styles.d.ts","sourceRoot":"","sources":["../../../src/components/picker/PhotoPicker.styles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAIhF,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,YAAY,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,CAAC;IACrC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,sBAAsB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACxD,qBAAqB,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAC;IACvD,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC;;;OAGG;IACH,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,yDAAyD;IACzD,wBAAwB,CAAC,EAAE,MAAM,CAAC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,eAAO,MAAM,aAAa,EAAE,QAAQ,CAAC,iBAAiB,CA4BrD,CAAC;AAEF,eAAO,MAAM,aAAa;;iCAEG,MAAM;6CACM,MAAM;4CAEP,MAAM;;;;;;;;;;;;iCAcjB,MAAM;6CACM,MAAM;4CAEP,MAAM;;;;;;;;;;;CAoB7C,CAAC"}
@@ -0,0 +1,59 @@
1
+ export const defaultStyles = {
2
+ photoPicker: { alignSelf: "center", marginTop: 8 },
3
+ photoPreview: {
4
+ width: 140,
5
+ height: 140,
6
+ borderRadius: 16,
7
+ backgroundColor: "#f9fafb",
8
+ },
9
+ photoPlaceholder: {
10
+ width: 140,
11
+ height: 140,
12
+ borderRadius: 16,
13
+ borderWidth: 1,
14
+ borderStyle: "dashed",
15
+ borderColor: "#e5e7eb",
16
+ backgroundColor: "#f9fafb",
17
+ alignItems: "center",
18
+ justifyContent: "center",
19
+ gap: 6,
20
+ },
21
+ photoPlaceholderText: { fontSize: 12, color: "#6b7280" },
22
+ clearLink: {
23
+ color: "#2563eb",
24
+ fontSize: 13,
25
+ textAlign: "center",
26
+ marginTop: 8,
27
+ },
28
+ iconColor: "#6b7280",
29
+ };
30
+ export const defaultLabels = {
31
+ fr: {
32
+ photoTitle: (photoLabel) => `Photo ${photoLabel}`,
33
+ editAccessibilityLabel: (photoLabel) => `Photo ${photoLabel}. Modifier.`,
34
+ addAccessibilityLabel: (photoLabel) => `Ajouter une photo ${photoLabel}`,
35
+ addPlaceholderText: "Ajouter une photo",
36
+ cameraPermissionTitle: "Accès à l'appareil photo refusé",
37
+ cameraPermissionMessage: "Autorise l'accès dans les réglages du téléphone pour prendre une photo.",
38
+ takePhoto: "Prendre une photo",
39
+ chooseFromLibrary: "Choisir dans la galerie",
40
+ cancel: "Annuler",
41
+ removePhoto: "Retirer la photo",
42
+ saveErrorTitle: "Photo non enregistrée",
43
+ saveErrorMessage: "La photo n'a pas pu être enregistrée. Réessaie.",
44
+ },
45
+ en: {
46
+ photoTitle: (photoLabel) => `Photo ${photoLabel}`,
47
+ editAccessibilityLabel: (photoLabel) => `Photo ${photoLabel}. Edit.`,
48
+ addAccessibilityLabel: (photoLabel) => `Add a photo ${photoLabel}`,
49
+ addPlaceholderText: "Add a photo",
50
+ cameraPermissionTitle: "Camera access denied",
51
+ cameraPermissionMessage: "Allow access in the phone's settings to take a photo.",
52
+ takePhoto: "Take a photo",
53
+ chooseFromLibrary: "Choose from library",
54
+ cancel: "Cancel",
55
+ removePhoto: "Remove photo",
56
+ saveErrorTitle: "Photo not saved",
57
+ saveErrorMessage: "The photo could not be saved. Try again.",
58
+ },
59
+ };
@@ -1,24 +1,6 @@
1
- import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
1
  import { type KitLocale } from "../../i18n/locale.js";
3
- /** Per-slot style overrides for {@link QuantityStepper}, each merged after its default. */
4
- export interface QuantityStepperStyles {
5
- /** The row: − button, field, + button. */
6
- row?: StyleProp<ViewStyle>;
7
- /** Both round buttons. */
8
- button?: StyleProp<ViewStyle>;
9
- /** Added on top of `button` for "−" at `min` and "+" at `max`. */
10
- buttonDisabled?: StyleProp<ViewStyle>;
11
- /** The "−" / "+" glyphs. */
12
- buttonText?: StyleProp<TextStyle>;
13
- /** The numeric field in the middle (its text color included). */
14
- input?: StyleProp<TextStyle>;
15
- }
16
- /** Screen-reader labels of the three controls, built from the stepper's `label`. */
17
- export interface QuantityStepperA11yLabels {
18
- decrease: (label: string) => string;
19
- increase: (label: string) => string;
20
- input: (label: string) => string;
21
- }
2
+ import { type QuantityStepperA11yLabels, type QuantityStepperStyles } from "./QuantityStepper.styles.js";
3
+ export type { QuantityStepperA11yLabels, QuantityStepperStyles, } from "./QuantityStepper.styles.js";
22
4
  export interface QuantityStepperProps {
23
5
  value: number;
24
6
  /** Receives the new value, already clamped to `min…max`. */
@@ -27,12 +9,24 @@ export interface QuantityStepperProps {
27
9
  label: string;
28
10
  /**
29
11
  * Below 0, negative values are allowed: the field then accepts a leading
30
- * "-" (and opens the keyboard that has one).
12
+ * "-" (and opens the keyboard that has one). `-Infinity`: no lower bound.
31
13
  * @defaultValue 0
32
14
  */
33
15
  min?: number;
34
- /** @defaultValue 999 */
16
+ /** `Infinity`: no upper bound. @defaultValue 999 */
35
17
  max?: number;
18
+ /**
19
+ * How much −/+ (and each repeat while held) move the value, e.g. `4` for a
20
+ * category worth 4 points a unit. Typing in the field is not snapped to it.
21
+ * @defaultValue 1
22
+ */
23
+ step?: number;
24
+ /**
25
+ * Called with the new value each time −/+ change it (tap, long press, each
26
+ * repeat while held) — not on typing. The place for a haptic tick:
27
+ * `onStep={tapFeedback}` (from `utils/helpers/haptics`).
28
+ */
29
+ onStep?: (value: number) => void;
36
30
  /**
37
31
  * Step applied by a long press on −/+, to reach large values quickly.
38
32
  * Unset, a long press does nothing more than a tap. Ignored with
@@ -56,13 +50,14 @@ export interface QuantityStepperProps {
56
50
  styles?: QuantityStepperStyles;
57
51
  }
58
52
  /**
59
- * A whole-number counter from `min` (0) to `max`: −/+ buttons to adjust
60
- * (optionally faster on long press, or repeating while held), and a numeric
61
- * field in the middle to type a large number in one go (other characters are
62
- * dropped, the result is clamped). A typed value that is not a number yet or
63
- * below `min` (an empty field, a lone "-", the "2" of "25" with `min={10}`)
64
- * stays as typed until the field loses focus. A negative `min` allows
65
- * negative values (penalties, defeat tokens…).
53
+ * A whole-number counter from `min` (0) to `max`: −/+ buttons to adjust by
54
+ * `step` (optionally faster on long press, or repeating while held), and a
55
+ * numeric field in the middle to type a large number in one go (other
56
+ * characters are dropped, the result is clamped). A typed value that is not a
57
+ * number yet or below `min` (an empty field, a lone "-", the "2" of "25" with
58
+ * `min={10}`) stays as typed until the field loses focus. A negative `min`
59
+ * allows negative values (penalties, defeat tokens…); `-Infinity`/`Infinity`
60
+ * remove a bound.
66
61
  *
67
62
  * @example
68
63
  * ```tsx
@@ -70,8 +65,8 @@ export interface QuantityStepperProps {
70
65
  *
71
66
  * <QuantityStepper value={count} onChange={setCount} label="Croissants" />
72
67
  * <QuantityStepper value={bans} onChange={setBans} label="Bannissements" min={1} max={40} longPressStep={5} />
73
- * <QuantityStepper value={score} onChange={setScore} label="Points" min={-999} repeatOnHold />
68
+ * <QuantityStepper value={score} onChange={setScore} label="Points" min={-Infinity} max={Infinity} step={4} repeatOnHold onStep={tapFeedback} />
74
69
  * ```
75
70
  */
76
- export declare function QuantityStepper({ value, onChange, label, min, max, longPressStep, repeatOnHold, locale, a11yLabels, styles, }: QuantityStepperProps): import("react").JSX.Element;
71
+ export declare function QuantityStepper({ value, onChange, label, min, max, step, onStep, longPressStep, repeatOnHold, locale, a11yLabels, styles, }: QuantityStepperProps): import("react").JSX.Element;
77
72
  //# sourceMappingURL=QuantityStepper.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"QuantityStepper.d.ts","sourceRoot":"","sources":["../../../src/components/picker/QuantityStepper.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAIpE,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAGpE,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3B,0BAA0B;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,kEAAkE;IAClE,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,4BAA4B;IAC5B,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,iEAAiE;IACjE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,oFAAoF;AACpF,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,wBAAwB;IACxB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,yDAAyD;IACzD,UAAU,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChD,oEAAoE;IACpE,MAAM,CAAC,EAAE,qBAAqB,CAAC;CAChC;AA0DD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,GAAO,EACP,GAAS,EACT,aAAa,EACb,YAAoB,EACpB,MAAM,EACN,UAAU,EACV,MAAM,GACP,EAAE,oBAAoB,+BAqItB"}
1
+ {"version":3,"file":"QuantityStepper.d.ts","sourceRoot":"","sources":["../../../src/components/picker/QuantityStepper.tsx"],"names":[],"mappings":"AAIA,OAAO,EAAE,KAAK,SAAS,EAAgB,MAAM,sBAAsB,CAAC;AAGpE,OAAO,EAGL,KAAK,yBAAyB,EAC9B,KAAK,qBAAqB,EAC3B,MAAM,6BAA6B,CAAC;AAErC,YAAY,EACV,yBAAyB,EACzB,qBAAqB,GACtB,MAAM,6BAA6B,CAAC;AAErC,MAAM,WAAW,oBAAoB;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,oDAAoD;IACpD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,yDAAyD;IACzD,UAAU,CAAC,EAAE,OAAO,CAAC,yBAAyB,CAAC,CAAC;IAChD,oEAAoE;IACpE,MAAM,CAAC,EAAE,qBAAqB,CAAC;CAChC;AAgBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAAC,EAC9B,KAAK,EACL,QAAQ,EACR,KAAK,EACL,GAAO,EACP,GAAS,EACT,IAAQ,EACR,MAAM,EACN,aAAa,EACb,YAAoB,EACpB,MAAM,EACN,UAAU,EACV,MAAM,GACP,EAAE,oBAAoB,+BA4ItB"}
@@ -4,52 +4,7 @@ import { Pressable, Text, TextInput, View } from "react-native";
4
4
  import { usePressRepeat } from "../../hooks/usePressRepeat.js";
5
5
  import { useKitLocale } from "../../i18n/locale.js";
6
6
  import { mergeSlotStyles } from "../../utils/helpers/mergeSlotStyles.js";
7
- const defaultA11yLabels = {
8
- fr: {
9
- decrease: (label) => `Retirer : ${label}`,
10
- increase: (label) => `Ajouter : ${label}`,
11
- input: (label) => `Quantité : ${label}`,
12
- },
13
- en: {
14
- decrease: (label) => `Decrease: ${label}`,
15
- increase: (label) => `Increase: ${label}`,
16
- input: (label) => `Quantity: ${label}`,
17
- },
18
- };
19
- const defaultStyles = {
20
- row: {
21
- flexDirection: "row",
22
- alignItems: "center",
23
- gap: 6,
24
- },
25
- button: {
26
- width: 40,
27
- height: 40,
28
- borderRadius: 20,
29
- backgroundColor: "#2563eb",
30
- alignItems: "center",
31
- justifyContent: "center",
32
- },
33
- buttonDisabled: { opacity: 0.35 },
34
- buttonText: {
35
- color: "#ffffff",
36
- fontSize: 22,
37
- fontWeight: "700",
38
- lineHeight: 26,
39
- },
40
- input: {
41
- width: 52,
42
- height: 40,
43
- borderRadius: 8,
44
- borderWidth: 1,
45
- borderColor: "#d4d4d4",
46
- color: "#111111",
47
- fontSize: 18,
48
- fontWeight: "700",
49
- textAlign: "center",
50
- padding: 0,
51
- },
52
- };
7
+ import { defaultA11yLabels, defaultStyles, } from "./QuantityStepper.styles.js";
53
8
  /** Keeps the digits, and a leading "-" when negatives are allowed. */
54
9
  function sanitize(text, allowsNegative) {
55
10
  const digits = text.replace(/\D/g, "");
@@ -57,14 +12,21 @@ function sanitize(text, allowsNegative) {
57
12
  ? `-${digits}`
58
13
  : digits;
59
14
  }
15
+ /** Characters a bound takes in the field; 15 digits (+ "-") if unbounded. */
16
+ function lengthOf(bound) {
17
+ if (Number.isFinite(bound))
18
+ return String(bound).length;
19
+ return bound < 0 ? 16 : 15;
20
+ }
60
21
  /**
61
- * A whole-number counter from `min` (0) to `max`: −/+ buttons to adjust
62
- * (optionally faster on long press, or repeating while held), and a numeric
63
- * field in the middle to type a large number in one go (other characters are
64
- * dropped, the result is clamped). A typed value that is not a number yet or
65
- * below `min` (an empty field, a lone "-", the "2" of "25" with `min={10}`)
66
- * stays as typed until the field loses focus. A negative `min` allows
67
- * negative values (penalties, defeat tokens…).
22
+ * A whole-number counter from `min` (0) to `max`: −/+ buttons to adjust by
23
+ * `step` (optionally faster on long press, or repeating while held), and a
24
+ * numeric field in the middle to type a large number in one go (other
25
+ * characters are dropped, the result is clamped). A typed value that is not a
26
+ * number yet or below `min` (an empty field, a lone "-", the "2" of "25" with
27
+ * `min={10}`) stays as typed until the field loses focus. A negative `min`
28
+ * allows negative values (penalties, defeat tokens…); `-Infinity`/`Infinity`
29
+ * remove a bound.
68
30
  *
69
31
  * @example
70
32
  * ```tsx
@@ -72,10 +34,10 @@ function sanitize(text, allowsNegative) {
72
34
  *
73
35
  * <QuantityStepper value={count} onChange={setCount} label="Croissants" />
74
36
  * <QuantityStepper value={bans} onChange={setBans} label="Bannissements" min={1} max={40} longPressStep={5} />
75
- * <QuantityStepper value={score} onChange={setScore} label="Points" min={-999} repeatOnHold />
37
+ * <QuantityStepper value={score} onChange={setScore} label="Points" min={-Infinity} max={Infinity} step={4} repeatOnHold onStep={tapFeedback} />
76
38
  * ```
77
39
  */
78
- export function QuantityStepper({ value, onChange, label, min = 0, max = 999, longPressStep, repeatOnHold = false, locale, a11yLabels, styles, }) {
40
+ export function QuantityStepper({ value, onChange, label, min = 0, max = 999, step = 1, onStep, longPressStep, repeatOnHold = false, locale, a11yLabels, styles, }) {
79
41
  const labels = { ...defaultA11yLabels[useKitLocale(locale)], ...a11yLabels };
80
42
  const merged = mergeSlotStyles(defaultStyles, styles);
81
43
  const atMin = value <= min;
@@ -96,32 +58,40 @@ export function QuantityStepper({ value, onChange, label, min = 0, max = 999, lo
96
58
  setDraft(null);
97
59
  onChange(clamp(next));
98
60
  }
61
+ /** A −/+ press: moves by `delta`; `false` when a bound stopped it. */
99
62
  function stepBy(delta) {
100
63
  const next = clamp(valueRef.current + delta);
101
- // A button disabled at its bound may never report the release.
102
- if (next === valueRef.current) {
103
- repeat.stop();
104
- return;
105
- }
64
+ if (next === valueRef.current)
65
+ return false;
106
66
  valueRef.current = next;
107
67
  set(next);
68
+ onStep?.(next);
69
+ return true;
108
70
  }
109
- function startRepeat(delta) {
71
+ function startRepeat(direction) {
110
72
  repeat.start(() => {
111
- stepBy(delta);
73
+ // A button disabled at its bound may never report the release.
74
+ if (!stepBy(direction * step))
75
+ repeat.stop();
112
76
  });
113
77
  }
114
78
  // Held: repeat from press-in to press-out. Otherwise: one step per tap,
115
79
  // and `longPressStep` on long press.
116
80
  const tapsOnly = !repeatOnHold;
117
81
  const jumps = tapsOnly && longPressStep !== undefined;
82
+ // A tap starts from the rendered value, even if the parent ignored the
83
+ // previous onChange.
84
+ function tap(delta) {
85
+ valueRef.current = value;
86
+ stepBy(delta);
87
+ }
118
88
  return (_jsxs(View, { style: merged.row, children: [_jsx(Pressable, { style: [merged.button, atMin && merged.buttonDisabled], onPress: tapsOnly
119
89
  ? () => {
120
- set(value - 1);
90
+ tap(-step);
121
91
  }
122
92
  : undefined, onLongPress: jumps
123
93
  ? () => {
124
- set(value - longPressStep);
94
+ tap(-longPressStep);
125
95
  }
126
96
  : undefined, onPressIn: repeatOnHold
127
97
  ? () => {
@@ -142,13 +112,13 @@ export function QuantityStepper({ value, onChange, label, min = 0, max = 999, lo
142
112
  set(draft === "" || draft === "-" ? 0 : Number(draft));
143
113
  },
144
114
  // "number-pad" has no "-" key on Android.
145
- keyboardType: allowsNegative ? "numeric" : "number-pad", selectTextOnFocus: true, maxLength: Math.max(String(min).length, String(max).length), accessibilityLabel: labels.input(label) }), _jsx(Pressable, { style: [merged.button, atMax && merged.buttonDisabled], onPress: tapsOnly
115
+ keyboardType: allowsNegative ? "numeric" : "number-pad", selectTextOnFocus: true, maxLength: Math.max(lengthOf(min), lengthOf(max)), accessibilityLabel: labels.input(label) }), _jsx(Pressable, { style: [merged.button, atMax && merged.buttonDisabled], onPress: tapsOnly
146
116
  ? () => {
147
- set(value + 1);
117
+ tap(step);
148
118
  }
149
119
  : undefined, onLongPress: jumps
150
120
  ? () => {
151
- set(value + longPressStep);
121
+ tap(longPressStep);
152
122
  }
153
123
  : undefined, onPressIn: repeatOnHold
154
124
  ? () => {
@@ -0,0 +1,59 @@
1
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ import type { KitLocale } from "../../i18n/locale.js";
3
+ /** Per-slot style overrides for {@link QuantityStepper}, each merged after its default. */
4
+ export interface QuantityStepperStyles {
5
+ /** The row: − button, field, + button. */
6
+ row?: StyleProp<ViewStyle>;
7
+ /** Both round buttons. */
8
+ button?: StyleProp<ViewStyle>;
9
+ /** Added on top of `button` for "−" at `min` and "+" at `max`. */
10
+ buttonDisabled?: StyleProp<ViewStyle>;
11
+ /** The "−" / "+" glyphs. */
12
+ buttonText?: StyleProp<TextStyle>;
13
+ /** The numeric field in the middle (its text color included). */
14
+ input?: StyleProp<TextStyle>;
15
+ }
16
+ /** Screen-reader labels of the three controls, built from the stepper's `label`. */
17
+ export interface QuantityStepperA11yLabels {
18
+ decrease: (label: string) => string;
19
+ increase: (label: string) => string;
20
+ input: (label: string) => string;
21
+ }
22
+ export declare const defaultA11yLabels: Record<KitLocale, QuantityStepperA11yLabels>;
23
+ export declare const defaultStyles: {
24
+ row: {
25
+ flexDirection: "row";
26
+ alignItems: "center";
27
+ gap: number;
28
+ };
29
+ button: {
30
+ width: number;
31
+ height: number;
32
+ borderRadius: number;
33
+ backgroundColor: string;
34
+ alignItems: "center";
35
+ justifyContent: "center";
36
+ };
37
+ buttonDisabled: {
38
+ opacity: number;
39
+ };
40
+ buttonText: {
41
+ color: string;
42
+ fontSize: number;
43
+ fontWeight: "700";
44
+ lineHeight: number;
45
+ };
46
+ input: {
47
+ width: number;
48
+ height: number;
49
+ borderRadius: number;
50
+ borderWidth: number;
51
+ borderColor: string;
52
+ color: string;
53
+ fontSize: number;
54
+ fontWeight: "700";
55
+ textAlign: "center";
56
+ padding: number;
57
+ };
58
+ };
59
+ //# sourceMappingURL=QuantityStepper.styles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"QuantityStepper.styles.d.ts","sourceRoot":"","sources":["../../../src/components/picker/QuantityStepper.styles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEpE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAEtD,2FAA2F;AAC3F,MAAM,WAAW,qBAAqB;IACpC,0CAA0C;IAC1C,GAAG,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3B,0BAA0B;IAC1B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,kEAAkE;IAClE,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,4BAA4B;IAC5B,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,iEAAiE;IACjE,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC9B;AAED,oFAAoF;AACpF,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,QAAQ,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC;CAClC;AAED,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,SAAS,EAAE,yBAAyB,CAW1E,CAAC;AAEF,eAAO,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiCzB,CAAC"}
@@ -0,0 +1,46 @@
1
+ export const defaultA11yLabels = {
2
+ fr: {
3
+ decrease: (label) => `Retirer : ${label}`,
4
+ increase: (label) => `Ajouter : ${label}`,
5
+ input: (label) => `Quantité : ${label}`,
6
+ },
7
+ en: {
8
+ decrease: (label) => `Decrease: ${label}`,
9
+ increase: (label) => `Increase: ${label}`,
10
+ input: (label) => `Quantity: ${label}`,
11
+ },
12
+ };
13
+ export const defaultStyles = {
14
+ row: {
15
+ flexDirection: "row",
16
+ alignItems: "center",
17
+ gap: 6,
18
+ },
19
+ button: {
20
+ width: 40,
21
+ height: 40,
22
+ borderRadius: 20,
23
+ backgroundColor: "#2563eb",
24
+ alignItems: "center",
25
+ justifyContent: "center",
26
+ },
27
+ buttonDisabled: { opacity: 0.35 },
28
+ buttonText: {
29
+ color: "#ffffff",
30
+ fontSize: 22,
31
+ fontWeight: "700",
32
+ lineHeight: 26,
33
+ },
34
+ input: {
35
+ width: 52,
36
+ height: 40,
37
+ borderRadius: 8,
38
+ borderWidth: 1,
39
+ borderColor: "#d4d4d4",
40
+ color: "#111111",
41
+ fontSize: 18,
42
+ fontWeight: "700",
43
+ textAlign: "center",
44
+ padding: 0,
45
+ },
46
+ };
@@ -7,7 +7,11 @@ export interface UpdateSettingsScreenProps {
7
7
  checkForUpdate: () => Promise<UpdateSettingsScreenRelease | null>;
8
8
  /** Positive when `a` is newer than `b`, e.g. semver comparison. */
9
9
  compareVersions: (a: string, b: string) => number;
10
- downloadAndInstallApk: (apkUrl: string, onProgress?: (fraction: number) => void) => Promise<void>;
10
+ /**
11
+ * Receives the release's `apkSha256`: pass it on as
12
+ * `expectedSha256` so a swapped or damaged APK is never installed.
13
+ */
14
+ downloadAndInstallApk: (apkUrl: string, onProgress?: (fraction: number) => void, apkSha256?: string) => Promise<void>;
11
15
  /** Omit to not show a version history section at all. */
12
16
  fetchReleaseHistory?: () => Promise<UpdateSettingsScreenHistoryEntry[]>;
13
17
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"UpdateSettingsScreen.d.ts","sourceRoot":"","sources":["../../../src/components/settings/UpdateSettingsScreen.tsx"],"names":[],"mappings":"AAKA,OAAO,EAEL,KAAK,SAAS,EAEf,MAAM,sBAAsB,CAAC;AAG9B,OAAO,KAAK,EACV,gCAAgC,EAChC,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,EAC3B,MAAM,kCAAkC,CAAC;AAI1C,YAAY,EACV,gCAAgC,EAChC,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,kCAAkC,CAAC;AAU1C,MAAM,WAAW,yBAAyB;IACxC,cAAc,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,cAAc,EAAE,MAAM,OAAO,CAAC,2BAA2B,GAAG,IAAI,CAAC,CAAC;IAClE,mEAAmE;IACnE,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAClD,qBAAqB,EAAE,CACrB,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,KACpC,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,yDAAyD;IACzD,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,gCAAgC,EAAE,CAAC,CAAC;IACxE;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,0BAA0B,CAAC;IACpC;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,0BAA0B,CAAC;CACrC;AAQD,wBAAgB,oBAAoB,CAAC,EACnC,cAAc,EACd,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,UAAU,EAAE,gBAAgB,EAC5B,MAAM,EACN,MAAM,EACN,MAAM,GACP,EAAE,yBAAyB,+BAuJ3B"}
1
+ {"version":3,"file":"UpdateSettingsScreen.d.ts","sourceRoot":"","sources":["../../../src/components/settings/UpdateSettingsScreen.tsx"],"names":[],"mappings":"AAKA,OAAO,EAEL,KAAK,SAAS,EAEf,MAAM,sBAAsB,CAAC;AAG9B,OAAO,KAAK,EACV,gCAAgC,EAChC,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,EAC3B,MAAM,kCAAkC,CAAC;AAI1C,YAAY,EACV,gCAAgC,EAChC,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,GAC3B,MAAM,kCAAkC,CAAC;AAU1C,MAAM,WAAW,yBAAyB;IACxC,cAAc,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,cAAc,EAAE,MAAM,OAAO,CAAC,2BAA2B,GAAG,IAAI,CAAC,CAAC;IAClE,mEAAmE;IACnE,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAClD;;;OAGG;IACH,qBAAqB,EAAE,CACrB,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,EACvC,SAAS,CAAC,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,CAAC;IACnB,yDAAyD;IACzD,mBAAmB,CAAC,EAAE,MAAM,OAAO,CAAC,gCAAgC,EAAE,CAAC,CAAC;IACxE;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,0BAA0B,CAAC;IACpC;;;OAGG;IACH,MAAM,CAAC,EAAE,SAAS,CAAC;IACnB,MAAM,CAAC,EAAE,0BAA0B,CAAC;CACrC;AAQD,wBAAgB,oBAAoB,CAAC,EACnC,cAAc,EACd,cAAc,EACd,eAAe,EACf,qBAAqB,EACrB,mBAAmB,EACnB,UAAU,EAAE,gBAAgB,EAC5B,MAAM,EACN,MAAM,EACN,MAAM,GACP,EAAE,yBAAyB,+BA8J3B"}
@@ -52,12 +52,12 @@ export function UpdateSettingsScreen({ currentVersion, checkForUpdate, compareVe
52
52
  void handleCheckForUpdate();
53
53
  // eslint-disable-next-line @eslint-react/exhaustive-deps -- runs once on mount by design; handleCheckForUpdate is expected to be stable enough for the screen's lifetime.
54
54
  }, []);
55
- async function handleInstall(apkUrl) {
55
+ async function handleInstall({ apkUrl, apkSha256, }) {
56
56
  setUpdateState({ status: "downloading", progress: 0 });
57
57
  try {
58
58
  await downloadAndInstallApk(apkUrl, (progress) => {
59
59
  setUpdateState({ status: "downloading", progress });
60
- });
60
+ }, apkSha256);
61
61
  setUpdateState({ status: "idle" });
62
62
  }
63
63
  catch {
@@ -74,6 +74,6 @@ export function UpdateSettingsScreen({ currentVersion, checkForUpdate, compareVe
74
74
  updateState.status !== "downloading" && (_jsx(Pressable, { style: [merged.button, isBusy && merged.buttonDisabled], onPress: () => {
75
75
  void handleCheckForUpdate();
76
76
  }, disabled: isBusy, accessibilityRole: "button", accessibilityLabel: t.checkButton, children: updateState.status === "checking" ? (_jsx(ActivityIndicator, { color: activityIndicatorColor })) : (_jsx(Text, { style: merged.buttonText, children: t.checkButton })) })), updateState.status === "up-to-date" && (_jsx(Text, { style: merged.helpText, children: t.upToDate })), updateState.status === "error" && (_jsx(Text, { style: merged.errorText, children: updateState.message })), updateState.status === "available" && (_jsxs(View, { style: merged.updateAvailableBox, children: [_jsx(Text, { style: merged.updateAvailableTitle, children: t.availableTitle(updateState.release.version) }), updateState.release.notes ? (_jsx(ChangelogNotes, { notes: updateState.release.notes, styles: merged.notes })) : null, _jsx(Pressable, { style: merged.button, onPress: () => {
77
- void handleInstall(updateState.release.apkUrl);
77
+ void handleInstall(updateState.release);
78
78
  }, accessibilityRole: "button", accessibilityLabel: t.installButtonAccessibilityLabel(updateState.release.version), children: _jsx(Text, { style: merged.buttonText, children: t.installButton(updateState.release.version) }) })] })), updateState.status === "downloading" && (_jsxs(View, { style: merged.updateAvailableBox, children: [_jsx(Text, { style: merged.helpText, children: t.downloadingLabel(Math.round(updateState.progress * 100)) }), _jsx(Text, { style: merged.helpText, children: t.downloadingHint })] })), releaseHistory && releaseHistory.length > 0 && (_jsx(UpdateSettingsScreenHistory, { releaseHistory: releaseHistory, historyTitle: t.historyTitle, dateLocale: dateLocale, notesStyles: notesStyles, styles: merged }))] }));
79
79
  }
@@ -4,6 +4,8 @@ export interface UpdateSettingsScreenRelease {
4
4
  version: string;
5
5
  notes: string;
6
6
  apkUrl: string;
7
+ /** SHA-256 of the APK, handed back to `downloadAndInstallApk`. */
8
+ apkSha256?: string;
7
9
  }
8
10
  export interface UpdateSettingsScreenHistoryEntry {
9
11
  version: string;
@@ -1 +1 @@
1
- {"version":3,"file":"UpdateSettingsScreen.styles.d.ts","sourceRoot":"","sources":["../../../src/components/settings/UpdateSettingsScreen.styles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wCAAwC,CAAC;AAInF,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,gCAAgC;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,0BAA0B;IACzC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7C,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,+BAA+B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9D,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,0BAA0B;IACzC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC/B,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAChC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,kBAAkB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC1C,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,KAAK,CAAC,EAAE,oBAAoB,CAAC;CAC9B;AAED,eAAO,MAAM,aAAa;;;;;;;kCAOI,MAAM;;mDAEW,MAAM;oCAErB,MAAM;;;;;;;;;;kCAYR,MAAM;;mDAEW,MAAM;oCAErB,MAAM;;;;CAI6B,CAAC;AAEpE,eAAO,MAAM,aAAa,EAAE,QAAQ,CAClC,IAAI,CAAC,0BAA0B,EAAE,wBAAwB,GAAG,OAAO,CAAC,CACrE,GACC,IAAI,CAAC,0BAA0B,EAAE,wBAAwB,GAAG,OAAO,CA8DpE,CAAC"}
1
+ {"version":3,"file":"UpdateSettingsScreen.styles.d.ts","sourceRoot":"","sources":["../../../src/components/settings/UpdateSettingsScreen.styles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAEpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,wCAAwC,CAAC;AAInF,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gCAAgC;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,0BAA0B;IACzC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC7C,aAAa,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC5C,+BAA+B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9D,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,0BAA0B;IACzC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,OAAO,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC/B,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,QAAQ,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAChC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,kBAAkB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC1C,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,oBAAoB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5C,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,KAAK,CAAC,EAAE,oBAAoB,CAAC;CAC9B;AAED,eAAO,MAAM,aAAa;;;;;;;kCAOI,MAAM;;mDAEW,MAAM;oCAErB,MAAM;;;;;;;;;;kCAYR,MAAM;;mDAEW,MAAM;oCAErB,MAAM;;;;CAI6B,CAAC;AAEpE,eAAO,MAAM,aAAa,EAAE,QAAQ,CAClC,IAAI,CAAC,0BAA0B,EAAE,wBAAwB,GAAG,OAAO,CAAC,CACrE,GACC,IAAI,CAAC,0BAA0B,EAAE,wBAAwB,GAAG,OAAO,CA8DpE,CAAC"}
@@ -0,0 +1,46 @@
1
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ import type { BackgroundOption } from "./findBackground.js";
3
+ export type { BackgroundOption } from "./findBackground.js";
4
+ export interface BackgroundPickerStyles {
5
+ /** The wrapping row of swatches. */
6
+ row?: StyleProp<ViewStyle>;
7
+ /** One option: swatch + label. */
8
+ option?: StyleProp<ViewStyle>;
9
+ swatch?: StyleProp<ViewStyle>;
10
+ /** Added on top of `swatch` for the chosen one. */
11
+ swatchSelected?: StyleProp<ViewStyle>;
12
+ card?: StyleProp<ViewStyle>;
13
+ /** The round badge holding the check mark. */
14
+ check?: StyleProp<ViewStyle>;
15
+ label?: StyleProp<TextStyle>;
16
+ labelSelected?: StyleProp<TextStyle>;
17
+ checkIconColor?: string;
18
+ }
19
+ export interface BackgroundPickerProps<T extends BackgroundOption> {
20
+ options: readonly T[];
21
+ /** Id of the chosen option. */
22
+ value: string;
23
+ /** Receives the chosen option — add a haptic tick here if wanted. */
24
+ onChange: (option: T) => void;
25
+ styles?: BackgroundPickerStyles;
26
+ }
27
+ /**
28
+ * Radio group of background swatches: each one draws the page and a card in
29
+ * the colours on offer, the chosen one outlined with a check mark. Pair it
30
+ * with `findBackground` (`./findBackground`) to read the saved id, and check each palette's
31
+ * contrast with `contrastRatio` from `@forthtilliath/ts-kit/color`.
32
+ *
33
+ * @example
34
+ * ```tsx
35
+ * <BackgroundPicker
36
+ * options={[
37
+ * { id: "night", label: "Bleu nuit", page: "#0f172a", card: "#1e293b" },
38
+ * { id: "felt", label: "Tapis vert", page: "#0b1f15", card: "#112c1e" },
39
+ * ]}
40
+ * value={settings.background}
41
+ * onChange={(option) => { selectFeedback(); save(option.id); }}
42
+ * />
43
+ * ```
44
+ */
45
+ export declare function BackgroundPicker<T extends BackgroundOption>({ options, value, onChange, styles, }: BackgroundPickerProps<T>): import("react").JSX.Element;
46
+ //# sourceMappingURL=BackgroundPicker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BackgroundPicker.d.ts","sourceRoot":"","sources":["../../../src/components/theme/BackgroundPicker.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAMpE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,MAAM,WAAW,sBAAsB;IACrC,oCAAoC;IACpC,GAAG,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC3B,kCAAkC;IAClC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,mDAAmD;IACnD,cAAc,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACtC,IAAI,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5B,8CAA8C;IAC9C,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,qBAAqB,CAAC,CAAC,SAAS,gBAAgB;IAC/D,OAAO,EAAE,SAAS,CAAC,EAAE,CAAC;IACtB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,qEAAqE;IACrE,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,MAAM,CAAC,EAAE,sBAAsB,CAAC;CACjC;AAwCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,gBAAgB,EAAE,EAC3D,OAAO,EACP,KAAK,EACL,QAAQ,EACR,MAAM,GACP,EAAE,qBAAqB,CAAC,CAAC,CAAC,+BAoD1B"}
@@ -0,0 +1,79 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import { Ionicons } from "@expo/vector-icons";
4
+ import { mergeSlotStyles } from "../../utils/helpers/mergeSlotStyles.js";
5
+ const defaultStyles = {
6
+ row: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
7
+ option: {
8
+ flexGrow: 1,
9
+ flexBasis: "22%",
10
+ alignItems: "center",
11
+ gap: 4,
12
+ },
13
+ swatch: {
14
+ width: "100%",
15
+ aspectRatio: 1,
16
+ borderRadius: 12,
17
+ borderWidth: 2,
18
+ borderColor: "#d4d4d4",
19
+ padding: 8,
20
+ justifyContent: "flex-end",
21
+ },
22
+ swatchSelected: { borderColor: "#2563eb" },
23
+ card: { height: "45%", borderRadius: 6, borderWidth: 1 },
24
+ check: {
25
+ position: "absolute",
26
+ top: 4,
27
+ right: 4,
28
+ width: 22,
29
+ height: 22,
30
+ borderRadius: 11,
31
+ backgroundColor: "#2563eb",
32
+ alignItems: "center",
33
+ justifyContent: "center",
34
+ },
35
+ label: {
36
+ color: "#6b7280",
37
+ fontSize: 13,
38
+ fontWeight: "600",
39
+ },
40
+ labelSelected: { color: "#111827" },
41
+ };
42
+ /**
43
+ * Radio group of background swatches: each one draws the page and a card in
44
+ * the colours on offer, the chosen one outlined with a check mark. Pair it
45
+ * with `findBackground` (`./findBackground`) to read the saved id, and check each palette's
46
+ * contrast with `contrastRatio` from `@forthtilliath/ts-kit/color`.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * <BackgroundPicker
51
+ * options={[
52
+ * { id: "night", label: "Bleu nuit", page: "#0f172a", card: "#1e293b" },
53
+ * { id: "felt", label: "Tapis vert", page: "#0b1f15", card: "#112c1e" },
54
+ * ]}
55
+ * value={settings.background}
56
+ * onChange={(option) => { selectFeedback(); save(option.id); }}
57
+ * />
58
+ * ```
59
+ */
60
+ export function BackgroundPicker({ options, value, onChange, styles, }) {
61
+ const merged = mergeSlotStyles(defaultStyles, styles);
62
+ const checkIconColor = styles?.checkIconColor ?? "#ffffff";
63
+ return (_jsx(View, { style: merged.row, accessibilityRole: "radiogroup", children: options.map((option) => {
64
+ const selected = option.id === value;
65
+ return (_jsxs(Pressable, { style: merged.option, onPress: () => {
66
+ onChange(option);
67
+ }, accessibilityRole: "radio", accessibilityState: { checked: selected }, accessibilityLabel: option.label, children: [_jsxs(View, { style: [
68
+ merged.swatch,
69
+ { backgroundColor: option.page },
70
+ selected && merged.swatchSelected,
71
+ ], children: [_jsx(View, { style: [
72
+ merged.card,
73
+ {
74
+ backgroundColor: option.card,
75
+ borderColor: option.cardBorder ?? option.card,
76
+ },
77
+ ] }), selected && (_jsx(View, { style: merged.check, children: _jsx(Ionicons, { name: "checkmark", size: 14, color: checkIconColor }) }))] }), _jsx(Text, { style: [merged.label, selected && merged.labelSelected], numberOfLines: 1, children: option.label })] }, option.id));
78
+ }) }));
79
+ }
@@ -0,0 +1,20 @@
1
+ /** One background on offer to `BackgroundPicker`, drawn as a page with a card on it. */
2
+ export interface BackgroundOption {
3
+ id: string;
4
+ /** Shown under the swatch and read by screen readers. */
5
+ label: string;
6
+ /** The page colour. */
7
+ page: string;
8
+ /** The card drawn on the page. */
9
+ card: string;
10
+ /** The card's border. Defaults to `card`. */
11
+ cardBorder?: string;
12
+ }
13
+ /**
14
+ * The option with this id, or the first one (the original background) if the
15
+ * id is unknown — a background removed since it was saved, or none saved yet.
16
+ *
17
+ * @throws {RangeError} If `options` is empty.
18
+ */
19
+ export declare function findBackground<T extends BackgroundOption>(options: readonly T[], id: string | null | undefined): T;
20
+ //# sourceMappingURL=findBackground.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"findBackground.d.ts","sourceRoot":"","sources":["../../../src/components/theme/findBackground.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,yDAAyD;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,uBAAuB;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,6CAA6C;IAC7C,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,CAAC,SAAS,gBAAgB,EACvD,OAAO,EAAE,SAAS,CAAC,EAAE,EACrB,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC5B,CAAC,CAIH"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The option with this id, or the first one (the original background) if the
3
+ * id is unknown — a background removed since it was saved, or none saved yet.
4
+ *
5
+ * @throws {RangeError} If `options` is empty.
6
+ */
7
+ export function findBackground(options, id) {
8
+ const found = options.find((option) => option.id === id) ?? options[0];
9
+ if (!found)
10
+ throw new RangeError("findBackground needs at least one option");
11
+ return found;
12
+ }
@@ -1,3 +1,5 @@
1
+ export * from "./BackgroundPicker.js";
2
+ export * from "./findBackground.js";
1
3
  export * from "./ThemeOptionList.js";
2
4
  export * from "./themePreferenceContext.js";
3
5
  export * from "./ThemePreferenceProvider.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/theme/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAC;AACrC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/components/theme/index.ts"],"names":[],"mappings":"AAAA,cAAc,uBAAuB,CAAC;AACtC,cAAc,qBAAqB,CAAC;AACpC,cAAc,sBAAsB,CAAC;AACrC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,kBAAkB,CAAC"}
@@ -1,3 +1,5 @@
1
+ export * from "./BackgroundPicker.js";
2
+ export * from "./findBackground.js";
1
3
  export * from "./ThemeOptionList.js";
2
4
  export * from "./themePreferenceContext.js";
3
5
  export * from "./ThemePreferenceProvider.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/react-native-kit",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -22,7 +22,7 @@
22
22
  "./*": "./dist/*.js"
23
23
  },
24
24
  "dependencies": {
25
- "@forthtilliath/ts-kit": "^0.13.0"
25
+ "@forthtilliath/ts-kit": "^0.14.0"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "@expo/vector-icons": ">=15.0.0",
@@ -102,15 +102,15 @@
102
102
  "size-limit": "^14.1.0",
103
103
  "typescript": "^5.9.3",
104
104
  "vitest": "^5.0.2",
105
- "@forthtilliath/eslint-config": "0.9.0",
106
- "@forthtilliath/expo-release-updates-ui": "^0.4.1",
107
- "@forthtilliath/typescript-config": "0.1.1"
105
+ "@forthtilliath/expo-release-updates-ui": "^0.5.0",
106
+ "@forthtilliath/typescript-config": "0.1.1",
107
+ "@forthtilliath/eslint-config": "0.9.0"
108
108
  },
109
109
  "size-limit": [
110
110
  {
111
111
  "name": "dist (all modules, brotli)",
112
112
  "path": "dist/**/*.js",
113
- "limit": "44 KB"
113
+ "limit": "47 KB"
114
114
  }
115
115
  ],
116
116
  "scripts": {