@forthtilliath/expo-release-updates-ui 0.3.1 → 0.4.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
@@ -131,6 +131,42 @@ import { UpdateAvailableBanner } from "@forthtilliath/expo-release-updates-ui";
131
131
 
132
132
  Built-in labels are French by default; pass `locale="en"` for English (e.g. from the app's language on a bilingual app). `labels` still overrides individual strings — `title`, `action`, `dismiss`, `dismissAccessibilityLabel` — whatever the locale.
133
133
 
134
+ ### `<UpdateNotifier ... />` — the whole flow, install included
135
+
136
+ Mount it once near the root: it checks on launch (`useUpdateCheck`), shows the banner with **Install / Ignore**, and "Install" downloads the APK right away with a progress card — Android then asks for confirmation — instead of sending the user to an update screen. A failed download offers Retry / Close. "Ignore" calls `onDismissVersion` so that exact version is never announced again (a newer one will be).
137
+
138
+ ```tsx
139
+ import { UpdateNotifier } from "@forthtilliath/expo-release-updates-ui/UpdateNotifier";
140
+
141
+ <View
142
+ style={{ position: "absolute", top: 56, left: 16, right: 16, zIndex: 10 }}
143
+ >
144
+ <UpdateNotifier
145
+ enabled={settings !== undefined}
146
+ currentVersion={Constants.expoConfig?.version ?? "0.0.0"}
147
+ checkForUpdate={fetchLatestRelease}
148
+ getLastCheck={() => ({
149
+ lastCheckedAt: settings?.lastUpdateCheckAt ?? null,
150
+ dismissedVersion: settings?.dismissedUpdateVersion ?? null,
151
+ })}
152
+ onChecked={(at) => recordUpdateCheck(at)}
153
+ onDismissVersion={(version) => dismissUpdateVersion(version)}
154
+ downloadAndInstallApk={(apkUrl, onProgress) =>
155
+ downloadAndInstallApk({
156
+ apkUrl,
157
+ fileName: "myapp-update.apk",
158
+ onProgress,
159
+ })
160
+ }
161
+ styles={{ banner: { container: { backgroundColor: colors.surface } } }}
162
+ />
163
+ </View>;
164
+ ```
165
+
166
+ Pass **`enabled={false}` until the settings `getLastCheck` reads have loaded** (async database, live query…): the check runs once, on mount, so starting earlier would ignore both the 12h throttle and the dismissed version. Positioning is left to the parent. `labels` / `styles` take `{ banner, install }`, same keys as `UpdateAvailableBanner` and `UpdateInstallCard`.
167
+
168
+ The building blocks are exported too, to compose a different flow: `useApkInstall(downloadAndInstallApk)` (`{ state, start(version, apkUrl), retry, reset }`) and `<UpdateInstallCard state onRetry onClose />` (renders nothing while idle).
169
+
134
170
  ## Scripts
135
171
 
136
172
  ```bash
@@ -0,0 +1,32 @@
1
+ import type { StyleProp, TextStyle, ViewStyle } from "react-native";
2
+ import type { UpdateBannerLocale } from "./UpdateAvailableBanner.js";
3
+ import type { ApkInstallState } from "./useApkInstall.js";
4
+ export interface UpdateInstallCardStyles {
5
+ container?: StyleProp<ViewStyle>;
6
+ title?: StyleProp<TextStyle>;
7
+ hint?: StyleProp<TextStyle>;
8
+ error?: StyleProp<TextStyle>;
9
+ actions?: StyleProp<ViewStyle>;
10
+ retryButton?: StyleProp<ViewStyle>;
11
+ retryButtonText?: StyleProp<TextStyle>;
12
+ closeButton?: StyleProp<ViewStyle>;
13
+ closeButtonText?: StyleProp<TextStyle>;
14
+ }
15
+ export interface UpdateInstallCardLabels {
16
+ downloading?: (version: string, percent: number) => string;
17
+ hint?: string;
18
+ error?: (version: string) => string;
19
+ retry?: string;
20
+ close?: string;
21
+ }
22
+ export interface UpdateInstallCardProps {
23
+ state: ApkInstallState;
24
+ onRetry: () => void;
25
+ onClose: () => void;
26
+ labels?: UpdateInstallCardLabels;
27
+ /** @defaultValue "fr" */
28
+ locale?: UpdateBannerLocale;
29
+ styles?: UpdateInstallCardStyles;
30
+ }
31
+ export declare function UpdateInstallCard({ state, onRetry, onClose, labels, locale, styles, }: UpdateInstallCardProps): import("react").JSX.Element | null;
32
+ //# sourceMappingURL=UpdateInstallCard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UpdateInstallCard.d.ts","sourceRoot":"","sources":["../src/UpdateInstallCard.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAGpE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AACrE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAE1D,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACjC,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,IAAI,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC5B,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,OAAO,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC/B,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,eAAe,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACvC,WAAW,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IACnC,eAAe,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;CACxC;AAED,MAAM,WAAW,uBAAuB;IACtC,WAAW,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IAC3D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,MAAM,CAAC,EAAE,uBAAuB,CAAC;IACjC,yBAAyB;IACzB,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,MAAM,CAAC,EAAE,uBAAuB,CAAC;CAClC;AAwDD,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,OAAO,EACP,OAAO,EACP,MAAM,EACN,MAAa,EACb,MAAM,GACP,EAAE,sBAAsB,sCAwCxB"}
@@ -0,0 +1,62 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Pressable, Text, View } from "react-native";
3
+ const defaultStyles = {
4
+ container: {
5
+ backgroundColor: "#ffffff",
6
+ borderWidth: 1,
7
+ borderColor: "#e5e7eb",
8
+ borderRadius: 10,
9
+ padding: 14,
10
+ gap: 6,
11
+ },
12
+ title: { fontSize: 15, fontWeight: "700", color: "#111827" },
13
+ hint: { fontSize: 12, color: "#6b7280" },
14
+ error: { fontSize: 14, color: "#dc2626" },
15
+ actions: {
16
+ flexDirection: "row",
17
+ justifyContent: "flex-end",
18
+ gap: 8,
19
+ marginTop: 4,
20
+ },
21
+ retryButton: {
22
+ backgroundColor: "#2563eb",
23
+ borderRadius: 8,
24
+ paddingVertical: 8,
25
+ paddingHorizontal: 14,
26
+ },
27
+ retryButtonText: { color: "#ffffff", fontWeight: "700" },
28
+ closeButton: { paddingVertical: 8, paddingHorizontal: 14 },
29
+ closeButtonText: { color: "#6b7280" },
30
+ };
31
+ const defaultLabels = {
32
+ fr: {
33
+ downloading: (version, percent) => `Téléchargement de la version ${version}… ${percent}%`,
34
+ hint: "Le téléphone va ensuite demander confirmation pour l'installer.",
35
+ error: (version) => `Le téléchargement de la version ${version} a échoué.`,
36
+ retry: "Réessayer",
37
+ close: "Fermer",
38
+ },
39
+ en: {
40
+ downloading: (version, percent) => `Downloading version ${version}… ${percent}%`,
41
+ hint: "Your phone will then ask you to confirm the installation.",
42
+ error: (version) => `Downloading version ${version} failed.`,
43
+ retry: "Retry",
44
+ close: "Close",
45
+ },
46
+ };
47
+ // Progress of an update started from the UI (see `useApkInstall`): a
48
+ // non-interactive card while the APK downloads, then — only on failure — an
49
+ // error with Retry/Close. Renders nothing while idle.
50
+ export function UpdateInstallCard({ state, onRetry, onClose, labels, locale = "fr", styles, }) {
51
+ if (state.status === "idle")
52
+ return null;
53
+ const t = { ...defaultLabels[locale], ...labels };
54
+ const s = (key) => [
55
+ defaultStyles[key],
56
+ styles?.[key],
57
+ ];
58
+ if (state.status === "downloading") {
59
+ return (_jsxs(View, { style: s("container"), pointerEvents: "none", children: [_jsx(Text, { style: s("title"), children: t.downloading(state.version, Math.round(state.progress * 100)) }), _jsx(Text, { style: s("hint"), children: t.hint })] }));
60
+ }
61
+ return (_jsxs(View, { style: s("container"), children: [_jsx(Text, { style: s("error"), children: t.error(state.version) }), _jsxs(View, { style: s("actions"), children: [_jsx(Pressable, { style: s("closeButton"), onPress: onClose, accessibilityRole: "button", children: _jsx(Text, { style: s("closeButtonText"), children: t.close }) }), _jsx(Pressable, { style: s("retryButton"), onPress: onRetry, accessibilityRole: "button", children: _jsx(Text, { style: s("retryButtonText"), children: t.retry }) })] })] }));
62
+ }
@@ -0,0 +1,27 @@
1
+ import type { UpdateAvailableBannerLabels, UpdateAvailableBannerStyles, UpdateBannerLocale } from "./UpdateAvailableBanner.js";
2
+ import type { UpdateInstallCardLabels, UpdateInstallCardStyles } from "./UpdateInstallCard.js";
3
+ import type { DownloadAndInstallApk } from "./useApkInstall.js";
4
+ import type { UseUpdateCheckOptions } from "./useUpdateCheck.js";
5
+ export interface UpdateNotifierProps extends UseUpdateCheckOptions {
6
+ /**
7
+ * `false` until what `getLastCheck` reads has loaded (settings from an async
8
+ * database...): the check runs once, on mount, so starting it earlier would
9
+ * ignore both the throttle and the dismissed version. Default: `true`.
10
+ */
11
+ enabled?: boolean;
12
+ downloadAndInstallApk: DownloadAndInstallApk;
13
+ /** "Ignore": persist it so this exact version is never announced again. */
14
+ onDismissVersion: (version: string) => void;
15
+ /** @defaultValue "fr" */
16
+ locale?: UpdateBannerLocale;
17
+ labels?: {
18
+ banner?: UpdateAvailableBannerLabels;
19
+ install?: UpdateInstallCardLabels;
20
+ };
21
+ styles?: {
22
+ banner?: UpdateAvailableBannerStyles;
23
+ install?: UpdateInstallCardStyles;
24
+ };
25
+ }
26
+ export declare function UpdateNotifier({ enabled, ...props }: UpdateNotifierProps): import("react").JSX.Element | null;
27
+ //# sourceMappingURL=UpdateNotifier.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UpdateNotifier.d.ts","sourceRoot":"","sources":["../src/UpdateNotifier.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,2BAA2B,EAC3B,2BAA2B,EAC3B,kBAAkB,EACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,KAAK,EACV,uBAAuB,EACvB,uBAAuB,EACxB,MAAM,wBAAwB,CAAC;AAEhC,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAEhE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAGjE,MAAM,WAAW,mBAAoB,SAAQ,qBAAqB;IAChE;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,qBAAqB,EAAE,qBAAqB,CAAC;IAC7C,2EAA2E;IAC3E,gBAAgB,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5C,yBAAyB;IACzB,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,MAAM,CAAC,EAAE;QACP,MAAM,CAAC,EAAE,2BAA2B,CAAC;QACrC,OAAO,CAAC,EAAE,uBAAuB,CAAC;KACnC,CAAC;IACF,MAAM,CAAC,EAAE;QACP,MAAM,CAAC,EAAE,2BAA2B,CAAC;QACrC,OAAO,CAAC,EAAE,uBAAuB,CAAC;KACnC,CAAC;CACH;AAqBD,wBAAgB,cAAc,CAAC,EAC7B,OAAc,EACd,GAAG,KAAK,EACT,EAAE,mBAAmB,sCAErB"}
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { UpdateAvailableBanner } from "./UpdateAvailableBanner.js";
3
+ import { UpdateInstallCard } from "./UpdateInstallCard.js";
4
+ import { useApkInstall } from "./useApkInstall.js";
5
+ import { useUpdateCheck } from "./useUpdateCheck.js";
6
+ const bannerLabels = {
7
+ fr: { action: "Installer", dismiss: "Ignorer" },
8
+ en: { action: "Install", dismiss: "Ignore" },
9
+ };
10
+ const dismissAccessibilityLabel = {
11
+ fr: (version) => `Ignorer la version ${version}`,
12
+ en: (version) => `Ignore version ${version}`,
13
+ };
14
+ // Whole update flow for a sideloaded app, to mount once near the root: checks
15
+ // for a new release on launch (`useUpdateCheck`), announces it, and
16
+ // "Install" downloads the APK right away with a progress card (Android then
17
+ // asks for confirmation) — no detour through an update screen. "Ignore" never
18
+ // announces that version again, but a newer one will be. Position it
19
+ // (absolute, safe area...) from the parent.
20
+ export function UpdateNotifier({ enabled = true, ...props }) {
21
+ return enabled ? _jsx(ActiveUpdateNotifier, { ...props }) : null;
22
+ }
23
+ function ActiveUpdateNotifier({ downloadAndInstallApk, onDismissVersion, locale = "fr", labels, styles, ...checkOptions }) {
24
+ const update = useUpdateCheck(checkOptions);
25
+ const install = useApkInstall(downloadAndInstallApk);
26
+ if (install.state.status !== "idle") {
27
+ return (_jsx(UpdateInstallCard, { state: install.state, onRetry: install.retry, onClose: install.reset, locale: locale, labels: labels?.install, styles: styles?.install }));
28
+ }
29
+ if (update.status !== "available")
30
+ return null;
31
+ const { release } = update;
32
+ return (_jsx(UpdateAvailableBanner, { version: release.version, notes: release.notes, locale: locale, labels: {
33
+ ...bannerLabels[locale],
34
+ dismissAccessibilityLabel: dismissAccessibilityLabel[locale](release.version),
35
+ ...labels?.banner,
36
+ }, styles: styles?.banner, onPress: () => {
37
+ // Not persisted as dismissed: if the install doesn't go through, the
38
+ // banner may come back on a later launch (unlike "Ignore").
39
+ update.dismiss();
40
+ install.start(release.version, release.apkUrl);
41
+ }, onDismiss: () => {
42
+ onDismissVersion(release.version);
43
+ update.dismiss();
44
+ } }));
45
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export * from "./ChangelogNotes.js";
2
2
  export * from "./UpdateAvailableBanner.js";
3
+ export * from "./UpdateInstallCard.js";
4
+ export * from "./UpdateNotifier.js";
5
+ export * from "./useApkInstall.js";
3
6
  export * from "./useUpdateCheck.js";
4
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,qBAAqB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,qBAAqB,CAAC;AACpC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,qBAAqB,CAAC;AACpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC"}
package/dist/index.js CHANGED
@@ -1,3 +1,6 @@
1
1
  export * from "./ChangelogNotes.js";
2
2
  export * from "./UpdateAvailableBanner.js";
3
+ export * from "./UpdateInstallCard.js";
4
+ export * from "./UpdateNotifier.js";
5
+ export * from "./useApkInstall.js";
3
6
  export * from "./useUpdateCheck.js";
@@ -0,0 +1,20 @@
1
+ /** Downloads an APK then hands it to Android's installer, reporting progress from 0 to 1. */
2
+ export type DownloadAndInstallApk = (apkUrl: string, onProgress?: (fraction: number) => void) => Promise<void>;
3
+ export type ApkInstallState = {
4
+ status: "idle";
5
+ } | {
6
+ status: "downloading";
7
+ version: string;
8
+ progress: number;
9
+ } | {
10
+ status: "error";
11
+ version: string;
12
+ apkUrl: string;
13
+ };
14
+ export declare function useApkInstall(downloadAndInstallApk: DownloadAndInstallApk): {
15
+ state: ApkInstallState;
16
+ start: (version: string, apkUrl: string) => void;
17
+ retry: () => void;
18
+ reset: () => void;
19
+ };
20
+ //# sourceMappingURL=useApkInstall.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useApkInstall.d.ts","sourceRoot":"","sources":["../src/useApkInstall.ts"],"names":[],"mappings":"AAEA,6FAA6F;AAC7F,MAAM,MAAM,qBAAqB,GAAG,CAClC,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,KACpC,OAAO,CAAC,IAAI,CAAC,CAAC;AAEnB,MAAM,MAAM,eAAe,GACvB;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAClB;IAAE,MAAM,EAAE,aAAa,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC5D;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAKzD,wBAAgB,aAAa,CAAC,qBAAqB,EAAE,qBAAqB;;qBAGhD,MAAM,UAAU,MAAM;;;EAsB/C"}
@@ -0,0 +1,27 @@
1
+ import { useState } from "react";
2
+ // Tracks one APK download/install started from the UI: progress while it
3
+ // downloads, back to idle once Android's installer has taken over, an error
4
+ // state (keeping the URL) so the user can retry.
5
+ export function useApkInstall(downloadAndInstallApk) {
6
+ const [state, setState] = useState({ status: "idle" });
7
+ function start(version, apkUrl) {
8
+ setState({ status: "downloading", version, progress: 0 });
9
+ downloadAndInstallApk(apkUrl, (progress) => {
10
+ setState({ status: "downloading", version, progress });
11
+ })
12
+ .then(() => {
13
+ setState({ status: "idle" });
14
+ })
15
+ .catch(() => {
16
+ setState({ status: "error", version, apkUrl });
17
+ });
18
+ }
19
+ function retry() {
20
+ if (state.status === "error")
21
+ start(state.version, state.apkUrl);
22
+ }
23
+ function reset() {
24
+ setState({ status: "idle" });
25
+ }
26
+ return { state, start, retry, reset };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forthtilliath/expo-release-updates-ui",
3
- "version": "0.3.1",
3
+ "version": "0.4.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/expo-release-updates": "^0.6.0"
25
+ "@forthtilliath/expo-release-updates": "^0.6.1"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "react": ">=19",
@@ -38,7 +38,7 @@
38
38
  "typescript": "^5.9.3",
39
39
  "vitest": "^5.0.2",
40
40
  "@forthtilliath/typescript-config": "0.1.1",
41
- "@forthtilliath/eslint-config": "0.8.0"
41
+ "@forthtilliath/eslint-config": "0.9.0"
42
42
  },
43
43
  "scripts": {
44
44
  "lint": "eslint . --max-warnings 0",