@ramonclaudio/create-vexpo 0.1.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 +50 -0
- package/dist/index.js +183 -0
- package/dist/templates/default/.eas/workflows/asc-events.yml +84 -0
- package/dist/templates/default/.eas/workflows/deploy-production.yml +129 -0
- package/dist/templates/default/.eas/workflows/development-builds.yml +19 -0
- package/dist/templates/default/.eas/workflows/e2e-tests.yml +42 -0
- package/dist/templates/default/.eas/workflows/pr-preview.yml +98 -0
- package/dist/templates/default/.eas/workflows/release.yml +44 -0
- package/dist/templates/default/.eas/workflows/rollback.yml +86 -0
- package/dist/templates/default/.eas/workflows/rollout.yml +84 -0
- package/dist/templates/default/.eas/workflows/rotate-apple-jwt.yml +42 -0
- package/dist/templates/default/.eas/workflows/testflight.yml +57 -0
- package/dist/templates/default/.github/workflows/check.yml +28 -0
- package/dist/templates/default/.maestro/launch.yaml +18 -0
- package/dist/templates/default/AGENTS.md +79 -0
- package/dist/templates/default/DESIGN.md +331 -0
- package/dist/templates/default/LICENSE +21 -0
- package/dist/templates/default/README.md +153 -0
- package/dist/templates/default/SETUP.md +618 -0
- package/dist/templates/default/__tests__/convex/constants.test.ts +49 -0
- package/dist/templates/default/__tests__/convex/validators.test.ts +23 -0
- package/dist/templates/default/__tests__/convex/webhook.test.ts +343 -0
- package/dist/templates/default/__tests__/lib/deep-link.test.ts +67 -0
- package/dist/templates/default/_easignore +22 -0
- package/dist/templates/default/_editorconfig +9 -0
- package/dist/templates/default/_env.example +34 -0
- package/dist/templates/default/_fingerprintignore +24 -0
- package/dist/templates/default/_gitattributes +7 -0
- package/dist/templates/default/_gitignore +69 -0
- package/dist/templates/default/_oxfmtrc.json +3 -0
- package/dist/templates/default/_oxlintrc.json +34 -0
- package/dist/templates/default/app/(app)/(tabs)/(home)/index.tsx +50 -0
- package/dist/templates/default/app/(app)/(tabs)/(home,search)/_layout.tsx +44 -0
- package/dist/templates/default/app/(app)/(tabs)/(search)/index.tsx +247 -0
- package/dist/templates/default/app/(app)/(tabs)/_layout.tsx +77 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/_layout.tsx +37 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/index.tsx +362 -0
- package/dist/templates/default/app/(app)/(tabs)/settings/preferences.tsx +184 -0
- package/dist/templates/default/app/(app)/_layout.tsx +73 -0
- package/dist/templates/default/app/(app)/debug.tsx +389 -0
- package/dist/templates/default/app/(app)/help.tsx +254 -0
- package/dist/templates/default/app/(app)/linked.tsx +116 -0
- package/dist/templates/default/app/(app)/privacy.tsx +159 -0
- package/dist/templates/default/app/(app)/profile.tsx +915 -0
- package/dist/templates/default/app/(app)/sessions.tsx +191 -0
- package/dist/templates/default/app/(app)/welcome.tsx +140 -0
- package/dist/templates/default/app/(auth)/_layout.tsx +31 -0
- package/dist/templates/default/app/(auth)/forgot-password.tsx +168 -0
- package/dist/templates/default/app/(auth)/reset-password.tsx +314 -0
- package/dist/templates/default/app/(auth)/sign-in.tsx +453 -0
- package/dist/templates/default/app/(auth)/sign-up.tsx +563 -0
- package/dist/templates/default/app/+native-intent.tsx +14 -0
- package/dist/templates/default/app/+not-found.tsx +51 -0
- package/dist/templates/default/app/_layout.tsx +102 -0
- package/dist/templates/default/app-store/screenshots/.gitkeep +0 -0
- package/dist/templates/default/app-store/screenshots/README.md +13 -0
- package/dist/templates/default/app.config.ts +201 -0
- package/dist/templates/default/app.json +11 -0
- package/dist/templates/default/assets/brand-icon-dark.png +0 -0
- package/dist/templates/default/assets/brand-icon-light.png +0 -0
- package/dist/templates/default/assets/fonts/Geist-Black.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BlackItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLight.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ExtraLightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Light.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-LightItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBold.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-SemiBoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Thin.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-ThinItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/Geist-Variable.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-BoldItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Italic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Medium.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-MediumItalic.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/dist/templates/default/assets/fonts/GeistPixel-Square.ttf +0 -0
- package/dist/templates/default/assets/icon.png +0 -0
- package/dist/templates/default/assets/sounds/notification.wav +0 -0
- package/dist/templates/default/assets/splash-image-dark.png +0 -0
- package/dist/templates/default/assets/splash-image-light.png +0 -0
- package/dist/templates/default/bun.lock +1860 -0
- package/dist/templates/default/components/auth/otp-verification.tsx +255 -0
- package/dist/templates/default/components/auth/password-field.tsx +121 -0
- package/dist/templates/default/components/auth/segmented-toggle.tsx +47 -0
- package/dist/templates/default/components/ui/convex-error.tsx +32 -0
- package/dist/templates/default/components/ui/error-boundary.tsx +57 -0
- package/dist/templates/default/components/ui/loading-screen.tsx +31 -0
- package/dist/templates/default/components/ui/material.tsx +94 -0
- package/dist/templates/default/components/ui/offline-banner.tsx +58 -0
- package/dist/templates/default/components/ui/prominent-button.tsx +71 -0
- package/dist/templates/default/components/ui/skeleton.tsx +107 -0
- package/dist/templates/default/components/ui/status-text.tsx +49 -0
- package/dist/templates/default/components/ui/update-banner.tsx +82 -0
- package/dist/templates/default/constants/layout.ts +102 -0
- package/dist/templates/default/constants/theme.ts +401 -0
- package/dist/templates/default/constants/ui.ts +77 -0
- package/dist/templates/default/convex/_generated/api.d.ts +77 -0
- package/dist/templates/default/convex/_generated/api.js +23 -0
- package/dist/templates/default/convex/_generated/dataModel.d.ts +60 -0
- package/dist/templates/default/convex/_generated/server.d.ts +143 -0
- package/dist/templates/default/convex/_generated/server.js +93 -0
- package/dist/templates/default/convex/admin.ts +102 -0
- package/dist/templates/default/convex/auth.config.ts +6 -0
- package/dist/templates/default/convex/auth.ts +335 -0
- package/dist/templates/default/convex/constants.ts +46 -0
- package/dist/templates/default/convex/convex.config.ts +11 -0
- package/dist/templates/default/convex/crons.ts +42 -0
- package/dist/templates/default/convex/email.ts +109 -0
- package/dist/templates/default/convex/env.ts +31 -0
- package/dist/templates/default/convex/errors.ts +33 -0
- package/dist/templates/default/convex/functions.ts +54 -0
- package/dist/templates/default/convex/http.ts +176 -0
- package/dist/templates/default/convex/log.ts +81 -0
- package/dist/templates/default/convex/pushTokens.ts +114 -0
- package/dist/templates/default/convex/rateLimit.ts +92 -0
- package/dist/templates/default/convex/schema.ts +28 -0
- package/dist/templates/default/convex/tsconfig.json +18 -0
- package/dist/templates/default/convex/users.ts +279 -0
- package/dist/templates/default/convex/validators.ts +74 -0
- package/dist/templates/default/convex/webhook.ts +193 -0
- package/dist/templates/default/convex.json +6 -0
- package/dist/templates/default/eas.json +56 -0
- package/dist/templates/default/fingerprint.config.js +9 -0
- package/dist/templates/default/hooks/use-debounce.ts +20 -0
- package/dist/templates/default/hooks/use-deep-link.ts +43 -0
- package/dist/templates/default/hooks/use-navigation-tracking.ts +15 -0
- package/dist/templates/default/hooks/use-network.ts +11 -0
- package/dist/templates/default/hooks/use-notifications.ts +107 -0
- package/dist/templates/default/hooks/use-onboarding.ts +15 -0
- package/dist/templates/default/hooks/use-reduced-motion.ts +11 -0
- package/dist/templates/default/hooks/use-theme.ts +53 -0
- package/dist/templates/default/hooks/use-updates.ts +86 -0
- package/dist/templates/default/lib/a11y.ts +5 -0
- package/dist/templates/default/lib/app.ts +14 -0
- package/dist/templates/default/lib/assets.ts +17 -0
- package/dist/templates/default/lib/auth-client.ts +21 -0
- package/dist/templates/default/lib/convex-auth.tsx +79 -0
- package/dist/templates/default/lib/deep-link.ts +71 -0
- package/dist/templates/default/lib/dev-menu.ts +119 -0
- package/dist/templates/default/lib/device.ts +40 -0
- package/dist/templates/default/lib/dynamic-font.ts +49 -0
- package/dist/templates/default/lib/env.ts +10 -0
- package/dist/templates/default/lib/haptics.ts +24 -0
- package/dist/templates/default/lib/notifications.ts +276 -0
- package/dist/templates/default/lib/preferences.ts +45 -0
- package/dist/templates/default/lib/schemas.ts +137 -0
- package/dist/templates/default/lib/storage.ts +47 -0
- package/dist/templates/default/lib/updates.ts +107 -0
- package/dist/templates/default/metro.config.js +14 -0
- package/dist/templates/default/package.json +129 -0
- package/dist/templates/default/patches/PR-368.patch +91 -0
- package/dist/templates/default/patches/convex-dev-better-auth-0.12.2.tgz +0 -0
- package/dist/templates/default/plugins/README.md +9 -0
- package/dist/templates/default/plugins/with-auto-signing.js +45 -0
- package/dist/templates/default/plugins/with-pod-deployment-target.js +35 -0
- package/dist/templates/default/scripts/README.md +36 -0
- package/dist/templates/default/scripts/_run.mjs +77 -0
- package/dist/templates/default/scripts/clean.ts +543 -0
- package/dist/templates/default/scripts/rotate-apple-jwt.mjs +80 -0
- package/dist/templates/default/store.config.json +58 -0
- package/dist/templates/default/tsconfig.json +13 -0
- package/dist/templates/default/vitest.config.ts +21 -0
- package/package.json +69 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { startTransition, useActionState, useState } from "react";
|
|
2
|
+
import { useMutation } from "convex/react";
|
|
3
|
+
import { Host, VStack, HStack, Text, TextField, Button, Image, Spacer } from "@expo/ui/swift-ui";
|
|
4
|
+
import {
|
|
5
|
+
foregroundStyle,
|
|
6
|
+
buttonStyle,
|
|
7
|
+
background,
|
|
8
|
+
clipShape,
|
|
9
|
+
disabled,
|
|
10
|
+
keyboardType,
|
|
11
|
+
monospacedDigit,
|
|
12
|
+
kerning,
|
|
13
|
+
multilineTextAlignment,
|
|
14
|
+
onSubmit,
|
|
15
|
+
submitLabel,
|
|
16
|
+
padding,
|
|
17
|
+
frame,
|
|
18
|
+
accessibilityLabel,
|
|
19
|
+
accessibilityHint,
|
|
20
|
+
tint,
|
|
21
|
+
textFieldStyle,
|
|
22
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
23
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
24
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
25
|
+
|
|
26
|
+
import { api } from "@/convex/_generated/api";
|
|
27
|
+
import { authClient } from "@/lib/auth-client";
|
|
28
|
+
import { haptics } from "@/lib/haptics";
|
|
29
|
+
import { useColors } from "@/hooks/use-theme";
|
|
30
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
31
|
+
import { ErrorText } from "@/components/ui/status-text";
|
|
32
|
+
import { announce } from "@/lib/a11y";
|
|
33
|
+
|
|
34
|
+
export type PendingAvatar = { uri: string; mimeType: string };
|
|
35
|
+
|
|
36
|
+
export type OtpFlow = "verify-email" | "sign-in";
|
|
37
|
+
|
|
38
|
+
type OtpVerificationProps = {
|
|
39
|
+
email: string;
|
|
40
|
+
onBack: () => void;
|
|
41
|
+
/**
|
|
42
|
+
* "verify-email" (default) confirms a fresh sign-up via
|
|
43
|
+
* `authClient.emailOtp.verifyEmail` - the server has
|
|
44
|
+
* `autoSignInAfterVerification: true` so a successful verify mints the
|
|
45
|
+
* session inline. "sign-in" hits `authClient.signIn.emailOtp` to log a
|
|
46
|
+
* returning user in passwordlessly.
|
|
47
|
+
*/
|
|
48
|
+
flow?: OtpFlow;
|
|
49
|
+
/**
|
|
50
|
+
* Avatar picked during sign-up. Uploaded to Convex storage right after
|
|
51
|
+
* verifyEmail succeeds and autoSignInAfterVerification mints the session.
|
|
52
|
+
* Held in the parent's state so it's forgotten if the user backs out.
|
|
53
|
+
* Ignored when `flow` is "sign-in" (existing accounts already have an
|
|
54
|
+
* avatar configured from the profile screen).
|
|
55
|
+
*/
|
|
56
|
+
pendingAvatar?: PendingAvatar | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type OtpState = { error?: string; ok?: boolean };
|
|
60
|
+
const initialState: OtpState = {};
|
|
61
|
+
|
|
62
|
+
export function OtpVerification({
|
|
63
|
+
email,
|
|
64
|
+
onBack,
|
|
65
|
+
flow = "verify-email",
|
|
66
|
+
pendingAvatar,
|
|
67
|
+
}: OtpVerificationProps) {
|
|
68
|
+
const dfont = useDynamicFont();
|
|
69
|
+
const colors = useColors();
|
|
70
|
+
const [otp, setOtp] = useState("");
|
|
71
|
+
const generateAvatarUploadUrl = useMutation(api.users.generateAvatarUploadUrl);
|
|
72
|
+
const updateAvatar = useMutation(api.users.updateAvatar);
|
|
73
|
+
const isSignIn = flow === "sign-in";
|
|
74
|
+
|
|
75
|
+
const [verifyState, verify, isVerifying] = useActionState<OtpState, void>(async () => {
|
|
76
|
+
haptics.light();
|
|
77
|
+
|
|
78
|
+
if (otp.length !== 6) {
|
|
79
|
+
haptics.error();
|
|
80
|
+
return { error: "Please enter the 6-digit code" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const response = isSignIn
|
|
85
|
+
? await authClient.signIn.emailOtp({ email: email.trim(), otp })
|
|
86
|
+
: await authClient.emailOtp.verifyEmail({ email: email.trim(), otp });
|
|
87
|
+
|
|
88
|
+
if (response.error) {
|
|
89
|
+
haptics.error();
|
|
90
|
+
return { error: "Invalid or expired code. Please try again." };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Upload the avatar picked at sign-up before this component unmounts.
|
|
94
|
+
// Stack.Protected swaps (auth) -> (app) on the next render once the
|
|
95
|
+
// session lands, but kicking off the requests here keeps them in flight
|
|
96
|
+
// server-side regardless of the unmount. Failures are non-fatal: the
|
|
97
|
+
// user is verified, they can set a photo from the profile screen.
|
|
98
|
+
if (!isSignIn && pendingAvatar) {
|
|
99
|
+
try {
|
|
100
|
+
const uploadUrl = await generateAvatarUploadUrl();
|
|
101
|
+
const blob = await (await fetch(pendingAvatar.uri)).blob();
|
|
102
|
+
const upload = await fetch(uploadUrl, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": pendingAvatar.mimeType },
|
|
105
|
+
body: blob,
|
|
106
|
+
});
|
|
107
|
+
if (upload.ok) {
|
|
108
|
+
const { storageId } = (await upload.json()) as { storageId: string };
|
|
109
|
+
await updateAvatar({ storageId: storageId as never });
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Swallow: verification still succeeded.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
haptics.success();
|
|
117
|
+
announce(isSignIn ? "Signed in" : "Email verified");
|
|
118
|
+
return { ok: true };
|
|
119
|
+
} catch {
|
|
120
|
+
haptics.error();
|
|
121
|
+
return {
|
|
122
|
+
error: isSignIn
|
|
123
|
+
? "Sign in failed. Please try again."
|
|
124
|
+
: "Verification failed. Please try again.",
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
}, initialState);
|
|
128
|
+
|
|
129
|
+
const [resendState, resend, isResending] = useActionState<OtpState, void>(async () => {
|
|
130
|
+
haptics.light();
|
|
131
|
+
try {
|
|
132
|
+
await authClient.emailOtp.sendVerificationOtp({
|
|
133
|
+
email: email.trim(),
|
|
134
|
+
type: isSignIn ? "sign-in" : "email-verification",
|
|
135
|
+
});
|
|
136
|
+
haptics.success();
|
|
137
|
+
announce("New verification code sent");
|
|
138
|
+
return { ok: true };
|
|
139
|
+
} catch {
|
|
140
|
+
haptics.error();
|
|
141
|
+
return { error: "Failed to send code. Please try again." };
|
|
142
|
+
}
|
|
143
|
+
}, initialState);
|
|
144
|
+
|
|
145
|
+
const error = verifyState.error ?? resendState.error;
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<Host style={{ flex: 1, backgroundColor: colors.background }}>
|
|
149
|
+
<VStack
|
|
150
|
+
spacing={16}
|
|
151
|
+
alignment="center"
|
|
152
|
+
modifiers={[padding({ horizontal: 24 }), tint(colors.primary as string)]}
|
|
153
|
+
>
|
|
154
|
+
<Spacer />
|
|
155
|
+
|
|
156
|
+
<Image
|
|
157
|
+
systemName={isSignIn ? "lock.shield" : "envelope.badge"}
|
|
158
|
+
size={56}
|
|
159
|
+
color={colors.primary}
|
|
160
|
+
/>
|
|
161
|
+
|
|
162
|
+
<Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
|
|
163
|
+
{isSignIn ? "Sign in with code" : "Verify your email"}
|
|
164
|
+
</Text>
|
|
165
|
+
|
|
166
|
+
<VStack spacing={4} alignment="center">
|
|
167
|
+
<Text
|
|
168
|
+
modifiers={[
|
|
169
|
+
dfont({ size: 15 }),
|
|
170
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
171
|
+
multilineTextAlignment("center"),
|
|
172
|
+
]}
|
|
173
|
+
>
|
|
174
|
+
Enter the 6-digit code sent to
|
|
175
|
+
</Text>
|
|
176
|
+
<Text modifiers={[dfont({ size: 15, weight: "semibold" })]}>{email}</Text>
|
|
177
|
+
</VStack>
|
|
178
|
+
|
|
179
|
+
{error && <ErrorText>{error}</ErrorText>}
|
|
180
|
+
|
|
181
|
+
<VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
|
|
182
|
+
<TextField
|
|
183
|
+
placeholder="000000"
|
|
184
|
+
onTextChange={(text) => setOtp(text.replace(/\D/g, "").slice(0, 6))}
|
|
185
|
+
autoFocus
|
|
186
|
+
modifiers={[
|
|
187
|
+
textFieldStyle("plain"),
|
|
188
|
+
padding({ horizontal: 16 }),
|
|
189
|
+
frame({ maxWidth: Infinity, height: ButtonTokens.height }),
|
|
190
|
+
background(colors.muted as string),
|
|
191
|
+
clipShape("capsule"),
|
|
192
|
+
dfont({ size: 24, design: "monospaced" }),
|
|
193
|
+
monospacedDigit(),
|
|
194
|
+
kerning(8),
|
|
195
|
+
multilineTextAlignment("center"),
|
|
196
|
+
keyboardType("numeric"),
|
|
197
|
+
onSubmit(() => startTransition(() => verify())),
|
|
198
|
+
submitLabel("done"),
|
|
199
|
+
accessibilityLabel("Verification code"),
|
|
200
|
+
accessibilityHint("Enter the 6 digit code sent to your email"),
|
|
201
|
+
]}
|
|
202
|
+
/>
|
|
203
|
+
|
|
204
|
+
<ProminentButton
|
|
205
|
+
label={
|
|
206
|
+
isVerifying
|
|
207
|
+
? isSignIn
|
|
208
|
+
? "Signing in..."
|
|
209
|
+
: "Verifying..."
|
|
210
|
+
: isSignIn
|
|
211
|
+
? "Sign in"
|
|
212
|
+
: "Verify"
|
|
213
|
+
}
|
|
214
|
+
onPress={() => startTransition(() => verify())}
|
|
215
|
+
disabled={isVerifying || otp.length !== 6}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
<Button
|
|
219
|
+
modifiers={[buttonStyle("plain"), frame({ maxWidth: 10000 }), disabled(isResending)]}
|
|
220
|
+
onPress={() => startTransition(() => resend())}
|
|
221
|
+
>
|
|
222
|
+
<Text
|
|
223
|
+
modifiers={[
|
|
224
|
+
frame({ maxWidth: 10000, height: ButtonTokens.height }),
|
|
225
|
+
multilineTextAlignment("center"),
|
|
226
|
+
dfont({ size: ButtonTokens.fontSize, weight: ButtonTokens.secondaryFontWeight }),
|
|
227
|
+
foregroundStyle(colors.primary as string),
|
|
228
|
+
]}
|
|
229
|
+
>
|
|
230
|
+
{isResending ? "Sending..." : "Resend code"}
|
|
231
|
+
</Text>
|
|
232
|
+
</Button>
|
|
233
|
+
</VStack>
|
|
234
|
+
|
|
235
|
+
<HStack modifiers={[padding({ top: 8 })]}>
|
|
236
|
+
<Text
|
|
237
|
+
modifiers={[dfont({ size: 14 }), foregroundStyle(colors.mutedForeground as string)]}
|
|
238
|
+
>
|
|
239
|
+
Wrong email?
|
|
240
|
+
</Text>
|
|
241
|
+
<Button
|
|
242
|
+
label="Go back"
|
|
243
|
+
modifiers={[buttonStyle("plain"), dfont({ size: 14, weight: "semibold" })]}
|
|
244
|
+
onPress={() => {
|
|
245
|
+
haptics.light();
|
|
246
|
+
onBack();
|
|
247
|
+
}}
|
|
248
|
+
/>
|
|
249
|
+
</HStack>
|
|
250
|
+
|
|
251
|
+
<Spacer />
|
|
252
|
+
</VStack>
|
|
253
|
+
</Host>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { type ComponentProps, useState } from "react";
|
|
2
|
+
import { Button, HStack, Image, SecureField, TextField, useNativeState } from "@expo/ui/swift-ui";
|
|
3
|
+
import {
|
|
4
|
+
accessibilityHint,
|
|
5
|
+
accessibilityLabel,
|
|
6
|
+
autocorrectionDisabled,
|
|
7
|
+
background,
|
|
8
|
+
buttonStyle,
|
|
9
|
+
clipShape,
|
|
10
|
+
disabled as disabledMod,
|
|
11
|
+
frame,
|
|
12
|
+
onSubmit as onSubmitMod,
|
|
13
|
+
padding,
|
|
14
|
+
submitLabel,
|
|
15
|
+
textFieldStyle,
|
|
16
|
+
textInputAutocapitalization,
|
|
17
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
18
|
+
|
|
19
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
20
|
+
import { useColors } from "@/hooks/use-theme";
|
|
21
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
22
|
+
import { haptics } from "@/lib/haptics";
|
|
23
|
+
|
|
24
|
+
type ObservableTextState = NonNullable<ComponentProps<typeof TextField>["text"]>;
|
|
25
|
+
type SubmitLabel = "next" | "done" | "send" | "go" | "search" | "join" | "route" | "continue";
|
|
26
|
+
|
|
27
|
+
type Props = {
|
|
28
|
+
text?: ObservableTextState;
|
|
29
|
+
placeholder?: string;
|
|
30
|
+
onTextChange: (next: string) => void;
|
|
31
|
+
onSubmit?: () => void;
|
|
32
|
+
submitLabelType?: SubmitLabel;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
accessibilityLabel?: string;
|
|
35
|
+
accessibilityHint?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Password input with an inline eye toggle to reveal what was typed.
|
|
40
|
+
*
|
|
41
|
+
* The toggle swaps between SecureField (masked) and TextField (visible). Both
|
|
42
|
+
* are bound to the same `useNativeState`, so the native value persists across
|
|
43
|
+
* the swap. without it, React unmounts one component and mounts the other
|
|
44
|
+
* and the new field starts empty. If the parent passes its own ObservableState
|
|
45
|
+
* via `text`, that's used instead (e.g. profile.tsx clears the field after
|
|
46
|
+
* submit by resetting the state from outside).
|
|
47
|
+
*/
|
|
48
|
+
export function PasswordField({
|
|
49
|
+
text,
|
|
50
|
+
placeholder = "••••••••",
|
|
51
|
+
onTextChange,
|
|
52
|
+
onSubmit,
|
|
53
|
+
submitLabelType = "done",
|
|
54
|
+
disabled = false,
|
|
55
|
+
accessibilityLabel: a11yLabel = "Password",
|
|
56
|
+
accessibilityHint: a11yHint = "Enter your password",
|
|
57
|
+
}: Props) {
|
|
58
|
+
const dfont = useDynamicFont();
|
|
59
|
+
const colors = useColors();
|
|
60
|
+
const [visible, setVisible] = useState(false);
|
|
61
|
+
const internalState = useNativeState("");
|
|
62
|
+
const sharedState = text ?? internalState;
|
|
63
|
+
|
|
64
|
+
const fieldModifiers = [
|
|
65
|
+
textFieldStyle("plain"),
|
|
66
|
+
frame({ maxWidth: Infinity }),
|
|
67
|
+
dfont({ size: 16 }),
|
|
68
|
+
autocorrectionDisabled(),
|
|
69
|
+
textInputAutocapitalization("never"),
|
|
70
|
+
disabledMod(disabled),
|
|
71
|
+
submitLabel(submitLabelType),
|
|
72
|
+
accessibilityLabel(a11yLabel),
|
|
73
|
+
accessibilityHint(a11yHint),
|
|
74
|
+
...(onSubmit ? [onSubmitMod(onSubmit)] : []),
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<HStack
|
|
79
|
+
spacing={8}
|
|
80
|
+
modifiers={[
|
|
81
|
+
padding({ horizontal: 16 }),
|
|
82
|
+
frame({ maxWidth: Infinity, height: ButtonTokens.height }),
|
|
83
|
+
background(colors.muted as string),
|
|
84
|
+
clipShape("capsule"),
|
|
85
|
+
]}
|
|
86
|
+
>
|
|
87
|
+
{visible ? (
|
|
88
|
+
<TextField
|
|
89
|
+
text={sharedState}
|
|
90
|
+
placeholder={placeholder}
|
|
91
|
+
onTextChange={onTextChange}
|
|
92
|
+
modifiers={fieldModifiers}
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<SecureField
|
|
96
|
+
text={sharedState}
|
|
97
|
+
placeholder={placeholder}
|
|
98
|
+
onTextChange={onTextChange}
|
|
99
|
+
modifiers={fieldModifiers}
|
|
100
|
+
/>
|
|
101
|
+
)}
|
|
102
|
+
<Button
|
|
103
|
+
modifiers={[
|
|
104
|
+
buttonStyle("plain"),
|
|
105
|
+
accessibilityLabel(visible ? "Hide password" : "Show password"),
|
|
106
|
+
accessibilityHint(visible ? "Tap to mask the password" : "Tap to reveal the password"),
|
|
107
|
+
]}
|
|
108
|
+
onPress={() => {
|
|
109
|
+
haptics.light();
|
|
110
|
+
setVisible((v) => !v);
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
<Image
|
|
114
|
+
systemName={visible ? "eye.slash" : "eye"}
|
|
115
|
+
size={18}
|
|
116
|
+
color={colors.mutedForeground as string}
|
|
117
|
+
/>
|
|
118
|
+
</Button>
|
|
119
|
+
</HStack>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Picker, Text } from "@expo/ui/swift-ui";
|
|
2
|
+
import { controlSize, frame, pickerStyle, tag } from "@expo/ui/swift-ui/modifiers";
|
|
3
|
+
|
|
4
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
5
|
+
import { Button as ButtonTokens } from "@/constants/layout";
|
|
6
|
+
import { haptics } from "@/lib/haptics";
|
|
7
|
+
|
|
8
|
+
// Native iOS segmented control via @expo/ui's SwiftUI Picker. Mirrors the
|
|
9
|
+
// affordance of tanvex's web SegmentedToggle (Sign in/Sign up, Email/Username
|
|
10
|
+
// /Email OTP) but renders as the platform-native control on iOS.
|
|
11
|
+
|
|
12
|
+
export type SegmentedOption<T extends string> = {
|
|
13
|
+
value: T;
|
|
14
|
+
label: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type Props<T extends string> = {
|
|
18
|
+
value: T;
|
|
19
|
+
options: SegmentedOption<T>[];
|
|
20
|
+
onChange: (value: T) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function SegmentedToggle<T extends string>({ value, options, onChange }: Props<T>) {
|
|
24
|
+
const dfont = useDynamicFont();
|
|
25
|
+
return (
|
|
26
|
+
<Picker
|
|
27
|
+
modifiers={[
|
|
28
|
+
pickerStyle("segmented"),
|
|
29
|
+
controlSize("large"),
|
|
30
|
+
frame({ maxWidth: 10000, height: ButtonTokens.height }),
|
|
31
|
+
]}
|
|
32
|
+
selection={value}
|
|
33
|
+
onSelectionChange={(selection) => {
|
|
34
|
+
const next = selection as T;
|
|
35
|
+
if (next === value) return;
|
|
36
|
+
haptics.light();
|
|
37
|
+
onChange(next);
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
{options.map((opt) => (
|
|
41
|
+
<Text key={opt.value} modifiers={[tag(opt.value), dfont({ size: 14, weight: "medium" })]}>
|
|
42
|
+
{opt.label}
|
|
43
|
+
</Text>
|
|
44
|
+
))}
|
|
45
|
+
</Picker>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ConvexError } from "convex/values";
|
|
2
|
+
|
|
3
|
+
import { ErrorText } from "./status-text";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Pull a human-readable message out of an unknown thrown value. Knows about
|
|
7
|
+
* `ConvexError`'s structured `data` payload (`{ code, message, field? }` from
|
|
8
|
+
* `convex/errors.ts`) so server-side validation errors and auth failures
|
|
9
|
+
* surface their original message instead of a stringified object.
|
|
10
|
+
*/
|
|
11
|
+
export function formatError(err: unknown): string {
|
|
12
|
+
if (err instanceof ConvexError) {
|
|
13
|
+
const data = err.data as unknown;
|
|
14
|
+
if (typeof data === "object" && data !== null && "message" in data) {
|
|
15
|
+
const msg = (data as { message?: unknown }).message;
|
|
16
|
+
if (typeof msg === "string" && msg.length > 0) return msg;
|
|
17
|
+
}
|
|
18
|
+
return err.message;
|
|
19
|
+
}
|
|
20
|
+
if (err instanceof Error) return err.message;
|
|
21
|
+
return "An unexpected error occurred";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render an unknown thrown value through `ErrorText`. Returns null when there
|
|
26
|
+
* is no error so call sites can do `<ConvexErrorView error={state.error} />`
|
|
27
|
+
* unconditionally.
|
|
28
|
+
*/
|
|
29
|
+
export function ConvexErrorView({ error }: { error: unknown }) {
|
|
30
|
+
if (error === undefined || error === null) return null;
|
|
31
|
+
return <ErrorText>{formatError(error)}</ErrorText>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { router, type ErrorBoundaryProps } from "expo-router";
|
|
2
|
+
import { Host, VStack, Text, Button, Image, Spacer } from "@expo/ui/swift-ui";
|
|
3
|
+
import {
|
|
4
|
+
foregroundStyle,
|
|
5
|
+
buttonStyle,
|
|
6
|
+
frame,
|
|
7
|
+
padding,
|
|
8
|
+
multilineTextAlignment,
|
|
9
|
+
tint,
|
|
10
|
+
} from "@expo/ui/swift-ui/modifiers";
|
|
11
|
+
import { useDynamicFont } from "@/lib/dynamic-font";
|
|
12
|
+
import { ProminentButton } from "@/components/ui/prominent-button";
|
|
13
|
+
import { useColors } from "@/hooks/use-theme";
|
|
14
|
+
|
|
15
|
+
export function AppErrorBoundary({ error, retry }: ErrorBoundaryProps) {
|
|
16
|
+
const dfont = useDynamicFont();
|
|
17
|
+
const colors = useColors();
|
|
18
|
+
console.error("[ErrorBoundary]", error);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Host style={{ flex: 1 }}>
|
|
22
|
+
<VStack
|
|
23
|
+
spacing={20}
|
|
24
|
+
alignment="center"
|
|
25
|
+
modifiers={[padding({ horizontal: 24, vertical: 32 }), tint(colors.primary as string)]}
|
|
26
|
+
>
|
|
27
|
+
<Spacer />
|
|
28
|
+
<Image
|
|
29
|
+
systemName="exclamationmark.triangle"
|
|
30
|
+
size={72}
|
|
31
|
+
color={colors.destructive as string}
|
|
32
|
+
/>
|
|
33
|
+
<Text modifiers={[dfont({ size: 28, weight: "bold" }), multilineTextAlignment("center")]}>
|
|
34
|
+
Something went wrong
|
|
35
|
+
</Text>
|
|
36
|
+
<Text
|
|
37
|
+
modifiers={[
|
|
38
|
+
dfont({ size: 16 }),
|
|
39
|
+
foregroundStyle(colors.mutedForeground as string),
|
|
40
|
+
multilineTextAlignment("center"),
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
Don't worry. Let's get you back on track.
|
|
44
|
+
</Text>
|
|
45
|
+
<VStack spacing={12} modifiers={[frame({ maxWidth: Infinity })]}>
|
|
46
|
+
<ProminentButton label="Try Again" onPress={retry} />
|
|
47
|
+
<Button
|
|
48
|
+
label="Go Home"
|
|
49
|
+
modifiers={[buttonStyle("plain"), foregroundStyle(colors.mutedForeground as string)]}
|
|
50
|
+
onPress={() => router.replace("/")}
|
|
51
|
+
/>
|
|
52
|
+
</VStack>
|
|
53
|
+
<Spacer />
|
|
54
|
+
</VStack>
|
|
55
|
+
</Host>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Image as ExpoImage } from "expo-image";
|
|
2
|
+
import { Host, ProgressView, Spacer, VStack, RNHostView } from "@expo/ui/swift-ui";
|
|
3
|
+
import { progressViewStyle, tint } from "@expo/ui/swift-ui/modifiers";
|
|
4
|
+
|
|
5
|
+
import { assets } from "@/lib/assets";
|
|
6
|
+
import { useColors, useThemedAsset } from "@/hooks/use-theme";
|
|
7
|
+
|
|
8
|
+
export function LoadingScreen() {
|
|
9
|
+
const colors = useColors();
|
|
10
|
+
const brandIcon = useThemedAsset(assets.brandIconLight, assets.brandIconDark);
|
|
11
|
+
return (
|
|
12
|
+
<Host
|
|
13
|
+
style={{ flex: 1, backgroundColor: colors.background as string }}
|
|
14
|
+
useViewportSizeMeasurement
|
|
15
|
+
>
|
|
16
|
+
<VStack alignment="center" spacing={20} modifiers={[tint(colors.primary as string)]}>
|
|
17
|
+
<Spacer />
|
|
18
|
+
<RNHostView matchContents>
|
|
19
|
+
<ExpoImage
|
|
20
|
+
source={brandIcon}
|
|
21
|
+
style={{ width: 80, height: 80 }}
|
|
22
|
+
contentFit="contain"
|
|
23
|
+
accessibilityLabel="App icon"
|
|
24
|
+
/>
|
|
25
|
+
</RNHostView>
|
|
26
|
+
<ProgressView modifiers={[progressViewStyle("circular")]} />
|
|
27
|
+
<Spacer />
|
|
28
|
+
</VStack>
|
|
29
|
+
</Host>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
3
|
+
import { BlurView, type BlurTint } from "expo-blur";
|
|
4
|
+
import { GlassView, isLiquidGlassAvailable, type GlassStyle } from "expo-glass-effect";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* HIG-aware translucent surface. Picks the right backing per OS:
|
|
8
|
+
*
|
|
9
|
+
* iOS 26+ -> `GlassView` (true Liquid Glass via UIVisualEffectView)
|
|
10
|
+
* iOS 16.4-25 -> `BlurView` (UIVisualEffectView blur + tint overlay)
|
|
11
|
+
* anything else -> solid `tintColor` fallback
|
|
12
|
+
*
|
|
13
|
+
* Apple's HIG reserves materials for the navigation layer that floats above
|
|
14
|
+
* content: tab bars, navigation bars, toolbars, sheets, popovers, alerts,
|
|
15
|
+
* notification banners. Most of those are already handled by `@expo/ui`'s
|
|
16
|
+
* SwiftUI primitives and `expo-router`'s NativeTabs. Reach for `<Material>`
|
|
17
|
+
* only when you're hand-building floating UI: a custom HUD, a toast, a
|
|
18
|
+
* pill that overlays scrollable content, a custom sheet backdrop.
|
|
19
|
+
*
|
|
20
|
+
* Children render inside the surface unchanged. `tintColor` paints over the
|
|
21
|
+
* blur (semi-transparent so the blur still reads); on iOS 26+ it goes to
|
|
22
|
+
* `GlassView`'s native `tintColor` instead.
|
|
23
|
+
*/
|
|
24
|
+
export type MaterialVariant = "ultraThin" | "thin" | "regular" | "thick" | "chrome";
|
|
25
|
+
|
|
26
|
+
const BLUR_INTENSITY: Record<MaterialVariant, number> = {
|
|
27
|
+
ultraThin: 30,
|
|
28
|
+
thin: 50,
|
|
29
|
+
regular: 70,
|
|
30
|
+
thick: 90,
|
|
31
|
+
chrome: 100,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const BLUR_TINT: Record<MaterialVariant, BlurTint> = {
|
|
35
|
+
ultraThin: "systemUltraThinMaterial",
|
|
36
|
+
thin: "systemThinMaterial",
|
|
37
|
+
regular: "systemMaterial",
|
|
38
|
+
thick: "systemThickMaterial",
|
|
39
|
+
chrome: "systemChromeMaterial",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const GLASS_STYLE: Record<MaterialVariant, GlassStyle> = {
|
|
43
|
+
ultraThin: "clear",
|
|
44
|
+
thin: "clear",
|
|
45
|
+
regular: "regular",
|
|
46
|
+
thick: "regular",
|
|
47
|
+
chrome: "regular",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const TINT_OVERLAY_OPACITY = 0.35;
|
|
51
|
+
|
|
52
|
+
export function Material({
|
|
53
|
+
children,
|
|
54
|
+
style,
|
|
55
|
+
variant = "regular",
|
|
56
|
+
tintColor,
|
|
57
|
+
isInteractive = false,
|
|
58
|
+
}: {
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
style?: ViewStyle;
|
|
61
|
+
variant?: MaterialVariant;
|
|
62
|
+
tintColor?: string;
|
|
63
|
+
isInteractive?: boolean;
|
|
64
|
+
}) {
|
|
65
|
+
if (isLiquidGlassAvailable()) {
|
|
66
|
+
return (
|
|
67
|
+
<GlassView
|
|
68
|
+
style={style}
|
|
69
|
+
glassEffectStyle={GLASS_STYLE[variant]}
|
|
70
|
+
tintColor={tintColor}
|
|
71
|
+
isInteractive={isInteractive}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</GlassView>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<BlurView style={style} intensity={BLUR_INTENSITY[variant]} tint={BLUR_TINT[variant]}>
|
|
80
|
+
{tintColor ? (
|
|
81
|
+
<View
|
|
82
|
+
style={[
|
|
83
|
+
StyleSheet.absoluteFill,
|
|
84
|
+
{ backgroundColor: tintColor, opacity: TINT_OVERLAY_OPACITY },
|
|
85
|
+
]}
|
|
86
|
+
pointerEvents="none"
|
|
87
|
+
accessible={false}
|
|
88
|
+
importantForAccessibility="no"
|
|
89
|
+
/>
|
|
90
|
+
) : null}
|
|
91
|
+
{children}
|
|
92
|
+
</BlurView>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Text, View } from "react-native";
|
|
2
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
3
|
+
|
|
4
|
+
import { Material } from "@/components/ui/material";
|
|
5
|
+
import { useNetwork } from "@/hooks/use-network";
|
|
6
|
+
import { Spacing, FontSize, FontFamily } from "@/constants/layout";
|
|
7
|
+
import { Radius } from "@/constants/theme";
|
|
8
|
+
import { ZIndex } from "@/constants/ui";
|
|
9
|
+
import { useColors } from "@/hooks/use-theme";
|
|
10
|
+
|
|
11
|
+
// HIG: notification banners overlay the navigation layer with a translucent
|
|
12
|
+
// material so context behind the alert remains visible.
|
|
13
|
+
export function OfflineBanner() {
|
|
14
|
+
const { isOffline } = useNetwork();
|
|
15
|
+
const insets = useSafeAreaInsets();
|
|
16
|
+
const colors = useColors();
|
|
17
|
+
|
|
18
|
+
if (!isOffline) return null;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<View
|
|
22
|
+
accessibilityLiveRegion="assertive"
|
|
23
|
+
accessibilityRole="alert"
|
|
24
|
+
style={{
|
|
25
|
+
position: "absolute",
|
|
26
|
+
top: 0,
|
|
27
|
+
left: 0,
|
|
28
|
+
right: 0,
|
|
29
|
+
zIndex: ZIndex.offlineBanner,
|
|
30
|
+
paddingTop: insets.top,
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<Material
|
|
34
|
+
variant="chrome"
|
|
35
|
+
tintColor={colors.destructive as string}
|
|
36
|
+
style={{
|
|
37
|
+
marginHorizontal: Spacing.md,
|
|
38
|
+
marginTop: Spacing.xs,
|
|
39
|
+
borderRadius: Radius.full,
|
|
40
|
+
overflow: "hidden",
|
|
41
|
+
paddingVertical: Spacing.sm,
|
|
42
|
+
paddingHorizontal: Spacing.lg,
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
<Text
|
|
47
|
+
style={{
|
|
48
|
+
fontSize: FontSize.md,
|
|
49
|
+
fontFamily: FontFamily.semiBold,
|
|
50
|
+
color: colors.destructiveForeground as string,
|
|
51
|
+
}}
|
|
52
|
+
>
|
|
53
|
+
You're offline
|
|
54
|
+
</Text>
|
|
55
|
+
</Material>
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|