create-react-native-airborne 0.0.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/README.md +24 -0
- package/package.json +21 -0
- package/src/index.mjs +103 -0
- package/template/.agents/skills/convex-best-practices/SKILL.md +333 -0
- package/template/.agents/skills/convex-file-storage/SKILL.md +466 -0
- package/template/.agents/skills/convex-security-audit/SKILL.md +538 -0
- package/template/.agents/skills/convex-security-check/SKILL.md +377 -0
- package/template/.github/workflows/ci.yml +130 -0
- package/template/.prettierignore +8 -0
- package/template/.prettierrc.json +6 -0
- package/template/AGENTS.md +156 -0
- package/template/Justfile +48 -0
- package/template/README.md +94 -0
- package/template/client/.env.example +3 -0
- package/template/client/.vscode/extensions.json +1 -0
- package/template/client/.vscode/settings.json +7 -0
- package/template/client/README.md +33 -0
- package/template/client/app/(app)/_layout.tsx +34 -0
- package/template/client/app/(app)/index.tsx +66 -0
- package/template/client/app/(app)/push.tsx +75 -0
- package/template/client/app/(app)/settings.tsx +36 -0
- package/template/client/app/(auth)/_layout.tsx +22 -0
- package/template/client/app/(auth)/sign-in.tsx +358 -0
- package/template/client/app/(auth)/sign-up.tsx +237 -0
- package/template/client/app/_layout.tsx +30 -0
- package/template/client/app/index.tsx +127 -0
- package/template/client/app.config.ts +30 -0
- package/template/client/assets/images/android-icon-background.png +0 -0
- package/template/client/assets/images/android-icon-foreground.png +0 -0
- package/template/client/assets/images/android-icon-monochrome.png +0 -0
- package/template/client/assets/images/favicon.png +0 -0
- package/template/client/assets/images/icon.png +0 -0
- package/template/client/assets/images/partial-react-logo.png +0 -0
- package/template/client/assets/images/react-logo.png +0 -0
- package/template/client/assets/images/react-logo@2x.png +0 -0
- package/template/client/assets/images/react-logo@3x.png +0 -0
- package/template/client/assets/images/splash-icon.png +0 -0
- package/template/client/eslint.config.js +10 -0
- package/template/client/global.css +2 -0
- package/template/client/metro.config.js +9 -0
- package/template/client/package.json +51 -0
- package/template/client/src/components/auth-shell.tsx +63 -0
- package/template/client/src/components/form-input.tsx +62 -0
- package/template/client/src/components/primary-button.tsx +37 -0
- package/template/client/src/components/screen.tsx +17 -0
- package/template/client/src/components/sign-out-button.tsx +32 -0
- package/template/client/src/hooks/use-theme-sync.ts +11 -0
- package/template/client/src/lib/convex.ts +6 -0
- package/template/client/src/lib/env-schema.ts +13 -0
- package/template/client/src/lib/env.test.ts +24 -0
- package/template/client/src/lib/env.ts +19 -0
- package/template/client/src/lib/notifications.ts +47 -0
- package/template/client/src/store/preferences-store.ts +42 -0
- package/template/client/src/types/theme.ts +1 -0
- package/template/client/tsconfig.json +18 -0
- package/template/client/uniwind-types.d.ts +10 -0
- package/template/client/vitest.config.ts +7 -0
- package/template/package.json +22 -0
- package/template/server/.env.example +8 -0
- package/template/server/README.md +31 -0
- package/template/server/convex/_generated/api.d.ts +55 -0
- package/template/server/convex/_generated/api.js +23 -0
- package/template/server/convex/_generated/dataModel.d.ts +60 -0
- package/template/server/convex/_generated/server.d.ts +143 -0
- package/template/server/convex/_generated/server.js +93 -0
- package/template/server/convex/auth.config.ts +11 -0
- package/template/server/convex/env.ts +18 -0
- package/template/server/convex/lib.ts +12 -0
- package/template/server/convex/push.ts +148 -0
- package/template/server/convex/schema.ts +22 -0
- package/template/server/convex/users.ts +54 -0
- package/template/server/convex.json +3 -0
- package/template/server/eslint.config.js +51 -0
- package/template/server/package.json +29 -0
- package/template/server/tests/convex.test.ts +52 -0
- package/template/server/tests/import-meta.d.ts +3 -0
- package/template/server/tsconfig.json +15 -0
- package/template/server/vitest.config.ts +13 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { SignedIn, SignedOut, useSession, useUser } from "@clerk/clerk-expo";
|
|
2
|
+
import { Link } from "expo-router";
|
|
3
|
+
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import { Screen } from "@/src/components/screen";
|
|
5
|
+
import { SignOutButton } from "@/src/components/sign-out-button";
|
|
6
|
+
|
|
7
|
+
export default function IndexRoute() {
|
|
8
|
+
const { user } = useUser();
|
|
9
|
+
const { session } = useSession();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<Screen className="bg-slate-100 px-5 py-4 dark:bg-zinc-950">
|
|
13
|
+
<View className="flex-1">
|
|
14
|
+
<View className="absolute -top-24 -left-16 h-56 w-56 rounded-full bg-cyan-200 dark:bg-cyan-900" />
|
|
15
|
+
<View className="absolute -bottom-24 -right-16 h-64 w-64 rounded-full bg-sky-200 dark:bg-sky-900" />
|
|
16
|
+
|
|
17
|
+
<View className="flex-1 justify-center">
|
|
18
|
+
<SignedOut>
|
|
19
|
+
<View
|
|
20
|
+
style={styles.cardShadow}
|
|
21
|
+
className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
|
|
22
|
+
>
|
|
23
|
+
<View className="gap-3">
|
|
24
|
+
<Text className="text-xs font-semibold uppercase tracking-[2px] text-sky-700 dark:text-sky-300">
|
|
25
|
+
React Native Airborne
|
|
26
|
+
</Text>
|
|
27
|
+
<Text className="text-4xl font-black leading-tight text-zinc-900 dark:text-zinc-50">
|
|
28
|
+
Start fast. Ship clean.
|
|
29
|
+
</Text>
|
|
30
|
+
<Text className="text-base leading-6 text-zinc-600 dark:text-zinc-300">
|
|
31
|
+
Expo Router, Clerk auth, Convex backend, push notifications, and Uniwind in one
|
|
32
|
+
starter.
|
|
33
|
+
</Text>
|
|
34
|
+
</View>
|
|
35
|
+
|
|
36
|
+
<View className="mt-7 gap-3">
|
|
37
|
+
<Link href="/(auth)/sign-up" asChild>
|
|
38
|
+
<Pressable className="rounded-2xl border border-sky-700 bg-sky-600 px-4 py-3.5">
|
|
39
|
+
<Text className="text-center text-base font-semibold text-white">
|
|
40
|
+
Create account
|
|
41
|
+
</Text>
|
|
42
|
+
</Pressable>
|
|
43
|
+
</Link>
|
|
44
|
+
|
|
45
|
+
<Link href="/(auth)/sign-in" asChild>
|
|
46
|
+
<Pressable className="rounded-2xl border border-zinc-300 bg-zinc-100 px-4 py-3.5 dark:border-zinc-700 dark:bg-zinc-800">
|
|
47
|
+
<Text className="text-center text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
|
48
|
+
I already have an account
|
|
49
|
+
</Text>
|
|
50
|
+
</Pressable>
|
|
51
|
+
</Link>
|
|
52
|
+
</View>
|
|
53
|
+
|
|
54
|
+
<View className="mt-7 flex-row flex-wrap gap-2">
|
|
55
|
+
<View className="rounded-full bg-sky-100 px-3 py-1 dark:bg-sky-900">
|
|
56
|
+
<Text className="text-xs font-semibold text-sky-700 dark:text-sky-200">
|
|
57
|
+
Expo Router
|
|
58
|
+
</Text>
|
|
59
|
+
</View>
|
|
60
|
+
<View className="rounded-full bg-emerald-100 px-3 py-1 dark:bg-emerald-900">
|
|
61
|
+
<Text className="text-xs font-semibold text-emerald-700 dark:text-emerald-200">
|
|
62
|
+
Clerk
|
|
63
|
+
</Text>
|
|
64
|
+
</View>
|
|
65
|
+
<View className="rounded-full bg-indigo-100 px-3 py-1 dark:bg-indigo-900">
|
|
66
|
+
<Text className="text-xs font-semibold text-indigo-700 dark:text-indigo-200">
|
|
67
|
+
Convex
|
|
68
|
+
</Text>
|
|
69
|
+
</View>
|
|
70
|
+
<View className="rounded-full bg-amber-100 px-3 py-1 dark:bg-amber-900">
|
|
71
|
+
<Text className="text-xs font-semibold text-amber-700 dark:text-amber-200">
|
|
72
|
+
Uniwind
|
|
73
|
+
</Text>
|
|
74
|
+
</View>
|
|
75
|
+
</View>
|
|
76
|
+
</View>
|
|
77
|
+
</SignedOut>
|
|
78
|
+
|
|
79
|
+
<SignedIn>
|
|
80
|
+
<View
|
|
81
|
+
style={styles.cardShadow}
|
|
82
|
+
className="gap-5 rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
|
|
83
|
+
>
|
|
84
|
+
<View className="gap-2">
|
|
85
|
+
<Text className="text-xs font-semibold uppercase tracking-[2px] text-sky-700 dark:text-sky-300">
|
|
86
|
+
Welcome
|
|
87
|
+
</Text>
|
|
88
|
+
<Text className="text-3xl font-black text-zinc-900 dark:text-zinc-50">
|
|
89
|
+
Hello {user?.primaryEmailAddress?.emailAddress}
|
|
90
|
+
</Text>
|
|
91
|
+
<Text className="text-zinc-600 dark:text-zinc-300">
|
|
92
|
+
Open your dashboard to continue.
|
|
93
|
+
</Text>
|
|
94
|
+
</View>
|
|
95
|
+
|
|
96
|
+
{session?.currentTask ? (
|
|
97
|
+
<Text className="rounded-xl bg-amber-100 px-3 py-2 text-sm text-amber-800 dark:bg-amber-900 dark:text-amber-200">
|
|
98
|
+
Session task pending: {session.currentTask.key}
|
|
99
|
+
</Text>
|
|
100
|
+
) : null}
|
|
101
|
+
|
|
102
|
+
<Link href="/(app)" asChild>
|
|
103
|
+
<Pressable className="rounded-2xl border border-sky-700 bg-sky-600 px-4 py-3.5">
|
|
104
|
+
<Text className="text-center text-base font-semibold text-white">
|
|
105
|
+
Open app dashboard
|
|
106
|
+
</Text>
|
|
107
|
+
</Pressable>
|
|
108
|
+
</Link>
|
|
109
|
+
|
|
110
|
+
<SignOutButton />
|
|
111
|
+
</View>
|
|
112
|
+
</SignedIn>
|
|
113
|
+
</View>
|
|
114
|
+
</View>
|
|
115
|
+
</Screen>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const styles = StyleSheet.create({
|
|
120
|
+
cardShadow: {
|
|
121
|
+
shadowColor: "#0f172a",
|
|
122
|
+
shadowOffset: { width: 0, height: 16 },
|
|
123
|
+
shadowOpacity: 0.16,
|
|
124
|
+
shadowRadius: 24,
|
|
125
|
+
elevation: 8,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExpoConfig, ConfigContext } from "expo/config";
|
|
2
|
+
|
|
3
|
+
export default ({ config }: ConfigContext): ExpoConfig => ({
|
|
4
|
+
...config,
|
|
5
|
+
name: "__APP_NAME__",
|
|
6
|
+
slug: "__APP_SLUG__",
|
|
7
|
+
version: "0.1.0",
|
|
8
|
+
orientation: "portrait",
|
|
9
|
+
icon: "./assets/images/icon.png",
|
|
10
|
+
scheme: "airborne",
|
|
11
|
+
userInterfaceStyle: "automatic",
|
|
12
|
+
ios: {
|
|
13
|
+
supportsTablet: true,
|
|
14
|
+
bundleIdentifier: "__APP_BUNDLE_ID__",
|
|
15
|
+
},
|
|
16
|
+
android: {
|
|
17
|
+
package: "__APP_BUNDLE_ID__",
|
|
18
|
+
adaptiveIcon: {
|
|
19
|
+
backgroundColor: "#f4f4f5",
|
|
20
|
+
foregroundImage: "./assets/images/android-icon-foreground.png",
|
|
21
|
+
backgroundImage: "./assets/images/android-icon-background.png",
|
|
22
|
+
monochromeImage: "./assets/images/android-icon-monochrome.png",
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
plugins: ["expo-router", "expo-notifications", "expo-secure-store"],
|
|
26
|
+
experiments: {
|
|
27
|
+
typedRoutes: true,
|
|
28
|
+
reactCompiler: true,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const { getDefaultConfig } = require("expo/metro-config");
|
|
2
|
+
const { withUniwindConfig } = require("uniwind/metro");
|
|
3
|
+
|
|
4
|
+
const config = getDefaultConfig(__dirname);
|
|
5
|
+
|
|
6
|
+
module.exports = withUniwindConfig(config, {
|
|
7
|
+
cssEntryFile: "./global.css",
|
|
8
|
+
dtsFile: "./uniwind-types.d.ts",
|
|
9
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "expo-router/entry",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"start": "expo start",
|
|
8
|
+
"android": "expo run:android",
|
|
9
|
+
"ios": "expo run:ios",
|
|
10
|
+
"lint": "expo lint",
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"test": "vitest run"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@clerk/clerk-expo": "^2.19.22",
|
|
16
|
+
"@expo/vector-icons": "^15.0.3",
|
|
17
|
+
"convex": "^1.28.2",
|
|
18
|
+
"expo": "^55.0.0-preview.10",
|
|
19
|
+
"expo-constants": "~55.0.4",
|
|
20
|
+
"expo-device": "~55.0.6",
|
|
21
|
+
"expo-linking": "~55.0.4",
|
|
22
|
+
"expo-notifications": "~55.0.6",
|
|
23
|
+
"expo-router": "~55.0.0-preview.7",
|
|
24
|
+
"expo-secure-store": "~55.0.5",
|
|
25
|
+
"expo-status-bar": "~55.0.2",
|
|
26
|
+
"expo-system-ui": "~55.0.5",
|
|
27
|
+
"react": "19.2.0",
|
|
28
|
+
"react-dom": "19.2.0",
|
|
29
|
+
"react-native": "0.83.1",
|
|
30
|
+
"react-native-gesture-handler": "~2.30.0",
|
|
31
|
+
"react-native-mmkv": "^4.1.2",
|
|
32
|
+
"react-native-reanimated": "~4.2.1",
|
|
33
|
+
"react-native-safe-area-context": "~5.6.0",
|
|
34
|
+
"react-native-screens": "~4.22.0",
|
|
35
|
+
"react-native-worklets": "0.7.2",
|
|
36
|
+
"tailwindcss": "^4.1.12",
|
|
37
|
+
"uniwind": "^1.3.0",
|
|
38
|
+
"zod": "^3.25.76",
|
|
39
|
+
"zustand": "^5.0.8"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/react": "~19.2.2",
|
|
43
|
+
"eslint": "^9.25.0",
|
|
44
|
+
"eslint-config-expo": "~55.0.0",
|
|
45
|
+
"typescript": "~5.9.2",
|
|
46
|
+
"vitest": "^3.2.4"
|
|
47
|
+
},
|
|
48
|
+
"overrides": {
|
|
49
|
+
"react-native-worklets": "0.8.0-nightly-20260122-45349f895"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { PropsWithChildren, ReactNode } from "react";
|
|
2
|
+
import { KeyboardAvoidingView, Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { Screen } from "@/src/components/screen";
|
|
4
|
+
|
|
5
|
+
type AuthShellProps = PropsWithChildren<{
|
|
6
|
+
badge: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
footer?: ReactNode;
|
|
10
|
+
}>;
|
|
11
|
+
|
|
12
|
+
export function AuthShell({ badge, title, subtitle, footer, children }: AuthShellProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Screen className="bg-slate-100 px-5 py-4 dark:bg-zinc-950">
|
|
15
|
+
<View className="flex-1">
|
|
16
|
+
<View className="absolute -top-20 -right-16 h-52 w-52 rounded-full bg-cyan-200 dark:bg-cyan-900" />
|
|
17
|
+
<View className="absolute -bottom-24 -left-20 h-64 w-64 rounded-full bg-sky-200 dark:bg-sky-900" />
|
|
18
|
+
|
|
19
|
+
<KeyboardAvoidingView
|
|
20
|
+
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
|
21
|
+
className="flex-1 justify-center"
|
|
22
|
+
>
|
|
23
|
+
<ScrollView
|
|
24
|
+
bounces={false}
|
|
25
|
+
keyboardShouldPersistTaps="handled"
|
|
26
|
+
contentContainerClassName="flex-grow justify-center py-10"
|
|
27
|
+
>
|
|
28
|
+
<View
|
|
29
|
+
style={styles.cardShadow}
|
|
30
|
+
className="rounded-3xl border border-zinc-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900"
|
|
31
|
+
>
|
|
32
|
+
<View className="mb-6 gap-3">
|
|
33
|
+
<Text className="text-xs font-semibold uppercase tracking-[2px] text-sky-700 dark:text-sky-300">
|
|
34
|
+
{badge}
|
|
35
|
+
</Text>
|
|
36
|
+
<Text className="text-4xl font-black leading-tight text-zinc-900 dark:text-zinc-50">
|
|
37
|
+
{title}
|
|
38
|
+
</Text>
|
|
39
|
+
<Text className="text-base leading-6 text-zinc-600 dark:text-zinc-300">
|
|
40
|
+
{subtitle}
|
|
41
|
+
</Text>
|
|
42
|
+
</View>
|
|
43
|
+
|
|
44
|
+
<View className="gap-4">{children}</View>
|
|
45
|
+
|
|
46
|
+
{footer ? <View className="mt-6">{footer}</View> : null}
|
|
47
|
+
</View>
|
|
48
|
+
</ScrollView>
|
|
49
|
+
</KeyboardAvoidingView>
|
|
50
|
+
</View>
|
|
51
|
+
</Screen>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const styles = StyleSheet.create({
|
|
56
|
+
cardShadow: {
|
|
57
|
+
shadowColor: "#0f172a",
|
|
58
|
+
shadowOffset: { width: 0, height: 16 },
|
|
59
|
+
shadowOpacity: 0.16,
|
|
60
|
+
shadowRadius: 24,
|
|
61
|
+
elevation: 8,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Text, TextInput, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
type FormInputProps = {
|
|
5
|
+
label: string;
|
|
6
|
+
value: string;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
hint?: string;
|
|
9
|
+
keyboardType?: "default" | "email-address" | "number-pad" | "numeric" | "phone-pad";
|
|
10
|
+
autoComplete?: "off" | "email" | "password" | "one-time-code" | "name" | "username" | "tel";
|
|
11
|
+
textContentType?:
|
|
12
|
+
| "none"
|
|
13
|
+
| "emailAddress"
|
|
14
|
+
| "password"
|
|
15
|
+
| "oneTimeCode"
|
|
16
|
+
| "name"
|
|
17
|
+
| "username"
|
|
18
|
+
| "telephoneNumber";
|
|
19
|
+
autoCapitalize?: "none" | "sentences" | "words" | "characters";
|
|
20
|
+
secureTextEntry?: boolean;
|
|
21
|
+
onChangeText: (value: string) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function FormInput({
|
|
25
|
+
label,
|
|
26
|
+
value,
|
|
27
|
+
placeholder,
|
|
28
|
+
hint,
|
|
29
|
+
keyboardType = "default",
|
|
30
|
+
autoComplete = "off",
|
|
31
|
+
textContentType = "none",
|
|
32
|
+
autoCapitalize = "none",
|
|
33
|
+
secureTextEntry = false,
|
|
34
|
+
onChangeText,
|
|
35
|
+
}: FormInputProps) {
|
|
36
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View className="gap-2">
|
|
40
|
+
<Text className="text-sm font-medium text-zinc-800 dark:text-zinc-100">{label}</Text>
|
|
41
|
+
<TextInput
|
|
42
|
+
value={value}
|
|
43
|
+
onChangeText={onChangeText}
|
|
44
|
+
placeholder={placeholder}
|
|
45
|
+
keyboardType={keyboardType}
|
|
46
|
+
autoComplete={autoComplete}
|
|
47
|
+
textContentType={textContentType}
|
|
48
|
+
autoCapitalize={autoCapitalize}
|
|
49
|
+
secureTextEntry={secureTextEntry}
|
|
50
|
+
onFocus={() => setIsFocused(true)}
|
|
51
|
+
onBlur={() => setIsFocused(false)}
|
|
52
|
+
className={`rounded-2xl border px-4 py-3.5 text-base text-zinc-900 dark:text-zinc-100 ${
|
|
53
|
+
isFocused
|
|
54
|
+
? "border-sky-500 bg-white dark:border-sky-400 dark:bg-zinc-950"
|
|
55
|
+
: "border-zinc-300 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900"
|
|
56
|
+
}`}
|
|
57
|
+
placeholderTextColor="#71717a"
|
|
58
|
+
/>
|
|
59
|
+
{hint ? <Text className="text-xs text-zinc-500 dark:text-zinc-400">{hint}</Text> : null}
|
|
60
|
+
</View>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { PropsWithChildren } from "react";
|
|
2
|
+
import { Pressable, Text } from "react-native";
|
|
3
|
+
|
|
4
|
+
type PrimaryButtonProps = PropsWithChildren<{
|
|
5
|
+
onPress: () => void | Promise<void>;
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
tone?: "primary" | "muted";
|
|
8
|
+
}>;
|
|
9
|
+
|
|
10
|
+
export function PrimaryButton({
|
|
11
|
+
children,
|
|
12
|
+
onPress,
|
|
13
|
+
disabled = false,
|
|
14
|
+
tone = "primary",
|
|
15
|
+
}: PrimaryButtonProps) {
|
|
16
|
+
const activeClassName =
|
|
17
|
+
tone === "primary"
|
|
18
|
+
? "border border-sky-700 bg-sky-600 dark:border-sky-500 dark:bg-sky-500"
|
|
19
|
+
: "border border-zinc-300 bg-zinc-100 dark:border-zinc-700 dark:bg-zinc-800";
|
|
20
|
+
|
|
21
|
+
const textClassName =
|
|
22
|
+
tone === "primary" ? "text-white dark:text-zinc-950" : "text-zinc-900 dark:text-zinc-100";
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<Pressable
|
|
26
|
+
disabled={disabled}
|
|
27
|
+
onPress={onPress}
|
|
28
|
+
className={`rounded-2xl px-4 py-3.5 ${disabled ? "border border-zinc-300 bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-700" : activeClassName}`}
|
|
29
|
+
>
|
|
30
|
+
<Text
|
|
31
|
+
className={`text-center text-base font-semibold ${disabled ? "text-zinc-500 dark:text-zinc-400" : textClassName}`}
|
|
32
|
+
>
|
|
33
|
+
{children}
|
|
34
|
+
</Text>
|
|
35
|
+
</Pressable>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { PropsWithChildren } from "react";
|
|
2
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
3
|
+
import { withUniwind } from "uniwind";
|
|
4
|
+
|
|
5
|
+
const StyledSafeAreaView = withUniwind(SafeAreaView);
|
|
6
|
+
|
|
7
|
+
type ScreenProps = PropsWithChildren<{
|
|
8
|
+
className?: string;
|
|
9
|
+
}>;
|
|
10
|
+
|
|
11
|
+
export function Screen({ children, className = "" }: ScreenProps) {
|
|
12
|
+
return (
|
|
13
|
+
<StyledSafeAreaView className={`flex-1 bg-white p-4 dark:bg-zinc-950 ${className}`}>
|
|
14
|
+
{children}
|
|
15
|
+
</StyledSafeAreaView>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useClerk } from "@clerk/clerk-expo";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Text } from "react-native";
|
|
4
|
+
import { PrimaryButton } from "@/src/components/primary-button";
|
|
5
|
+
|
|
6
|
+
export function SignOutButton() {
|
|
7
|
+
const { signOut } = useClerk();
|
|
8
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
9
|
+
const [error, setError] = useState<string | null>(null);
|
|
10
|
+
|
|
11
|
+
const handleSignOut = async () => {
|
|
12
|
+
setIsSubmitting(true);
|
|
13
|
+
setError(null);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await signOut();
|
|
17
|
+
} catch {
|
|
18
|
+
setError("Unable to sign out. Try again.");
|
|
19
|
+
} finally {
|
|
20
|
+
setIsSubmitting(false);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<>
|
|
26
|
+
<PrimaryButton onPress={handleSignOut} disabled={isSubmitting}>
|
|
27
|
+
{isSubmitting ? "Signing out..." : "Sign out"}
|
|
28
|
+
</PrimaryButton>
|
|
29
|
+
{error ? <Text className="text-sm text-red-500">{error}</Text> : null}
|
|
30
|
+
</>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Uniwind } from "uniwind";
|
|
3
|
+
import { usePreferencesStore } from "@/src/store/preferences-store";
|
|
4
|
+
|
|
5
|
+
export function useThemeSync() {
|
|
6
|
+
const theme = usePreferencesStore((state) => state.theme);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
Uniwind.setTheme(theme);
|
|
10
|
+
}, [theme]);
|
|
11
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const clientEnvSchema = z.object({
|
|
4
|
+
clerkPublishableKey: z.string().min(1),
|
|
5
|
+
convexUrl: z.string().url(),
|
|
6
|
+
easProjectId: z.string().optional(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type ClientEnv = z.infer<typeof clientEnvSchema>;
|
|
10
|
+
|
|
11
|
+
export function parseClientEnv(raw: unknown): ClientEnv {
|
|
12
|
+
return clientEnvSchema.parse(raw);
|
|
13
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseClientEnv } from "./env-schema";
|
|
3
|
+
|
|
4
|
+
describe("parseClientEnv", () => {
|
|
5
|
+
it("parses valid values", () => {
|
|
6
|
+
const env = parseClientEnv({
|
|
7
|
+
clerkPublishableKey: "pk_test_example",
|
|
8
|
+
convexUrl: "https://example.convex.cloud",
|
|
9
|
+
easProjectId: "project-id",
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(env.clerkPublishableKey).toBe("pk_test_example");
|
|
13
|
+
expect(env.convexUrl).toBe("https://example.convex.cloud");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("throws for invalid values", () => {
|
|
17
|
+
expect(() =>
|
|
18
|
+
parseClientEnv({
|
|
19
|
+
clerkPublishableKey: "",
|
|
20
|
+
convexUrl: "invalid-url",
|
|
21
|
+
}),
|
|
22
|
+
).toThrowError();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { parseClientEnv, type ClientEnv } from "@/src/lib/env-schema";
|
|
2
|
+
|
|
3
|
+
let cachedEnv: ClientEnv | null = null;
|
|
4
|
+
|
|
5
|
+
export { parseClientEnv, type ClientEnv };
|
|
6
|
+
|
|
7
|
+
export function getClientEnv() {
|
|
8
|
+
if (cachedEnv) {
|
|
9
|
+
return cachedEnv;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
cachedEnv = parseClientEnv({
|
|
13
|
+
clerkPublishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY,
|
|
14
|
+
convexUrl: process.env.EXPO_PUBLIC_CONVEX_URL,
|
|
15
|
+
easProjectId: process.env.EXPO_PUBLIC_EAS_PROJECT_ID,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
return cachedEnv;
|
|
19
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import Constants from "expo-constants";
|
|
2
|
+
import * as Device from "expo-device";
|
|
3
|
+
import * as Notifications from "expo-notifications";
|
|
4
|
+
import { Platform } from "react-native";
|
|
5
|
+
import { getClientEnv } from "@/src/lib/env";
|
|
6
|
+
|
|
7
|
+
Notifications.setNotificationHandler({
|
|
8
|
+
handleNotification: async () => ({
|
|
9
|
+
shouldShowBanner: true,
|
|
10
|
+
shouldShowList: true,
|
|
11
|
+
shouldPlaySound: false,
|
|
12
|
+
shouldSetBadge: false,
|
|
13
|
+
}),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export async function registerForPushNotificationsAsync() {
|
|
17
|
+
if (!Device.isDevice) {
|
|
18
|
+
throw new Error("Push notifications require a physical device.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const existing = await Notifications.getPermissionsAsync();
|
|
22
|
+
let finalStatus = existing.status;
|
|
23
|
+
if (existing.status !== "granted") {
|
|
24
|
+
const requested = await Notifications.requestPermissionsAsync();
|
|
25
|
+
finalStatus = requested.status;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (finalStatus !== "granted") {
|
|
29
|
+
throw new Error("Push notification permission was not granted.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { easProjectId } = getClientEnv();
|
|
33
|
+
const projectId = easProjectId ?? Constants.expoConfig?.extra?.eas?.projectId ?? undefined;
|
|
34
|
+
|
|
35
|
+
const token = await Notifications.getExpoPushTokenAsync({ projectId });
|
|
36
|
+
|
|
37
|
+
if (Platform.OS === "android") {
|
|
38
|
+
await Notifications.setNotificationChannelAsync("default", {
|
|
39
|
+
name: "default",
|
|
40
|
+
importance: Notifications.AndroidImportance.MAX,
|
|
41
|
+
vibrationPattern: [0, 250, 250, 250],
|
|
42
|
+
lightColor: "#ffffff",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return token.data;
|
|
47
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createMMKV } from "react-native-mmkv";
|
|
2
|
+
import { create } from "zustand";
|
|
3
|
+
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
|
|
4
|
+
import type { ThemePreference } from "@/src/types/theme";
|
|
5
|
+
|
|
6
|
+
const mmkv = createMMKV({ id: "airborne-preferences" });
|
|
7
|
+
|
|
8
|
+
const zustandStorage: StateStorage = {
|
|
9
|
+
setItem: (name, value) => {
|
|
10
|
+
mmkv.set(name, value);
|
|
11
|
+
},
|
|
12
|
+
getItem: (name) => mmkv.getString(name) ?? null,
|
|
13
|
+
removeItem: (name) => {
|
|
14
|
+
mmkv.remove(name);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type PreferencesState = {
|
|
19
|
+
theme: ThemePreference;
|
|
20
|
+
lastPushToken: string | null;
|
|
21
|
+
setTheme: (theme: ThemePreference) => void;
|
|
22
|
+
setLastPushToken: (token: string | null) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const usePreferencesStore = create<PreferencesState>()(
|
|
26
|
+
persist(
|
|
27
|
+
(set) => ({
|
|
28
|
+
theme: "system",
|
|
29
|
+
lastPushToken: null,
|
|
30
|
+
setTheme: (theme) => set({ theme }),
|
|
31
|
+
setLastPushToken: (lastPushToken) => set({ lastPushToken }),
|
|
32
|
+
}),
|
|
33
|
+
{
|
|
34
|
+
name: "airborne-preferences-store",
|
|
35
|
+
storage: createJSONStorage(() => zustandStorage),
|
|
36
|
+
partialize: (state) => ({
|
|
37
|
+
theme: state.theme,
|
|
38
|
+
lastPushToken: state.lastPushToken,
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
),
|
|
42
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type ThemePreference = "light" | "dark" | "system";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "expo/tsconfig.base",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": true,
|
|
5
|
+
"paths": {
|
|
6
|
+
"@/*": ["./*"],
|
|
7
|
+
"@convex/*": ["../server/convex/*"]
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"include": [
|
|
11
|
+
"**/*.ts",
|
|
12
|
+
"**/*.tsx",
|
|
13
|
+
"../server/convex/_generated/**/*.ts",
|
|
14
|
+
".expo/types/**/*.ts",
|
|
15
|
+
"expo-env.d.ts",
|
|
16
|
+
"uniwind-types.d.ts"
|
|
17
|
+
]
|
|
18
|
+
}
|