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.
Files changed (78) hide show
  1. package/README.md +24 -0
  2. package/package.json +21 -0
  3. package/src/index.mjs +103 -0
  4. package/template/.agents/skills/convex-best-practices/SKILL.md +333 -0
  5. package/template/.agents/skills/convex-file-storage/SKILL.md +466 -0
  6. package/template/.agents/skills/convex-security-audit/SKILL.md +538 -0
  7. package/template/.agents/skills/convex-security-check/SKILL.md +377 -0
  8. package/template/.github/workflows/ci.yml +130 -0
  9. package/template/.prettierignore +8 -0
  10. package/template/.prettierrc.json +6 -0
  11. package/template/AGENTS.md +156 -0
  12. package/template/Justfile +48 -0
  13. package/template/README.md +94 -0
  14. package/template/client/.env.example +3 -0
  15. package/template/client/.vscode/extensions.json +1 -0
  16. package/template/client/.vscode/settings.json +7 -0
  17. package/template/client/README.md +33 -0
  18. package/template/client/app/(app)/_layout.tsx +34 -0
  19. package/template/client/app/(app)/index.tsx +66 -0
  20. package/template/client/app/(app)/push.tsx +75 -0
  21. package/template/client/app/(app)/settings.tsx +36 -0
  22. package/template/client/app/(auth)/_layout.tsx +22 -0
  23. package/template/client/app/(auth)/sign-in.tsx +358 -0
  24. package/template/client/app/(auth)/sign-up.tsx +237 -0
  25. package/template/client/app/_layout.tsx +30 -0
  26. package/template/client/app/index.tsx +127 -0
  27. package/template/client/app.config.ts +30 -0
  28. package/template/client/assets/images/android-icon-background.png +0 -0
  29. package/template/client/assets/images/android-icon-foreground.png +0 -0
  30. package/template/client/assets/images/android-icon-monochrome.png +0 -0
  31. package/template/client/assets/images/favicon.png +0 -0
  32. package/template/client/assets/images/icon.png +0 -0
  33. package/template/client/assets/images/partial-react-logo.png +0 -0
  34. package/template/client/assets/images/react-logo.png +0 -0
  35. package/template/client/assets/images/react-logo@2x.png +0 -0
  36. package/template/client/assets/images/react-logo@3x.png +0 -0
  37. package/template/client/assets/images/splash-icon.png +0 -0
  38. package/template/client/eslint.config.js +10 -0
  39. package/template/client/global.css +2 -0
  40. package/template/client/metro.config.js +9 -0
  41. package/template/client/package.json +51 -0
  42. package/template/client/src/components/auth-shell.tsx +63 -0
  43. package/template/client/src/components/form-input.tsx +62 -0
  44. package/template/client/src/components/primary-button.tsx +37 -0
  45. package/template/client/src/components/screen.tsx +17 -0
  46. package/template/client/src/components/sign-out-button.tsx +32 -0
  47. package/template/client/src/hooks/use-theme-sync.ts +11 -0
  48. package/template/client/src/lib/convex.ts +6 -0
  49. package/template/client/src/lib/env-schema.ts +13 -0
  50. package/template/client/src/lib/env.test.ts +24 -0
  51. package/template/client/src/lib/env.ts +19 -0
  52. package/template/client/src/lib/notifications.ts +47 -0
  53. package/template/client/src/store/preferences-store.ts +42 -0
  54. package/template/client/src/types/theme.ts +1 -0
  55. package/template/client/tsconfig.json +18 -0
  56. package/template/client/uniwind-types.d.ts +10 -0
  57. package/template/client/vitest.config.ts +7 -0
  58. package/template/package.json +22 -0
  59. package/template/server/.env.example +8 -0
  60. package/template/server/README.md +31 -0
  61. package/template/server/convex/_generated/api.d.ts +55 -0
  62. package/template/server/convex/_generated/api.js +23 -0
  63. package/template/server/convex/_generated/dataModel.d.ts +60 -0
  64. package/template/server/convex/_generated/server.d.ts +143 -0
  65. package/template/server/convex/_generated/server.js +93 -0
  66. package/template/server/convex/auth.config.ts +11 -0
  67. package/template/server/convex/env.ts +18 -0
  68. package/template/server/convex/lib.ts +12 -0
  69. package/template/server/convex/push.ts +148 -0
  70. package/template/server/convex/schema.ts +22 -0
  71. package/template/server/convex/users.ts +54 -0
  72. package/template/server/convex.json +3 -0
  73. package/template/server/eslint.config.js +51 -0
  74. package/template/server/package.json +29 -0
  75. package/template/server/tests/convex.test.ts +52 -0
  76. package/template/server/tests/import-meta.d.ts +3 -0
  77. package/template/server/tsconfig.json +15 -0
  78. 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
+ });
@@ -0,0 +1,10 @@
1
+ // https://docs.expo.dev/guides/using-eslint/
2
+ const { defineConfig } = require("eslint/config");
3
+ const expoConfig = require("eslint-config-expo/flat");
4
+
5
+ module.exports = defineConfig([
6
+ ...expoConfig,
7
+ {
8
+ ignores: ["dist/*"],
9
+ },
10
+ ]);
@@ -0,0 +1,2 @@
1
+ @import "tailwindcss";
2
+ @import "uniwind";
@@ -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,6 @@
1
+ import { ConvexReactClient } from "convex/react";
2
+ import { getClientEnv } from "@/src/lib/env";
3
+
4
+ const env = getClientEnv();
5
+
6
+ export const convexClient = new ConvexReactClient(env.convexUrl);
@@ -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
+ }