@entropy-softworks/ui 2026.8.41 → 2026.9.1
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/CHANGELOG.md +116 -106
- package/lib/components/item-icon.d.ts +16 -0
- package/lib/components/item-icon.d.ts.map +1 -1
- package/lib/components/item-icon.jsx +2 -2
- package/lib/components/item-icon.jsx.map +1 -1
- package/lib/components/pin-pad.d.ts +22 -1
- package/lib/components/pin-pad.d.ts.map +1 -1
- package/lib/components/pin-pad.jsx +73 -20
- package/lib/components/pin-pad.jsx.map +1 -1
- package/lib/hooks/index.d.ts +1 -1
- package/lib/hooks/index.d.ts.map +1 -1
- package/lib/hooks/index.js +1 -1
- package/lib/hooks/index.js.map +1 -1
- package/lib/hooks/useUploadBlobUrl.d.ts +49 -5
- package/lib/hooks/useUploadBlobUrl.d.ts.map +1 -1
- package/lib/hooks/useUploadBlobUrl.js +92 -13
- package/lib/hooks/useUploadBlobUrl.js.map +1 -1
- package/package.json +1 -1
- package/src/components/item-icon.tsx +18 -2
- package/src/components/pin-pad.tsx +129 -25
- package/src/hooks/__tests__/useUploadBlobUrl.test.tsx +128 -16
- package/src/hooks/index.ts +6 -1
- package/src/hooks/useUploadBlobUrl.ts +140 -16
|
@@ -4,10 +4,54 @@ export interface UploadBlobUrlResult {
|
|
|
4
4
|
setErrored: (v: boolean) => void;
|
|
5
5
|
}
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Produce a renderable URI for an upload id without going through
|
|
8
|
+
* `apiFetch`.
|
|
9
|
+
*
|
|
10
|
+
* For a consumer that keeps uploads somewhere this package cannot reach — an
|
|
11
|
+
* on-device database, a cache it populated itself — the resolver is the whole
|
|
12
|
+
* seam. Return `null` for an id that does not resolve; the hook reports that
|
|
13
|
+
* as `errored`, which is what a renderer draws its fallback on.
|
|
14
|
+
*
|
|
15
|
+
* The `signal` aborts with the effect, so a resolver doing real work can stop
|
|
16
|
+
* when the row scrolls away.
|
|
11
17
|
*/
|
|
12
|
-
export
|
|
18
|
+
export type UploadResolver = (uploadId: string, signal: AbortSignal) => Promise<string | null>;
|
|
19
|
+
export interface UploadBlobUrlOptions {
|
|
20
|
+
/** Upload-fetch path prefix. Defaults to `/uploads`. */
|
|
21
|
+
uploadsPath?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Resolve the URI yourself instead of fetching it.
|
|
24
|
+
*
|
|
25
|
+
* Identity does not have to be stable: the resolver is held in a ref and
|
|
26
|
+
* only `uploadId` / `uploadsPath` re-run the effect. An inline arrow would
|
|
27
|
+
* otherwise refetch on every render, and what identifies the resource is the
|
|
28
|
+
* id, not the function that happens to fetch it.
|
|
29
|
+
*/
|
|
30
|
+
resolve?: UploadResolver;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A URI for an upload, fetched through `apiFetch` unless the consumer resolves
|
|
34
|
+
* it itself.
|
|
35
|
+
*
|
|
36
|
+
* # Web gets a blob URL; native gets a data URI
|
|
37
|
+
*
|
|
38
|
+
* `URL.createObjectURL` is a browser API. React Native's `URL` does not
|
|
39
|
+
* implement it in any way that can be relied on across both platforms — so on
|
|
40
|
+
* native this hook used to hand `<Image>` a URI built by a function that
|
|
41
|
+
* either threw or produced something the image loader could not open, and the
|
|
42
|
+
* fetch fell into the `catch` below. The visible symptom was that custom
|
|
43
|
+
* uploaded icons never appeared on a phone: they resolved to the fallback
|
|
44
|
+
* glyph every time, on every consumer, and looked like a missing-image problem
|
|
45
|
+
* rather than a URL one.
|
|
46
|
+
*
|
|
47
|
+
* Native therefore reads the bytes and builds `data:<type>;base64,…`, which
|
|
48
|
+
* every RN image loader accepts. The cost is that base64 inflates by a third
|
|
49
|
+
* and the string sits in the JS heap for as long as the row is mounted, which
|
|
50
|
+
* is affordable for the icons this exists to draw (the vault caps an upload at
|
|
51
|
+
* 256KB) and is why web keeps the blob URL — no copy, and revocable.
|
|
52
|
+
*
|
|
53
|
+
* The second argument accepts a bare path for backwards compatibility with
|
|
54
|
+
* `useUploadBlobUrl(id, "/uploads")`; new call sites should pass options.
|
|
55
|
+
*/
|
|
56
|
+
export declare function useUploadBlobUrl(uploadId: string | null | undefined, options?: string | UploadBlobUrlOptions): UploadBlobUrlResult;
|
|
13
57
|
//# sourceMappingURL=useUploadBlobUrl.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUploadBlobUrl.d.ts","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"useUploadBlobUrl.d.ts","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"AAMA,MAAM,WAAW,mBAAmB;IAClC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,OAAO,EAAE,OAAO,CAAC;IACjB,UAAU,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAClC;AAED;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAE/F,MAAM,WAAW,oBAAoB;IACnC,wDAAwD;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,cAAc,CAAC;CAC1B;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,OAAO,GAAE,MAAM,GAAG,oBAAyB,GAC1C,mBAAmB,CAgGrB"}
|
|
@@ -1,15 +1,39 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Platform } from "react-native";
|
|
2
3
|
import { apiFetch } from "../utils/api-fetch";
|
|
3
4
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
4
5
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* A URI for an upload, fetched through `apiFetch` unless the consumer resolves
|
|
7
|
+
* it itself.
|
|
8
|
+
*
|
|
9
|
+
* # Web gets a blob URL; native gets a data URI
|
|
10
|
+
*
|
|
11
|
+
* `URL.createObjectURL` is a browser API. React Native's `URL` does not
|
|
12
|
+
* implement it in any way that can be relied on across both platforms — so on
|
|
13
|
+
* native this hook used to hand `<Image>` a URI built by a function that
|
|
14
|
+
* either threw or produced something the image loader could not open, and the
|
|
15
|
+
* fetch fell into the `catch` below. The visible symptom was that custom
|
|
16
|
+
* uploaded icons never appeared on a phone: they resolved to the fallback
|
|
17
|
+
* glyph every time, on every consumer, and looked like a missing-image problem
|
|
18
|
+
* rather than a URL one.
|
|
19
|
+
*
|
|
20
|
+
* Native therefore reads the bytes and builds `data:<type>;base64,…`, which
|
|
21
|
+
* every RN image loader accepts. The cost is that base64 inflates by a third
|
|
22
|
+
* and the string sits in the JS heap for as long as the row is mounted, which
|
|
23
|
+
* is affordable for the icons this exists to draw (the vault caps an upload at
|
|
24
|
+
* 256KB) and is why web keeps the blob URL — no copy, and revocable.
|
|
25
|
+
*
|
|
26
|
+
* The second argument accepts a bare path for backwards compatibility with
|
|
27
|
+
* `useUploadBlobUrl(id, "/uploads")`; new call sites should pass options.
|
|
9
28
|
*/
|
|
10
|
-
export function useUploadBlobUrl(uploadId,
|
|
29
|
+
export function useUploadBlobUrl(uploadId, options = {}) {
|
|
30
|
+
const { uploadsPath = "/uploads", resolve } = typeof options === "string" ? { uploadsPath: options, resolve: undefined } : options;
|
|
11
31
|
const [uri, setUri] = useState(null);
|
|
12
32
|
const [errored, setErrored] = useState(false);
|
|
33
|
+
// See `UploadBlobUrlOptions.resolve`: kept in a ref so a resolver defined
|
|
34
|
+
// inline at the call site cannot turn every render into a refetch.
|
|
35
|
+
const resolveRef = useRef(resolve);
|
|
36
|
+
resolveRef.current = resolve;
|
|
13
37
|
useEffect(() => {
|
|
14
38
|
setErrored(false);
|
|
15
39
|
if (!uploadId) {
|
|
@@ -19,21 +43,51 @@ export function useUploadBlobUrl(uploadId, uploadsPath = "/uploads") {
|
|
|
19
43
|
const controller = new AbortController();
|
|
20
44
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
21
45
|
let createdUrl = null;
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
const load = async () => {
|
|
47
|
+
const resolver = resolveRef.current;
|
|
48
|
+
if (resolver) {
|
|
49
|
+
return resolver(uploadId, controller.signal);
|
|
50
|
+
}
|
|
51
|
+
const response = await apiFetch(`${uploadsPath}/${encodeURIComponent(uploadId)}`, {
|
|
52
|
+
signal: controller.signal,
|
|
53
|
+
});
|
|
24
54
|
if (controller.signal.aborted) {
|
|
25
|
-
return;
|
|
55
|
+
return null;
|
|
26
56
|
}
|
|
27
57
|
if (!response.ok) {
|
|
28
58
|
throw new Error(`Failed to load upload (${response.status})`);
|
|
29
59
|
}
|
|
30
|
-
|
|
60
|
+
if (Platform.OS === "web") {
|
|
61
|
+
const blob = await response.blob();
|
|
62
|
+
if (controller.signal.aborted) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
createdUrl = URL.createObjectURL(blob);
|
|
66
|
+
return createdUrl;
|
|
67
|
+
}
|
|
68
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
69
|
+
if (controller.signal.aborted) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
// The server sends the stored content type; anything else is a server
|
|
73
|
+
// that has lost track of what it is holding, and `<Image>` will refuse
|
|
74
|
+
// the URI rather than guess — which surfaces as the fallback glyph, the
|
|
75
|
+
// same outcome a failed fetch gives.
|
|
76
|
+
const contentType = response.headers.get("content-type") ?? "application/octet-stream";
|
|
77
|
+
return `data:${contentType};base64,${toBase64(bytes)}`;
|
|
78
|
+
};
|
|
79
|
+
load()
|
|
80
|
+
.then((next) => {
|
|
31
81
|
if (controller.signal.aborted) {
|
|
32
82
|
return;
|
|
33
83
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
84
|
+
if (next === null) {
|
|
85
|
+
// A resolver that has nothing for this id, or an aborted read. Either
|
|
86
|
+
// way there is no image, which is what `errored` means to a renderer.
|
|
87
|
+
setErrored(true);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
setUri(next);
|
|
37
91
|
})
|
|
38
92
|
.catch((e) => {
|
|
39
93
|
if (controller.signal.aborted) {
|
|
@@ -57,4 +111,29 @@ export function useUploadBlobUrl(uploadId, uploadsPath = "/uploads") {
|
|
|
57
111
|
}, [uploadId, uploadsPath]);
|
|
58
112
|
return { uri, errored, setErrored };
|
|
59
113
|
}
|
|
114
|
+
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
115
|
+
/**
|
|
116
|
+
* Base64, without depending on the host having `btoa`.
|
|
117
|
+
*
|
|
118
|
+
* React Native ships no `btoa`, and whether one exists depends entirely on
|
|
119
|
+
* which polyfill the consuming app happened to install — a library cannot
|
|
120
|
+
* assume it. Encoding by hand is a dozen lines and removes the question.
|
|
121
|
+
*
|
|
122
|
+
* The `?? 0` on the tail bytes is the padding rule rather than a guard: base64
|
|
123
|
+
* completes a short final group with zero bits and then marks the missing
|
|
124
|
+
* bytes with `=`. Reading past the end is therefore correct here, and doing it
|
|
125
|
+
* this way keeps the loop free of the non-null assertions
|
|
126
|
+
* `noUncheckedIndexedAccess` would otherwise demand at every lookup.
|
|
127
|
+
*/
|
|
128
|
+
function toBase64(bytes) {
|
|
129
|
+
const chars = [];
|
|
130
|
+
for (let index = 0; index < bytes.length; index += 3) {
|
|
131
|
+
const b0 = bytes[index] ?? 0;
|
|
132
|
+
const b1 = bytes[index + 1] ?? 0;
|
|
133
|
+
const b2 = bytes[index + 2] ?? 0;
|
|
134
|
+
const remaining = bytes.length - index;
|
|
135
|
+
chars.push(BASE64_ALPHABET.charAt(b0 >> 2), BASE64_ALPHABET.charAt(((b0 & 3) << 4) | (b1 >> 4)), remaining > 1 ? BASE64_ALPHABET.charAt(((b1 & 15) << 2) | (b2 >> 6)) : "=", remaining > 2 ? BASE64_ALPHABET.charAt(b2 & 63) : "=");
|
|
136
|
+
}
|
|
137
|
+
return chars.join("");
|
|
138
|
+
}
|
|
60
139
|
//# sourceMappingURL=useUploadBlobUrl.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUploadBlobUrl.js","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"useUploadBlobUrl.js","sourceRoot":"","sources":["../../src/hooks/useUploadBlobUrl.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import { Image, Platform, View } from "react-native";
|
|
|
3
3
|
import { Ionicons } from "@expo/vector-icons";
|
|
4
4
|
import { useColorScheme } from "nativewind";
|
|
5
5
|
import { resolveIcon } from "../services/favicon";
|
|
6
|
-
import { useUploadBlobUrl } from "../hooks/useUploadBlobUrl";
|
|
6
|
+
import { type UploadResolver, useUploadBlobUrl } from "../hooks/useUploadBlobUrl";
|
|
7
7
|
|
|
8
8
|
export interface ItemIconProps {
|
|
9
9
|
/**
|
|
@@ -28,6 +28,21 @@ export interface ItemIconProps {
|
|
|
28
28
|
faviconPath?: string;
|
|
29
29
|
/** Upload-fetch path prefix. Defaults to `/uploads`. */
|
|
30
30
|
uploadsPath?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Resolve a `custom:<uploadId>` reference yourself instead of letting this
|
|
33
|
+
* component fetch it.
|
|
34
|
+
*
|
|
35
|
+
* For a consumer whose uploads are not behind `apiFetch` at all — vault's
|
|
36
|
+
* device-only vaults keep them in on-device SQLite, with no server to ask.
|
|
37
|
+
* Without this, such a consumer has to either fork the component or
|
|
38
|
+
* pre-resolve the reference into a `data:` URI and pass it as `iconUrl`,
|
|
39
|
+
* which works by accident (anything that is not `custom:` is treated as a
|
|
40
|
+
* plain URL) and hides the intent.
|
|
41
|
+
*
|
|
42
|
+
* Return `null` for an id that does not resolve; the fallback glyph is drawn,
|
|
43
|
+
* exactly as for a fetch that fails.
|
|
44
|
+
*/
|
|
45
|
+
resolveUpload?: UploadResolver;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
// react-native-web forwards `loading` and `decoding` to the underlying
|
|
@@ -51,13 +66,14 @@ export const ItemIcon = React.memo(function ItemIcon({
|
|
|
51
66
|
fallbackColor,
|
|
52
67
|
faviconPath = "/favicon",
|
|
53
68
|
uploadsPath = "/uploads",
|
|
69
|
+
resolveUpload,
|
|
54
70
|
}: ItemIconProps) {
|
|
55
71
|
const { colorScheme } = useColorScheme();
|
|
56
72
|
const isDark = colorScheme === "dark";
|
|
57
73
|
|
|
58
74
|
const resolved = resolveIcon({ iconUrl, siteUrl, faviconPath });
|
|
59
75
|
const uploadId = resolved.kind === "upload" ? resolved.uploadId : null;
|
|
60
|
-
const upload = useUploadBlobUrl(uploadId, uploadsPath);
|
|
76
|
+
const upload = useUploadBlobUrl(uploadId, { uploadsPath, resolve: resolveUpload });
|
|
61
77
|
|
|
62
78
|
const directUri = resolved.kind === "url" ? resolved.uri : null;
|
|
63
79
|
const [urlErrored, setUrlErrored] = useState(false);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { Platform, Pressable, Text, View } from "react-native";
|
|
2
|
+
import { Platform, Pressable, Text, useWindowDimensions, View } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
4
|
+
import * as Haptics from "expo-haptics";
|
|
3
5
|
import { useColorScheme } from "nativewind";
|
|
4
6
|
import { Delete, Fingerprint } from "lucide-react-native";
|
|
5
7
|
|
|
@@ -8,6 +10,21 @@ import { Delete, Fingerprint } from "lucide-react-native";
|
|
|
8
10
|
* the active palette via NativeWind classes). Self-manages the entered
|
|
9
11
|
* digits and fires `onComplete` when `length` is reached; pass a changing
|
|
10
12
|
* `resetKey` to clear it (e.g. after a wrong PIN).
|
|
13
|
+
*
|
|
14
|
+
* # Sizing
|
|
15
|
+
*
|
|
16
|
+
* The pad measures itself against the viewport instead of sitting at a fixed
|
|
17
|
+
* 288px. On a modern phone a fixed pad reads as a small control marooned in the
|
|
18
|
+
* middle of a large screen — and this is the primary way into the app for a
|
|
19
|
+
* device-only vault, so it should feel like the screen's purpose rather than a
|
|
20
|
+
* widget on it. Keys grow with the available width and stop at `MAX_KEY` so a
|
|
21
|
+
* tablet does not get comedy-sized buttons.
|
|
22
|
+
*
|
|
23
|
+
* # Why the keys have a fill
|
|
24
|
+
*
|
|
25
|
+
* They previously had no background at all until the moment of the press, which
|
|
26
|
+
* left three columns of bare digits that did not read as pressable. A resting
|
|
27
|
+
* fill plus a border is what makes a target look like a target.
|
|
11
28
|
*/
|
|
12
29
|
export interface PinPadProps {
|
|
13
30
|
length?: number;
|
|
@@ -18,10 +35,23 @@ export interface PinPadProps {
|
|
|
18
35
|
/** Bump to clear the entered digits. */
|
|
19
36
|
resetKey?: string | number;
|
|
20
37
|
error?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Haptic feedback on each key. On by default, and a no-op on web and on any
|
|
40
|
+
* device without a haptic engine. Pass `false` where a press already triggers
|
|
41
|
+
* feedback of its own.
|
|
42
|
+
*/
|
|
43
|
+
haptics?: boolean;
|
|
21
44
|
}
|
|
22
45
|
|
|
23
46
|
const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9"] as const;
|
|
24
47
|
|
|
48
|
+
/** Key diameter bounds, and the gap between them. */
|
|
49
|
+
const MIN_KEY = 76;
|
|
50
|
+
const MAX_KEY = 104;
|
|
51
|
+
const GAP = 16;
|
|
52
|
+
/** Room left for the screen's own horizontal padding. */
|
|
53
|
+
const SIDE_PADDING = 48;
|
|
54
|
+
|
|
25
55
|
export function PinPad({
|
|
26
56
|
length = 6,
|
|
27
57
|
onComplete,
|
|
@@ -29,9 +59,24 @@ export function PinPad({
|
|
|
29
59
|
onBiometric,
|
|
30
60
|
resetKey,
|
|
31
61
|
error,
|
|
62
|
+
haptics = true,
|
|
32
63
|
}: PinPadProps) {
|
|
33
64
|
const { colorScheme } = useColorScheme();
|
|
34
|
-
const
|
|
65
|
+
const isDark = colorScheme === "dark";
|
|
66
|
+
const iconColor = isDark ? "#FFFFFF" : "#000000";
|
|
67
|
+
const { width } = useWindowDimensions();
|
|
68
|
+
|
|
69
|
+
// Three keys and two gaps have to fit inside the viewport less the screen's
|
|
70
|
+
// padding. Clamped at both ends: MIN_KEY keeps the pad usable on a small
|
|
71
|
+
// phone even if that means it is the widest thing on screen, MAX_KEY stops a
|
|
72
|
+
// tablet from rendering three dinner plates.
|
|
73
|
+
const keySize = Math.max(
|
|
74
|
+
MIN_KEY,
|
|
75
|
+
Math.min(MAX_KEY, Math.floor((width - SIDE_PADDING - 2 * GAP) / 3))
|
|
76
|
+
);
|
|
77
|
+
const padWidth = keySize * 3 + GAP * 2;
|
|
78
|
+
const dotSize = Math.round(keySize * 0.17);
|
|
79
|
+
|
|
35
80
|
const [pin, setPin] = useState("");
|
|
36
81
|
// Source of truth for the latest digits — lets callbacks fire OUTSIDE the
|
|
37
82
|
// setPin updater (calling a parent setState inside an updater triggers
|
|
@@ -49,12 +94,30 @@ export function PinPad({
|
|
|
49
94
|
}
|
|
50
95
|
}
|
|
51
96
|
|
|
97
|
+
/**
|
|
98
|
+
* A tap you can feel.
|
|
99
|
+
*
|
|
100
|
+
* Deliberately fire-and-forget: the feedback is worth nothing if the digit
|
|
101
|
+
* waits for it, and a device with no haptic engine rejects the promise rather
|
|
102
|
+
* than throwing, which must not surface as an unhandled rejection over a
|
|
103
|
+
* keypress.
|
|
104
|
+
*/
|
|
105
|
+
const tap = useCallback(() => {
|
|
106
|
+
if (!haptics || Platform.OS === "web") {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light).catch(() => {
|
|
110
|
+
// No haptic engine, or the OS declined. Nothing to say about it.
|
|
111
|
+
});
|
|
112
|
+
}, [haptics]);
|
|
113
|
+
|
|
52
114
|
const press = useCallback(
|
|
53
115
|
(d: string) => {
|
|
54
116
|
const cur = pinRef.current;
|
|
55
117
|
if (cur.length >= length) {
|
|
56
118
|
return;
|
|
57
119
|
}
|
|
120
|
+
tap();
|
|
58
121
|
const next = cur + d;
|
|
59
122
|
pinRef.current = next;
|
|
60
123
|
setPin(next);
|
|
@@ -63,15 +126,19 @@ export function PinPad({
|
|
|
63
126
|
onComplete(next);
|
|
64
127
|
}
|
|
65
128
|
},
|
|
66
|
-
[length, onChange, onComplete]
|
|
129
|
+
[length, onChange, onComplete, tap]
|
|
67
130
|
);
|
|
68
131
|
|
|
69
132
|
const back = useCallback(() => {
|
|
133
|
+
if (pinRef.current.length === 0) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
tap();
|
|
70
137
|
const next = pinRef.current.slice(0, -1);
|
|
71
138
|
pinRef.current = next;
|
|
72
139
|
setPin(next);
|
|
73
140
|
onChange?.(next);
|
|
74
|
-
}, [onChange]);
|
|
141
|
+
}, [onChange, tap]);
|
|
75
142
|
|
|
76
143
|
// Physical keyboard / numpad support (web): digits type, Backspace/Delete
|
|
77
144
|
// removes, Enter submits a full PIN.
|
|
@@ -98,35 +165,46 @@ export function PinPad({
|
|
|
98
165
|
}, [press, back, onComplete, length]);
|
|
99
166
|
|
|
100
167
|
return (
|
|
101
|
-
<View className="items-center gap-
|
|
168
|
+
<View className="items-center gap-8">
|
|
102
169
|
{/* dots */}
|
|
103
|
-
<View className="flex-row gap
|
|
170
|
+
<View className="flex-row" style={{ gap: Math.round(dotSize * 0.9) }}>
|
|
104
171
|
{Array.from({ length }).map((_, i) => (
|
|
105
172
|
<View
|
|
106
173
|
key={i}
|
|
174
|
+
style={{ width: dotSize, height: dotSize, borderRadius: dotSize / 2 }}
|
|
107
175
|
className={
|
|
108
176
|
i < pin.length
|
|
109
177
|
? error
|
|
110
|
-
? "
|
|
111
|
-
: "
|
|
112
|
-
: "
|
|
178
|
+
? "bg-destructive dark:bg-red-900"
|
|
179
|
+
: "bg-primary dark:bg-white"
|
|
180
|
+
: "border-2 border-border dark:border-neutral-700"
|
|
113
181
|
}
|
|
114
182
|
/>
|
|
115
183
|
))}
|
|
116
184
|
</View>
|
|
117
185
|
|
|
118
186
|
{/* keypad */}
|
|
119
|
-
<View className="
|
|
187
|
+
<View className="flex-row flex-wrap justify-center" style={{ width: padWidth, gap: GAP }}>
|
|
120
188
|
{KEYS.map((k) => (
|
|
121
|
-
<Key key={k} label={k} onPress={() => press(k)} />
|
|
189
|
+
<Key key={k} label={k} size={keySize} onPress={() => press(k)} />
|
|
122
190
|
))}
|
|
123
191
|
{onBiometric ? (
|
|
124
|
-
<Key
|
|
192
|
+
<Key
|
|
193
|
+
size={keySize}
|
|
194
|
+
onPress={onBiometric}
|
|
195
|
+
icon={<Fingerprint size={Math.round(keySize * 0.34)} color={iconColor} />}
|
|
196
|
+
accessibilityLabel="Unlock with biometrics"
|
|
197
|
+
/>
|
|
125
198
|
) : (
|
|
126
|
-
<View
|
|
199
|
+
<View style={{ width: keySize, height: keySize }} />
|
|
127
200
|
)}
|
|
128
|
-
<Key label="0" onPress={() => press("0")} />
|
|
129
|
-
<Key
|
|
201
|
+
<Key label="0" size={keySize} onPress={() => press("0")} />
|
|
202
|
+
<Key
|
|
203
|
+
size={keySize}
|
|
204
|
+
onPress={back}
|
|
205
|
+
icon={<Delete size={Math.round(keySize * 0.3)} color={iconColor} />}
|
|
206
|
+
accessibilityLabel="Delete"
|
|
207
|
+
/>
|
|
130
208
|
</View>
|
|
131
209
|
</View>
|
|
132
210
|
);
|
|
@@ -135,23 +213,49 @@ export function PinPad({
|
|
|
135
213
|
function Key({
|
|
136
214
|
label,
|
|
137
215
|
icon,
|
|
216
|
+
size,
|
|
138
217
|
onPress,
|
|
218
|
+
accessibilityLabel,
|
|
139
219
|
}: {
|
|
140
220
|
label?: string;
|
|
141
221
|
icon?: React.ReactNode;
|
|
222
|
+
size: number;
|
|
142
223
|
onPress: () => void;
|
|
224
|
+
accessibilityLabel?: string;
|
|
143
225
|
}) {
|
|
226
|
+
// Same two-part response as Button: a dip in scale and a lift in fill. The
|
|
227
|
+
// fill change alone was too easy to miss on a dark screen, and on a keypad
|
|
228
|
+
// "did that register?" is the entire question the user is asking.
|
|
229
|
+
const pressed = useSharedValue(0);
|
|
230
|
+
const pressStyle = useAnimatedStyle(() => ({
|
|
231
|
+
transform: [{ scale: 1 - pressed.value * 0.08 }],
|
|
232
|
+
}));
|
|
233
|
+
|
|
144
234
|
return (
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
235
|
+
<Animated.View style={pressStyle}>
|
|
236
|
+
<Pressable
|
|
237
|
+
onPress={onPress}
|
|
238
|
+
onPressIn={() => {
|
|
239
|
+
pressed.value = withTiming(1, { duration: 60 });
|
|
240
|
+
}}
|
|
241
|
+
onPressOut={() => {
|
|
242
|
+
pressed.value = withTiming(0, { duration: 160 });
|
|
243
|
+
}}
|
|
244
|
+
style={{ width: size, height: size, borderRadius: size / 2 }}
|
|
245
|
+
className="items-center justify-center border border-border bg-secondary active:bg-border dark:border-neutral-700 dark:bg-neutral-900 dark:active:bg-neutral-700"
|
|
246
|
+
accessibilityRole="button"
|
|
247
|
+
accessibilityLabel={accessibilityLabel ?? label ?? "key"}
|
|
248
|
+
>
|
|
249
|
+
{icon ?? (
|
|
250
|
+
<Text
|
|
251
|
+
className="font-sans-medium text-foreground dark:text-white"
|
|
252
|
+
style={{ fontSize: Math.round(size * 0.38) }}
|
|
253
|
+
>
|
|
254
|
+
{label}
|
|
255
|
+
</Text>
|
|
256
|
+
)}
|
|
257
|
+
</Pressable>
|
|
258
|
+
</Animated.View>
|
|
155
259
|
);
|
|
156
260
|
}
|
|
157
261
|
|