@myazahq/kyc-sdk-react-native 2.3.0 → 2.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/package.json +1 -2
- package/src/components/BrandBar.tsx +137 -0
- package/src/components/DialCodePicker.tsx +11 -17
- package/src/components/DocumentReviewSide.tsx +34 -14
- package/src/components/Icon.tsx +5 -0
- package/src/components/KycSheet.tsx +39 -140
- package/src/components/MediaSourceSheet.tsx +7 -37
- package/src/components/MyazaDateField.tsx +6 -20
- package/src/components/MyazaSelect.tsx +15 -39
- package/src/components/SandboxBanner.tsx +92 -0
- package/src/components/StepHeader.tsx +15 -2
- package/src/components/glass/ChromeGlass.tsx +65 -0
- package/src/components/glass/FloatingSheet.tsx +190 -0
- package/src/components/glass/GlassSheet.tsx +38 -0
- package/src/components/glass/GlassSurface.tsx +31 -3
- package/src/components/viewfinder/ImmersiveBottomBar.tsx +28 -17
- package/src/components/viewfinder/ImmersiveControls.tsx +26 -14
- package/src/components/viewfinder/ViewfinderControls.tsx +29 -7
- package/src/services/deviceMetadata.ts +1 -1
- package/src/types/config.ts +7 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myazahq/kyc-sdk-react-native",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Myaza KYC SDK for React Native (Expo) — ID verification, liveness detection, and document capture",
|
|
5
5
|
"author": "Flitstack Technologies Inc.",
|
|
6
6
|
"repository": {
|
|
@@ -61,7 +61,6 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@expo-google-fonts/karla": "^0.4.2",
|
|
63
63
|
"@expo-google-fonts/space-grotesk": "^0.4.1",
|
|
64
|
-
"@expo/ui": "~56.0.16",
|
|
65
64
|
"@expo/vector-icons": "^14.0.4",
|
|
66
65
|
"country-flag-icons": "^1.6.17",
|
|
67
66
|
"expo-application": "~56.0.3",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Image, View } from 'react-native';
|
|
3
|
+
import { SvgXml } from 'react-native-svg';
|
|
4
|
+
|
|
5
|
+
import { radius, spacing } from '../config/theme';
|
|
6
|
+
import { useTheme } from './runtime';
|
|
7
|
+
import { MyazaText } from './Typography';
|
|
8
|
+
|
|
9
|
+
/** Whether a logo URI points at an SVG (extension check, query-safe). */
|
|
10
|
+
export function isSvgUri(uri: string): boolean {
|
|
11
|
+
const path = uri.split('?')[0] ?? uri;
|
|
12
|
+
return path.toLowerCase().endsWith('.svg');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Deliberately NOT on a Liquid Glass capsule, though it would balance the
|
|
16
|
+
// theme/close capsule opposite it. Glass at capsule scale is the material for
|
|
17
|
+
// controls — something that floats and responds to touch. The brand is a label,
|
|
18
|
+
// so a pill around it promises a tap that never does anything. The header block
|
|
19
|
+
// behind it is glass, which is the correct use: a surface, not a control.
|
|
20
|
+
//
|
|
21
|
+
// Persistent header brand — circular logo avatar + company name. A broken/missing
|
|
22
|
+
// logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
|
|
23
|
+
// Flutter's `errorBuilder`), so the header never shows a broken-image box.
|
|
24
|
+
export function BrandBar({
|
|
25
|
+
logoUri,
|
|
26
|
+
companyName,
|
|
27
|
+
}: {
|
|
28
|
+
logoUri: string;
|
|
29
|
+
companyName: string;
|
|
30
|
+
}): React.ReactElement | null {
|
|
31
|
+
const { colors } = useTheme();
|
|
32
|
+
const [broken, setBroken] = useState(false);
|
|
33
|
+
const svg = isSvgUri(logoUri);
|
|
34
|
+
// SVG logos render through SvgXml with SELF-FETCHED markup — the exact
|
|
35
|
+
// pipeline the country flags use, which is proven on-device. SvgUri's own
|
|
36
|
+
// fetch/sizing was flaky here (the favicon rendered at intrinsic size
|
|
37
|
+
// instead of filling the chip).
|
|
38
|
+
const [xml, setXml] = useState<string | null>(null);
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
if (!svg) return;
|
|
41
|
+
let cancelled = false;
|
|
42
|
+
fetch(logoUri)
|
|
43
|
+
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
|
44
|
+
.then((text) => {
|
|
45
|
+
if (!cancelled) setXml(text);
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {
|
|
48
|
+
if (!cancelled) setBroken(true);
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
cancelled = true;
|
|
52
|
+
};
|
|
53
|
+
}, [logoUri, svg]);
|
|
54
|
+
if (broken) return null;
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{/* TWO layers, deliberately.
|
|
58
|
+
|
|
59
|
+
A shadow and `overflow: 'hidden'` CANNOT live on the same view on iOS:
|
|
60
|
+
overflow-hidden compiles to `masksToBounds`, which clips the shadow
|
|
61
|
+
away with everything else outside the bounds. Android draws `elevation`
|
|
62
|
+
outside the view, so it survived there — which is why the chip lost its
|
|
63
|
+
ring on iPhone only.
|
|
64
|
+
|
|
65
|
+
So the outer view owns the border and shadow and does NOT clip, and the
|
|
66
|
+
inner one clips the logo. Same split Flutter gets for free by putting
|
|
67
|
+
the border on the Container's decoration and the crop in a ClipOval.
|
|
68
|
+
|
|
69
|
+
The white plate is load-bearing, not decoration: brand logos are
|
|
70
|
+
routinely dark artwork on a transparent background (the import picks up
|
|
71
|
+
favicons), which would vanish against a dark header. */}
|
|
72
|
+
<View
|
|
73
|
+
style={{
|
|
74
|
+
width: 28,
|
|
75
|
+
height: 28,
|
|
76
|
+
borderRadius: radius.full,
|
|
77
|
+
backgroundColor: '#FFFFFF',
|
|
78
|
+
// Light ring + soft shadow — the same treatment as the Flutter SDK's
|
|
79
|
+
// brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and the web
|
|
80
|
+
// SDK's ring-1 ring-black/5.
|
|
81
|
+
borderWidth: 1,
|
|
82
|
+
borderColor: 'rgba(0,0,0,0.05)',
|
|
83
|
+
shadowColor: '#000000',
|
|
84
|
+
shadowOpacity: 0.06,
|
|
85
|
+
shadowRadius: 4,
|
|
86
|
+
shadowOffset: { width: 0, height: 1 },
|
|
87
|
+
elevation: 1,
|
|
88
|
+
marginRight: spacing.sm,
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
justifyContent: 'center',
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
<View
|
|
94
|
+
style={{
|
|
95
|
+
// Fills the parent's CONTENT box (inside its 1px border) and is the
|
|
96
|
+
// only thing that clips, so the logo reaches the ring on every side.
|
|
97
|
+
alignSelf: 'stretch',
|
|
98
|
+
flex: 1,
|
|
99
|
+
borderRadius: radius.full,
|
|
100
|
+
overflow: 'hidden',
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
{/* Fill + cover-crop, matching Flutter's SizedBox.expand + BoxFit.cover.
|
|
104
|
+
|
|
105
|
+
Sized in PERCENTAGES, not the literal 26 this used to hardcode: that
|
|
106
|
+
number was "28 minus the 1px border on each side", so it silently
|
|
107
|
+
went wrong the moment either value changed, and it left the logo a
|
|
108
|
+
pixel short of the ring. The clipping parent now owns the geometry.
|
|
109
|
+
|
|
110
|
+
SVG logos are common here because the brand import picks up site
|
|
111
|
+
favicons, and <Image> cannot decode SVG at all — hence the fetched
|
|
112
|
+
markup through SvgXml. `slice` is the SVG spelling of cover-crop. */}
|
|
113
|
+
{svg ? (
|
|
114
|
+
xml ? (
|
|
115
|
+
<SvgXml xml={xml} width="100%" height="100%" preserveAspectRatio="xMidYMid slice" />
|
|
116
|
+
) : null
|
|
117
|
+
) : (
|
|
118
|
+
<Image
|
|
119
|
+
source={{ uri: logoUri }}
|
|
120
|
+
style={{ width: '100%', height: '100%' }}
|
|
121
|
+
resizeMode="cover"
|
|
122
|
+
onError={() => setBroken(true)}
|
|
123
|
+
/>
|
|
124
|
+
)}
|
|
125
|
+
</View>
|
|
126
|
+
</View>
|
|
127
|
+
<MyazaText
|
|
128
|
+
variant="heading3"
|
|
129
|
+
numberOfLines={1}
|
|
130
|
+
color={colors.textDark}
|
|
131
|
+
style={{ flexShrink: 1, fontSize: 14 }}
|
|
132
|
+
>
|
|
133
|
+
{companyName}
|
|
134
|
+
</MyazaText>
|
|
135
|
+
</>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -2,19 +2,19 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|
|
2
2
|
import {
|
|
3
3
|
FlatList,
|
|
4
4
|
Keyboard,
|
|
5
|
-
Modal,
|
|
6
5
|
Platform,
|
|
7
6
|
Pressable,
|
|
8
7
|
useWindowDimensions,
|
|
9
8
|
View,
|
|
10
9
|
} from 'react-native';
|
|
11
10
|
|
|
12
|
-
import {
|
|
11
|
+
import { spacing } from '../config/theme';
|
|
13
12
|
import { useTheme } from './runtime';
|
|
14
13
|
import { MyazaText } from './Typography';
|
|
15
14
|
import { MyazaInput } from './MyazaInput';
|
|
16
15
|
import { Icon } from './Icon';
|
|
17
16
|
import { CountryFlag } from './CountryFlag';
|
|
17
|
+
import { FloatingSheet } from './glass/FloatingSheet';
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
// THE country sheet — the phone field's dial-code picker, generalised.
|
|
@@ -98,19 +98,14 @@ export function DialCodePicker({
|
|
|
98
98
|
};
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
borderTopRightRadius: radius.lg,
|
|
110
|
-
paddingTop: spacing.md,
|
|
111
|
-
paddingBottom: spacing.sm,
|
|
112
|
-
}}
|
|
113
|
-
>
|
|
101
|
+
<FloatingSheet
|
|
102
|
+
visible={visible}
|
|
103
|
+
onClose={close}
|
|
104
|
+
maxHeight={maxHeight}
|
|
105
|
+
// Ride above a raised keyboard — this sheet's whole job is a search box.
|
|
106
|
+
bottomOffset={keyboard}
|
|
107
|
+
closeLabel="Close country picker"
|
|
108
|
+
>
|
|
114
109
|
<View style={{ paddingHorizontal: spacing.md, paddingBottom: spacing.sm }}>
|
|
115
110
|
<MyazaInput
|
|
116
111
|
value={query}
|
|
@@ -141,8 +136,7 @@ export function DialCodePicker({
|
|
|
141
136
|
/>
|
|
142
137
|
)}
|
|
143
138
|
/>
|
|
144
|
-
|
|
145
|
-
</Modal>
|
|
139
|
+
</FloatingSheet>
|
|
146
140
|
);
|
|
147
141
|
}
|
|
148
142
|
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { Image, Pressable, View } from 'react-native';
|
|
2
|
+
import { Image, Pressable, View, type ViewStyle } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { useTheme } from './runtime';
|
|
5
5
|
import { Icon } from './Icon';
|
|
6
6
|
import { MyazaText } from './Typography';
|
|
7
7
|
import { radius, spacing } from '../config/theme';
|
|
8
|
+
import { CHROME_SCRIM, ChromeGlass } from './glass/ChromeGlass';
|
|
9
|
+
|
|
10
|
+
/** The chip's flat backing, and still the fallback off iOS 26. */
|
|
11
|
+
const CHIP_SCRIM = CHROME_SCRIM;
|
|
8
12
|
|
|
9
13
|
// ─── One captured side, and its enlarged view ─────────────────────────────────
|
|
10
14
|
//
|
|
@@ -67,7 +71,7 @@ export function DocumentReviewThumb({
|
|
|
67
71
|
{/* Enlarging is a tap on the image, which nothing announces — so the
|
|
68
72
|
image says so itself. */}
|
|
69
73
|
<View style={{ position: 'absolute', right: spacing.md, bottom: spacing.md }} pointerEvents="none">
|
|
70
|
-
<Chip icon>
|
|
74
|
+
<Chip icon glass>
|
|
71
75
|
<Icon name="maximize" size={18} color="#FFFFFF" />
|
|
72
76
|
</Chip>
|
|
73
77
|
</View>
|
|
@@ -116,20 +120,36 @@ export function Chip({
|
|
|
116
120
|
children,
|
|
117
121
|
/** Icon chips are square and need room around the glyph, not text padding. */
|
|
118
122
|
icon = false,
|
|
123
|
+
/**
|
|
124
|
+
* Render on Liquid Glass instead of the flat scrim.
|
|
125
|
+
*
|
|
126
|
+
* Only for a chip that ANNOUNCES AN ACTION. It looks like an exception to the
|
|
127
|
+
* "glass is for controls" rule, since the chip itself is `pointerEvents:
|
|
128
|
+
* none` — but the photo behind it is the button, and the chip sits inside
|
|
129
|
+
* that target, so a tap on it really does fire. The promise is kept.
|
|
130
|
+
*
|
|
131
|
+
* A chip that only labels something (FRONT / BACK) stays flat: glass would
|
|
132
|
+
* offer a tap it cannot honour.
|
|
133
|
+
*/
|
|
134
|
+
glass = false,
|
|
119
135
|
}: {
|
|
120
136
|
children: React.ReactNode;
|
|
121
137
|
icon?: boolean;
|
|
138
|
+
glass?: boolean;
|
|
122
139
|
}): React.ReactElement {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
const shape: ViewStyle = {
|
|
141
|
+
paddingHorizontal: icon ? 8 : 10,
|
|
142
|
+
paddingVertical: icon ? 8 : 5,
|
|
143
|
+
borderRadius: radius.full,
|
|
144
|
+
};
|
|
145
|
+
// Not `interactive`: the chip never receives the touch (the image below it
|
|
146
|
+
// does), so asking the glass to react to one would be asking for nothing.
|
|
147
|
+
if (glass) {
|
|
148
|
+
return (
|
|
149
|
+
<ChromeGlass scrim={CHIP_SCRIM} style={shape}>
|
|
150
|
+
{children}
|
|
151
|
+
</ChromeGlass>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
return <View style={[shape, { backgroundColor: CHIP_SCRIM }]}>{children}</View>;
|
|
135
155
|
}
|
package/src/components/Icon.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
CreditCard,
|
|
18
18
|
Fingerprint,
|
|
19
19
|
FileText,
|
|
20
|
+
FlaskConical,
|
|
20
21
|
IdCard,
|
|
21
22
|
Image as ImageIcon,
|
|
22
23
|
Landmark,
|
|
@@ -68,6 +69,7 @@ export type IconName =
|
|
|
68
69
|
| 'check'
|
|
69
70
|
| 'lock'
|
|
70
71
|
| 'alert'
|
|
72
|
+
| 'flask'
|
|
71
73
|
| 'refresh'
|
|
72
74
|
| 'maximize'
|
|
73
75
|
| 'calendar'
|
|
@@ -128,6 +130,9 @@ const ICONS: Record<IconName, LucideIcon> = {
|
|
|
128
130
|
check: Check,
|
|
129
131
|
lock: Lock,
|
|
130
132
|
alert: CircleAlert,
|
|
133
|
+
// The environment banner's mark, shared with the web SDK (same lucide glyph)
|
|
134
|
+
// and mirrored by Flutter's Icons.science_outlined.
|
|
135
|
+
flask: FlaskConical,
|
|
131
136
|
refresh: RefreshCw,
|
|
132
137
|
maximize: Maximize2,
|
|
133
138
|
calendar: Calendar,
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import {
|
|
3
|
-
import { SvgXml } from "react-native-svg";
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Platform, ScrollView, StatusBar, View } from "react-native";
|
|
4
3
|
import { StatusBarController } from "./StatusBarController";
|
|
5
4
|
import { initialWindowMetrics } from "react-native-safe-area-context";
|
|
6
5
|
|
|
7
|
-
import { headerSurface,
|
|
6
|
+
import { headerSurface, spacing } from "../config/theme";
|
|
8
7
|
import { useKeyboardInset } from "../lib/use-keyboard-inset";
|
|
9
8
|
import type { SupportedCountry } from "../types/config";
|
|
10
9
|
import { useKycConfig, useTheme } from "./runtime";
|
|
11
10
|
import { useBranding } from "./useBranding";
|
|
12
|
-
import {
|
|
11
|
+
import { BrandBar } from "./BrandBar";
|
|
13
12
|
import { PoweredBy } from "./PoweredBy";
|
|
14
13
|
import { StepHeader } from "./StepHeader";
|
|
15
14
|
import { StepIndicator } from "./StepIndicator";
|
|
16
15
|
import { ProgressBar } from "./ProgressBar";
|
|
17
16
|
import { GlassIconButton } from "./GlassIconButton";
|
|
18
17
|
import { GlassSurface } from "./glass/GlassSurface";
|
|
18
|
+
import { SandboxBanner, useSandboxBannerVisible } from "./SandboxBanner";
|
|
19
19
|
import { GlassGroup } from "./glass/GlassGroup";
|
|
20
20
|
import { FlashOverlay } from "./FlashOverlay";
|
|
21
21
|
|
|
@@ -84,13 +84,31 @@ export function KycSheet({
|
|
|
84
84
|
const keyboardInset = useKeyboardInset();
|
|
85
85
|
|
|
86
86
|
const tint = headerSurface(colors, mode);
|
|
87
|
+
// Decides who absorbs the status-bar inset: the banner sits above the header,
|
|
88
|
+
// so when it renders it is the topmost element and takes it instead.
|
|
89
|
+
const bannerVisible = useSandboxBannerVisible();
|
|
87
90
|
const hasProgress = progress != null && stepCount != null;
|
|
88
|
-
// The
|
|
89
|
-
// indicators on one header would be
|
|
90
|
-
// it costs no height
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const
|
|
91
|
+
// The three styles are mutually exclusive: the bar REPLACES the step row
|
|
92
|
+
// rather than joining it (two progress indicators on one header would be
|
|
93
|
+
// noise, and the point of the bar is that it costs no height), and 'none'
|
|
94
|
+
// drops both. Switching on the resolved style rather than negating flags
|
|
95
|
+
// keeps a future fourth style from silently falling into the 'steps' branch.
|
|
96
|
+
const progressStyle = config.progressStyle ?? "steps";
|
|
97
|
+
const showIndicator = hasProgress && progressStyle === "steps";
|
|
98
|
+
const showBar = hasProgress && progressStyle === "bar";
|
|
99
|
+
|
|
100
|
+
// The header's bottom padding is the GAP to whatever sits under the brand
|
|
101
|
+
// row — the title block or the step row. With neither (a step that owns its
|
|
102
|
+
// own title, and progress off or drawn as the edge bar), that padding is
|
|
103
|
+
// space to a row that never renders, and the brand + controls read as
|
|
104
|
+
// sitting high in the band rather than centred on one line. Fall back to the
|
|
105
|
+
// row's own top padding so the two edges match.
|
|
106
|
+
//
|
|
107
|
+
// The bar is exempt: it is absolutely positioned ON the bottom edge, so the
|
|
108
|
+
// wider padding is what keeps it clear of the controls.
|
|
109
|
+
const hasRowBelowBrand = !!(title || onBack) || showIndicator;
|
|
110
|
+
const headerPaddingBottom =
|
|
111
|
+
hasRowBelowBrand || showBar ? spacing.md : spacing.sm;
|
|
94
112
|
const showBrand = !hideBrand && !!logoUri;
|
|
95
113
|
|
|
96
114
|
// On iOS the modal is a pageSheet by default (sits below the status bar), but
|
|
@@ -143,6 +161,11 @@ export function KycSheet({
|
|
|
143
161
|
/>
|
|
144
162
|
) : null}
|
|
145
163
|
<View style={{ flex: 1 }}>
|
|
164
|
+
{/* Above the header block (and outside the immersive branch) so a
|
|
165
|
+
capture step that hides the chrome still says it is not live. It is
|
|
166
|
+
then the topmost element, so it — not the header — absorbs the
|
|
167
|
+
status-bar inset. */}
|
|
168
|
+
<SandboxBanner topInset={topInset} />
|
|
146
169
|
{/* Header block (glass on iOS 26, tinted surface otherwise) — dropped
|
|
147
170
|
entirely when the step owns the display. */}
|
|
148
171
|
{immersive ? null : (
|
|
@@ -154,7 +177,7 @@ export function KycSheet({
|
|
|
154
177
|
// border would double it.
|
|
155
178
|
borderBottomWidth: showBar ? 0 : 1,
|
|
156
179
|
borderBottomColor: colors.border,
|
|
157
|
-
paddingBottom:
|
|
180
|
+
paddingBottom: headerPaddingBottom,
|
|
158
181
|
}}
|
|
159
182
|
>
|
|
160
183
|
{/* Row 1 — brand + controls */}
|
|
@@ -163,7 +186,10 @@ export function KycSheet({
|
|
|
163
186
|
flexDirection: "row",
|
|
164
187
|
alignItems: "center",
|
|
165
188
|
paddingHorizontal: spacing.md,
|
|
166
|
-
|
|
189
|
+
// The banner, when shown, already cleared the status bar above
|
|
190
|
+
// us — padding for it again would leave a gap the width of the
|
|
191
|
+
// notch between the strip and the brand row.
|
|
192
|
+
paddingTop: (bannerVisible ? 0 : topInset) + spacing.sm,
|
|
167
193
|
}}
|
|
168
194
|
>
|
|
169
195
|
<View
|
|
@@ -307,130 +333,3 @@ export function KycSheet({
|
|
|
307
333
|
</View>
|
|
308
334
|
);
|
|
309
335
|
}
|
|
310
|
-
|
|
311
|
-
/** Whether a logo URI points at an SVG (extension check, query-safe). */
|
|
312
|
-
function isSvgUri(uri: string): boolean {
|
|
313
|
-
const path = uri.split("?")[0] ?? uri;
|
|
314
|
-
return path.toLowerCase().endsWith(".svg");
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Persistent header brand — circular logo avatar + company name. A broken/missing
|
|
318
|
-
// logo image collapses the whole brand bar (mirrors the web SDK's `onError` and
|
|
319
|
-
// Flutter's `errorBuilder`), so the header never shows a broken-image box.
|
|
320
|
-
function BrandBar({
|
|
321
|
-
logoUri,
|
|
322
|
-
companyName,
|
|
323
|
-
}: {
|
|
324
|
-
logoUri: string;
|
|
325
|
-
companyName: string;
|
|
326
|
-
}): React.ReactElement | null {
|
|
327
|
-
const { colors } = useTheme();
|
|
328
|
-
const [broken, setBroken] = useState(false);
|
|
329
|
-
const svg = isSvgUri(logoUri);
|
|
330
|
-
// SVG logos render through SvgXml with SELF-FETCHED markup — the exact
|
|
331
|
-
// pipeline the country flags use, which is proven on-device. SvgUri's own
|
|
332
|
-
// fetch/sizing was flaky here (the favicon rendered at intrinsic size
|
|
333
|
-
// instead of filling the chip).
|
|
334
|
-
const [xml, setXml] = useState<string | null>(null);
|
|
335
|
-
useEffect(() => {
|
|
336
|
-
if (!svg) return;
|
|
337
|
-
let cancelled = false;
|
|
338
|
-
fetch(logoUri)
|
|
339
|
-
.then((r) =>
|
|
340
|
-
r.ok ? r.text() : Promise.reject(new Error(String(r.status))),
|
|
341
|
-
)
|
|
342
|
-
.then((text) => {
|
|
343
|
-
if (!cancelled) setXml(text);
|
|
344
|
-
})
|
|
345
|
-
.catch(() => {
|
|
346
|
-
if (!cancelled) setBroken(true);
|
|
347
|
-
});
|
|
348
|
-
return () => {
|
|
349
|
-
cancelled = true;
|
|
350
|
-
};
|
|
351
|
-
}, [logoUri, svg]);
|
|
352
|
-
if (broken) return null;
|
|
353
|
-
return (
|
|
354
|
-
<>
|
|
355
|
-
{/* TWO layers, deliberately.
|
|
356
|
-
|
|
357
|
-
A shadow and `overflow: 'hidden'` CANNOT live on the same view on iOS:
|
|
358
|
-
overflow-hidden compiles to `masksToBounds`, which clips the shadow
|
|
359
|
-
away with everything else outside the bounds. Android draws `elevation`
|
|
360
|
-
outside the view, so it survived there — which is why the chip lost its
|
|
361
|
-
ring on iPhone only.
|
|
362
|
-
|
|
363
|
-
So the outer view owns the border and shadow and does NOT clip, and the
|
|
364
|
-
inner one clips the logo. Same split Flutter gets for free by putting
|
|
365
|
-
the border on the Container's decoration and the crop in a ClipOval. */}
|
|
366
|
-
<View
|
|
367
|
-
style={{
|
|
368
|
-
width: 28,
|
|
369
|
-
height: 28,
|
|
370
|
-
borderRadius: radius.full,
|
|
371
|
-
backgroundColor: "#FFFFFF",
|
|
372
|
-
// Light ring + soft shadow — the same treatment as the Flutter SDK's
|
|
373
|
-
// brand chip (black 5% ring, black 6% blur-4 y-1 shadow) and the web
|
|
374
|
-
// SDK's ring-1 ring-black/5.
|
|
375
|
-
borderWidth: 1,
|
|
376
|
-
borderColor: "rgba(0,0,0,0.05)",
|
|
377
|
-
shadowColor: "#000000",
|
|
378
|
-
shadowOpacity: 0.06,
|
|
379
|
-
shadowRadius: 4,
|
|
380
|
-
shadowOffset: { width: 0, height: 1 },
|
|
381
|
-
elevation: 1,
|
|
382
|
-
marginRight: spacing.sm,
|
|
383
|
-
alignItems: "center",
|
|
384
|
-
justifyContent: "center",
|
|
385
|
-
}}
|
|
386
|
-
>
|
|
387
|
-
<View
|
|
388
|
-
style={{
|
|
389
|
-
// Fills the parent's CONTENT box (inside its 1px border) and is the
|
|
390
|
-
// only thing that clips, so the logo reaches the ring on every side.
|
|
391
|
-
alignSelf: "stretch",
|
|
392
|
-
flex: 1,
|
|
393
|
-
borderRadius: radius.full,
|
|
394
|
-
overflow: "hidden",
|
|
395
|
-
}}
|
|
396
|
-
>
|
|
397
|
-
{/* Fill + cover-crop, matching Flutter's SizedBox.expand + BoxFit.cover.
|
|
398
|
-
|
|
399
|
-
Sized in PERCENTAGES, not the literal 26 this used to hardcode: that
|
|
400
|
-
number was "28 minus the 1px border on each side", so it silently
|
|
401
|
-
went wrong the moment either value changed, and it left the logo a
|
|
402
|
-
pixel short of the ring. The clipping parent now owns the geometry.
|
|
403
|
-
|
|
404
|
-
SVG logos are common here because the brand import picks up site
|
|
405
|
-
favicons, and <Image> cannot decode SVG at all — hence the fetched
|
|
406
|
-
markup through SvgXml. `slice` is the SVG spelling of cover-crop. */}
|
|
407
|
-
{svg ? (
|
|
408
|
-
xml ? (
|
|
409
|
-
<SvgXml
|
|
410
|
-
xml={xml}
|
|
411
|
-
width="100%"
|
|
412
|
-
height="100%"
|
|
413
|
-
preserveAspectRatio="xMidYMid slice"
|
|
414
|
-
/>
|
|
415
|
-
) : null
|
|
416
|
-
) : (
|
|
417
|
-
<Image
|
|
418
|
-
source={{ uri: logoUri }}
|
|
419
|
-
style={{ width: "100%", height: "100%" }}
|
|
420
|
-
resizeMode="cover"
|
|
421
|
-
onError={() => setBroken(true)}
|
|
422
|
-
/>
|
|
423
|
-
)}
|
|
424
|
-
</View>
|
|
425
|
-
</View>
|
|
426
|
-
<MyazaText
|
|
427
|
-
variant="heading3"
|
|
428
|
-
numberOfLines={1}
|
|
429
|
-
color={colors.textDark}
|
|
430
|
-
style={{ flexShrink: 1, fontSize: 14 }}
|
|
431
|
-
>
|
|
432
|
-
{companyName}
|
|
433
|
-
</MyazaText>
|
|
434
|
-
</>
|
|
435
|
-
);
|
|
436
|
-
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import React, { useRef } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Platform, Pressable, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import { Icon, type IconName } from './Icon';
|
|
6
6
|
import { useTheme } from './runtime';
|
|
7
7
|
import { MyazaText } from './Typography';
|
|
8
|
+
import { FloatingSheet } from './glass/FloatingSheet';
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* The SDK's own "where is the document?" sheet.
|
|
@@ -61,46 +62,16 @@ export function MediaSourceSheet({
|
|
|
61
62
|
};
|
|
62
63
|
|
|
63
64
|
return (
|
|
64
|
-
<
|
|
65
|
-
transparent
|
|
65
|
+
<FloatingSheet
|
|
66
66
|
visible={open}
|
|
67
|
-
|
|
68
|
-
onRequestClose={onClose}
|
|
67
|
+
onClose={onClose}
|
|
69
68
|
onDismiss={() => {
|
|
70
69
|
const run = pendingRef.current;
|
|
71
70
|
pendingRef.current = null;
|
|
72
71
|
run?.();
|
|
73
72
|
}}
|
|
74
73
|
>
|
|
75
|
-
<
|
|
76
|
-
onPress={onClose}
|
|
77
|
-
style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
78
|
-
>
|
|
79
|
-
{/* Swallow taps on the card so only the backdrop dismisses. */}
|
|
80
|
-
<Pressable
|
|
81
|
-
onPress={() => undefined}
|
|
82
|
-
style={{
|
|
83
|
-
backgroundColor: colors.background,
|
|
84
|
-
borderTopLeftRadius: radius.xl,
|
|
85
|
-
borderTopRightRadius: radius.xl,
|
|
86
|
-
padding: spacing.md,
|
|
87
|
-
paddingBottom: spacing.xl,
|
|
88
|
-
}}
|
|
89
|
-
>
|
|
90
|
-
{/* Grab handle — the affordance that says "sheet", and therefore
|
|
91
|
-
"swipe-away-able", without a close button competing with the
|
|
92
|
-
options. */}
|
|
93
|
-
<View
|
|
94
|
-
style={{
|
|
95
|
-
alignSelf: 'center',
|
|
96
|
-
width: 36,
|
|
97
|
-
height: 4,
|
|
98
|
-
borderRadius: 2,
|
|
99
|
-
backgroundColor: colors.border,
|
|
100
|
-
marginBottom: spacing.md,
|
|
101
|
-
}}
|
|
102
|
-
/>
|
|
103
|
-
|
|
74
|
+
<View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
|
|
104
75
|
<MyazaText variant="heading3" style={{ textAlign: 'center', marginBottom: spacing.md }}>
|
|
105
76
|
{title}
|
|
106
77
|
</MyazaText>
|
|
@@ -156,8 +127,7 @@ export function MediaSourceSheet({
|
|
|
156
127
|
Cancel
|
|
157
128
|
</MyazaText>
|
|
158
129
|
</Pressable>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
</Modal>
|
|
130
|
+
</View>
|
|
131
|
+
</FloatingSheet>
|
|
162
132
|
);
|
|
163
133
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { Pressable, ScrollView, View } from 'react-native';
|
|
3
3
|
|
|
4
4
|
import { radius, spacing } from '../config/theme';
|
|
5
5
|
import {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
import { Icon } from './Icon';
|
|
14
14
|
import { useTheme } from './runtime';
|
|
15
15
|
import { MyazaText } from './Typography';
|
|
16
|
+
import { FloatingSheet } from './glass/FloatingSheet';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* A date field that opens a REAL calendar picker, replacing the "type
|
|
@@ -117,22 +118,8 @@ function DatePickerSheet({
|
|
|
117
118
|
!!initial && initial.year === cursor.year && initial.month0 === cursor.month0 && initial.day === d;
|
|
118
119
|
|
|
119
120
|
return (
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
onPress={onClose}
|
|
123
|
-
style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' }}
|
|
124
|
-
>
|
|
125
|
-
{/* Stop the backdrop press from closing when the card itself is tapped. */}
|
|
126
|
-
<Pressable
|
|
127
|
-
onPress={() => undefined}
|
|
128
|
-
style={{
|
|
129
|
-
backgroundColor: colors.background,
|
|
130
|
-
borderTopLeftRadius: radius.xl,
|
|
131
|
-
borderTopRightRadius: radius.xl,
|
|
132
|
-
padding: spacing.md,
|
|
133
|
-
paddingBottom: spacing.xl,
|
|
134
|
-
}}
|
|
135
|
-
>
|
|
121
|
+
<FloatingSheet visible onClose={onClose} closeLabel="Close date picker">
|
|
122
|
+
<View style={{ padding: spacing.md, paddingTop: spacing.sm }}>
|
|
136
123
|
{/* Month header: step, or tap the title to jump years. */}
|
|
137
124
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: spacing.sm }}>
|
|
138
125
|
<Pressable
|
|
@@ -233,8 +220,7 @@ function DatePickerSheet({
|
|
|
233
220
|
))}
|
|
234
221
|
</>
|
|
235
222
|
)}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
</Modal>
|
|
223
|
+
</View>
|
|
224
|
+
</FloatingSheet>
|
|
239
225
|
);
|
|
240
226
|
}
|