@forthtilliath/react-native-kit 0.5.0 → 0.6.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
@@ -204,6 +204,73 @@ confirmDestructive("Delete this item?", () => deleteItem(id), {
204
204
  });
205
205
  ```
206
206
 
207
+ ### `useEffectiveColorScheme(preference)`
208
+
209
+ 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.
210
+
211
+ ```tsx
212
+ import { useEffectiveColorScheme } from "@forthtilliath/react-native-kit/useEffectiveColorScheme";
213
+
214
+ const scheme = useEffectiveColorScheme(themePreference); // "light" | "dark"
215
+ const colors = scheme === "dark" ? darkColors : lightColors;
216
+ ```
217
+
218
+ ### `<ThemeToggle value onChange />`
219
+
220
+ 3-way segmented control for a light/dark/system theme preference.
221
+
222
+ ```tsx
223
+ import { ThemeToggle } from "@forthtilliath/react-native-kit/ThemeToggle";
224
+
225
+ <ThemeToggle value={themePreference} onChange={setThemePreference} />;
226
+ ```
227
+
228
+ ### `useUpdateCheck(options)`
229
+
230
+ 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.
231
+
232
+ ```tsx
233
+ import { useUpdateCheck } from "@forthtilliath/react-native-kit/useUpdateCheck";
234
+
235
+ const update = useUpdateCheck({
236
+ currentVersion: Constants.expoConfig?.version ?? "0.0.0",
237
+ checkForUpdate: fetchLatestRelease,
238
+ compareVersions,
239
+ getLastCheck: () => ({
240
+ lastCheckedAt: settings?.lastUpdateCheckAt ?? null,
241
+ dismissedVersion: settings?.dismissedUpdateVersion ?? null,
242
+ }),
243
+ onChecked: (lastCheckedAt) =>
244
+ updateSettings({ lastUpdateCheckAt: lastCheckedAt }),
245
+ });
246
+
247
+ if (update.status === "available") {
248
+ // update.release.version / .notes / .apkUrl
249
+ }
250
+ ```
251
+
252
+ ### `<UpdateAvailableBanner version notes onPress onDismiss />`
253
+
254
+ 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.
255
+
256
+ ```tsx
257
+ import { UpdateAvailableBanner } from "@forthtilliath/react-native-kit/UpdateAvailableBanner";
258
+
259
+ {
260
+ update.status === "available" && (
261
+ <UpdateAvailableBanner
262
+ version={update.release.version}
263
+ notes={update.release.notes}
264
+ onPress={() => router.push("/settings/update")}
265
+ onDismiss={() => {
266
+ dismissUpdateVersion(update.release.version);
267
+ update.dismiss();
268
+ }}
269
+ />
270
+ );
271
+ }
272
+ ```
273
+
207
274
  ### Utils (`utils/`)
208
275
 
209
276
  Framework-agnostic pure functions — no React or React Native import, usable from Node/web too.
@@ -0,0 +1,22 @@
1
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ import type { ThemePreference } from "./useEffectiveColorScheme.js";
3
+ export interface ThemeToggleStyles {
4
+ container?: StyleProp<ViewStyle>;
5
+ option?: StyleProp<ViewStyle>;
6
+ optionActive?: StyleProp<ViewStyle>;
7
+ optionText?: StyleProp<TextStyle>;
8
+ optionTextActive?: StyleProp<TextStyle>;
9
+ }
10
+ export interface ThemeToggleLabels {
11
+ light?: string;
12
+ dark?: string;
13
+ system?: string;
14
+ }
15
+ export interface ThemeToggleProps {
16
+ value: ThemePreference;
17
+ onChange: (preference: ThemePreference) => void;
18
+ labels?: ThemeToggleLabels;
19
+ styles?: ThemeToggleStyles;
20
+ }
21
+ export declare function ThemeToggle({ value, onChange, labels, styles, }: ThemeToggleProps): import("react").JSX.Element;
22
+ //# sourceMappingURL=ThemeToggle.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ThemeToggle.d.ts","sourceRoot":"","sources":["../src/ThemeToggle.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAGpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AAEpE,MAAM,WAAW,iBAAiB;IAChC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACpC,UAAU,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAClC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACzC;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,eAAe,CAAC;IACvB,QAAQ,EAAE,CAAC,UAAU,EAAE,eAAe,KAAK,IAAI,CAAC;IAChD,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B,MAAM,CAAC,EAAE,iBAAiB,CAAC;CAC5B;AA2BD,wBAAgB,WAAW,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,MAAM,EACN,MAAM,GACP,EAAE,gBAAgB,+BA6BlB"}
@@ -0,0 +1,34 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Pressable, Text, View } from "react-native";
3
+ const OPTIONS = ["light", "dark", "system"];
4
+ const defaultStyles = {
5
+ container: { flexDirection: "row", gap: 8 },
6
+ option: {
7
+ flex: 1,
8
+ borderWidth: 1,
9
+ borderColor: "#e5e7eb",
10
+ borderRadius: 10,
11
+ paddingVertical: 10,
12
+ alignItems: "center",
13
+ backgroundColor: "#ffffff",
14
+ },
15
+ optionActive: { backgroundColor: "#2563eb", borderColor: "#2563eb" },
16
+ optionText: { fontSize: 15, fontWeight: "600", color: "#111827" },
17
+ optionTextActive: { color: "#ffffff" },
18
+ };
19
+ const defaultLabels = {
20
+ light: "Clair",
21
+ dark: "Sombre",
22
+ system: "Système",
23
+ };
24
+ // 3-way segmented control for a light/dark/system theme preference.
25
+ export function ThemeToggle({ value, onChange, labels, styles, }) {
26
+ const merged = { ...defaultStyles, ...styles };
27
+ const t = { ...defaultLabels, ...labels };
28
+ return (_jsx(View, { style: merged.container, children: OPTIONS.map((option) => {
29
+ const active = option === value;
30
+ return (_jsx(Pressable, { style: [merged.option, active && merged.optionActive], onPress: () => {
31
+ onChange(option);
32
+ }, accessibilityRole: "button", accessibilityState: { selected: active }, accessibilityLabel: t[option], children: _jsx(Text, { style: [merged.optionText, active && merged.optionTextActive], children: t[option] }) }, option));
33
+ }) }));
34
+ }
@@ -0,0 +1,29 @@
1
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ import type { ChangelogNotesStyles } from "./ChangelogNotes.js";
3
+ export interface UpdateAvailableBannerStyles {
4
+ container?: StyleProp<ViewStyle>;
5
+ header?: StyleProp<ViewStyle>;
6
+ title?: StyleProp<TextStyle>;
7
+ notes?: ChangelogNotesStyles;
8
+ actionButton?: StyleProp<ViewStyle>;
9
+ actionButtonText?: StyleProp<TextStyle>;
10
+ dismissButton?: StyleProp<ViewStyle>;
11
+ dismissButtonText?: StyleProp<TextStyle>;
12
+ }
13
+ export interface UpdateAvailableBannerLabels {
14
+ title?: (version: string) => string;
15
+ action?: string;
16
+ dismiss?: string;
17
+ dismissAccessibilityLabel?: string;
18
+ }
19
+ export interface UpdateAvailableBannerProps {
20
+ version: string;
21
+ /** Raw release notes, passed straight to `ChangelogNotes`. */
22
+ notes: string;
23
+ onPress: () => void;
24
+ onDismiss: () => void;
25
+ labels?: UpdateAvailableBannerLabels;
26
+ styles?: UpdateAvailableBannerStyles;
27
+ }
28
+ export declare function UpdateAvailableBanner({ version, notes, onPress, onDismiss, labels, styles, }: UpdateAvailableBannerProps): import("react").JSX.Element;
29
+ //# sourceMappingURL=UpdateAvailableBanner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UpdateAvailableBanner.d.ts","sourceRoot":"","sources":["../src/UpdateAvailableBanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAGpE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAGhE,MAAM,WAAW,2BAA2B;IAC1C,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,MAAM,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC9B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,YAAY,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACpC,gBAAgB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACxC,aAAa,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACrC,iBAAiB,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CAC1C;AAED,MAAM,WAAW,2BAA2B;IAC1C,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yBAAyB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED,MAAM,WAAW,0BAA0B;IACzC,OAAO,EAAE,MAAM,CAAC;IAChB,8DAA8D;IAC9D,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,2BAA2B,CAAC;IACrC,MAAM,CAAC,EAAE,2BAA2B,CAAC;CACtC;AAwCD,wBAAgB,qBAAqB,CAAC,EACpC,OAAO,EACP,KAAK,EACL,OAAO,EACP,SAAS,EACT,MAAM,EACN,MAAM,GACP,EAAE,0BAA0B,+BA4B5B"}
@@ -0,0 +1,44 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import { ChangelogNotes } from "./ChangelogNotes.js";
4
+ const defaultStyles = {
5
+ container: {
6
+ backgroundColor: "#ffffff",
7
+ borderWidth: 1,
8
+ borderColor: "#e5e7eb",
9
+ borderRadius: 10,
10
+ padding: 14,
11
+ gap: 8,
12
+ },
13
+ header: {
14
+ flexDirection: "row",
15
+ justifyContent: "space-between",
16
+ alignItems: "center",
17
+ },
18
+ title: { fontSize: 15, fontWeight: "700", color: "#111827" },
19
+ notes: {},
20
+ actionButton: {
21
+ backgroundColor: "#2563eb",
22
+ borderRadius: 10,
23
+ paddingVertical: 10,
24
+ alignItems: "center",
25
+ },
26
+ actionButtonText: { color: "#ffffff", fontSize: 14, fontWeight: "700" },
27
+ dismissButton: { padding: 4 },
28
+ dismissButtonText: { fontSize: 13, color: "#6b7280" },
29
+ };
30
+ const defaultLabels = {
31
+ title: (version) => `Version ${version} disponible`,
32
+ action: "Voir",
33
+ dismiss: "Fermer",
34
+ dismissAccessibilityLabel: "Fermer la notification de mise à jour",
35
+ };
36
+ // Dismissible banner announcing an available update: version, release notes
37
+ // (via `ChangelogNotes`), and an action + dismiss button. Has no opinion on
38
+ // what "action" does (e.g. navigate to an update screen) or on how/whether
39
+ // dismissal is persisted — both are left entirely to the caller.
40
+ export function UpdateAvailableBanner({ version, notes, onPress, onDismiss, labels, styles, }) {
41
+ const merged = { ...defaultStyles, ...styles };
42
+ const t = { ...defaultLabels, ...labels };
43
+ return (_jsxs(View, { style: merged.container, children: [_jsxs(View, { style: merged.header, children: [_jsx(Text, { style: merged.title, children: t.title(version) }), _jsx(Pressable, { style: merged.dismissButton, onPress: onDismiss, accessibilityRole: "button", accessibilityLabel: t.dismissAccessibilityLabel, children: _jsx(Text, { style: merged.dismissButtonText, children: t.dismiss }) })] }), notes ? _jsx(ChangelogNotes, { notes: notes, styles: merged.notes }) : null, _jsx(Pressable, { style: merged.actionButton, onPress: onPress, accessibilityRole: "button", accessibilityLabel: t.action, children: _jsx(Text, { style: merged.actionButtonText, children: t.action }) })] }));
44
+ }
@@ -0,0 +1,3 @@
1
+ export type ThemePreference = "light" | "dark" | "system";
2
+ export declare function useEffectiveColorScheme(preference: ThemePreference): "light" | "dark";
3
+ //# sourceMappingURL=useEffectiveColorScheme.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useEffectiveColorScheme.d.ts","sourceRoot":"","sources":["../src/useEffectiveColorScheme.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,CAAC;AAM1D,wBAAgB,uBAAuB,CACrC,UAAU,EAAE,eAAe,GAC1B,OAAO,GAAG,MAAM,CAIlB"}
@@ -0,0 +1,11 @@
1
+ import { useColorScheme } from "react-native";
2
+ // Resolves a user's theme preference against the device's color scheme:
3
+ // "system" follows the device, "light"/"dark" override it regardless of what
4
+ // the device is set to. `useColorScheme()` can return `null` (unknown, e.g.
5
+ // during the very first render on some platforms), treated as "light".
6
+ export function useEffectiveColorScheme(preference) {
7
+ const systemScheme = useColorScheme();
8
+ if (preference !== "system")
9
+ return preference;
10
+ return systemScheme === "dark" ? "dark" : "light";
11
+ }
@@ -0,0 +1,35 @@
1
+ export interface UpdateCheckRelease {
2
+ version: string;
3
+ notes: string;
4
+ apkUrl: string;
5
+ }
6
+ export type UpdateCheckState = {
7
+ status: "idle";
8
+ } | {
9
+ status: "available";
10
+ release: UpdateCheckRelease;
11
+ } | {
12
+ status: "error";
13
+ };
14
+ export interface UseUpdateCheckOptions {
15
+ /** The app's currently installed version, e.g. from its own version string. */
16
+ currentVersion: string;
17
+ /** Fetches the latest available release, or `null` if there is none. */
18
+ checkForUpdate: () => Promise<UpdateCheckRelease | null>;
19
+ /** Positive when `a` is newer than `b`, e.g. semver comparison. */
20
+ compareVersions: (a: string, b: string) => number;
21
+ /** Read once on mount: when the last check ran, and which version (if any) the user already dismissed. */
22
+ getLastCheck: () => {
23
+ lastCheckedAt: number | null;
24
+ dismissedVersion: string | null;
25
+ };
26
+ /** Called after each completed check (success or failure) so the caller can persist the timestamp. */
27
+ onChecked: (lastCheckedAt: number) => void;
28
+ /** Minimum delay between two checks. Defaults to 12h. */
29
+ minIntervalMs?: number;
30
+ now?: () => number;
31
+ }
32
+ export declare function useUpdateCheck({ currentVersion, checkForUpdate, compareVersions, getLastCheck, onChecked, minIntervalMs, now, }: UseUpdateCheckOptions): UpdateCheckState & {
33
+ dismiss: () => void;
34
+ };
35
+ //# sourceMappingURL=useUpdateCheck.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useUpdateCheck.d.ts","sourceRoot":"","sources":["../src/useUpdateCheck.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,gBAAgB,GACxB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,kBAAkB,CAAA;CAAE,GACpD;IAAE,MAAM,EAAE,OAAO,CAAA;CAAE,CAAC;AAExB,MAAM,WAAW,qBAAqB;IACpC,+EAA+E;IAC/E,cAAc,EAAE,MAAM,CAAC;IACvB,wEAAwE;IACxE,cAAc,EAAE,MAAM,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC,CAAC;IACzD,mEAAmE;IACnE,eAAe,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAClD,0GAA0G;IAC1G,YAAY,EAAE,MAAM;QAClB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;QAC7B,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;KACjC,CAAC;IACF,sGAAsG;IACtG,SAAS,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,yDAAyD;IACzD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAUD,wBAAgB,cAAc,CAAC,EAC7B,cAAc,EACd,cAAc,EACd,eAAe,EACf,YAAY,EACZ,SAAS,EACT,aAAuC,EACvC,GAAc,GACf,EAAE,qBAAqB,GAAG,gBAAgB,GAAG;IAAE,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAyCpE"}
@@ -0,0 +1,47 @@
1
+ import { useEffect, useState } from "react";
2
+ const DEFAULT_MIN_INTERVAL_MS = 12 * 60 * 60 * 1000;
3
+ // Checks once per mount (e.g. app launch) whether a newer release is
4
+ // available, throttled to at most one real network check per `minIntervalMs`
5
+ // and silent for a release the user already dismissed. Persistence of
6
+ // "when did we last check" / "which version did the user dismiss" is left
7
+ // entirely to the caller (`getLastCheck`/`onChecked`, plus its own dismiss
8
+ // handler) — this hook has no opinion on where that lives.
9
+ export function useUpdateCheck({ currentVersion, checkForUpdate, compareVersions, getLastCheck, onChecked, minIntervalMs = DEFAULT_MIN_INTERVAL_MS, now = Date.now, }) {
10
+ const [state, setState] = useState({ status: "idle" });
11
+ useEffect(() => {
12
+ const { lastCheckedAt, dismissedVersion } = getLastCheck();
13
+ if (lastCheckedAt != null && now() - lastCheckedAt < minIntervalMs)
14
+ return;
15
+ let cancelled = false;
16
+ checkForUpdate()
17
+ .then((release) => {
18
+ if (cancelled)
19
+ return;
20
+ onChecked(now());
21
+ if (release &&
22
+ compareVersions(release.version, currentVersion) > 0 &&
23
+ release.version !== dismissedVersion) {
24
+ setState({ status: "available", release });
25
+ }
26
+ else {
27
+ setState({ status: "idle" });
28
+ }
29
+ })
30
+ .catch(() => {
31
+ if (cancelled)
32
+ return;
33
+ onChecked(now());
34
+ setState({ status: "error" });
35
+ });
36
+ return () => {
37
+ cancelled = true;
38
+ };
39
+ // eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps -- runs once per mount by design (e.g. once per app launch); options are expected to be stable for the caller's lifetime.
40
+ }, []);
41
+ return {
42
+ ...state,
43
+ dismiss: () => {
44
+ setState({ status: "idle" });
45
+ },
46
+ };
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/react-native-kit",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -43,8 +43,8 @@
43
43
  "react-test-renderer": "19.2.3",
44
44
  "typescript": "^5.9.3",
45
45
  "vitest": "^4.1.10",
46
- "@forthtilliath/typescript-config": "0.0.0",
47
- "@forthtilliath/eslint-config": "0.0.0"
46
+ "@forthtilliath/eslint-config": "0.0.0",
47
+ "@forthtilliath/typescript-config": "0.0.0"
48
48
  },
49
49
  "scripts": {
50
50
  "lint": "eslint . --max-warnings 0",