feat: turbostarter boilerplate

Production-ready Next.js boilerplate with:
- Runtime env validation (fail-fast on missing vars)
- Feature-gated config (S3, Stripe, email, OAuth)
- Docker + Coolify deployment pipeline
- PostgreSQL + pgvector, MinIO S3, Better Auth
- TypeScript strict mode (no ignoreBuildErrors)
- i18n (en/es), AI modules, billing, monitoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-02 17:29:12 +00:00
commit 3527e732d4
1618 changed files with 338230 additions and 0 deletions

View File

@@ -0,0 +1,379 @@
import { useMutation, useMutationState } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react";
import { FormProvider, useForm, useFormContext } from "react-hook-form";
import { Alert, Text, View } from "react-native";
import * as z from "zod";
import { handle } from "@turbostarter/api/utils";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-mobile/avatar";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { api } from "~/lib/api";
import { useImagePicker } from "~/modules/common/hooks/use-image-picker";
import type { ImagePickerAsset } from "expo-image-picker";
interface AvatarFormProps {
readonly id: string;
readonly image?: string | null;
readonly update: (image: string | null) => Promise<unknown>;
}
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ACCEPTED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
];
const mutations = {
upload: {
mutationKey: ["avatar", "upload"] as const,
mutationFn: async ({
avatar,
id,
image,
update,
}: AvatarFormProps & { avatar?: ImagePickerAsset }) => {
if (!avatar) throw new Error("No file selected");
const guessedExtensionFromMime = avatar.mimeType?.split("/").pop();
const guessedExtensionFromUri = avatar.uri.split(".").pop();
const extension =
guessedExtensionFromMime ?? guessedExtensionFromUri ?? "jpg";
const uuid = String(Date.now());
const path = `avatars/${id}-${uuid}.${extension}`;
const blob = await fetch(avatar.uri).then((r) => r.blob());
const { url: uploadUrl } = await handle(api.storage.upload.$get)({
query: { path },
});
const response = await fetch(uploadUrl, {
method: "PUT",
body: blob,
headers: {
"Content-Type": avatar.mimeType ?? "",
},
});
if (!response.ok) {
throw new Error();
}
const { url: publicUrl } = await handle(api.storage.public.$get)({
query: { path },
});
await update(publicUrl);
return { publicUrl, oldImage: image };
},
},
remove: {
mutationKey: ["avatar", "remove"] as const,
mutationFn: async ({ image, update }: Omit<AvatarFormProps, "id">) => {
const path = image?.split("/").pop();
if (!path) return;
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
query: { path: `avatars/${path}` },
});
await update(null);
void fetch(deleteUrl, { method: "DELETE" });
},
},
} as const;
const useAvatarFormSchema = () => {
const assetSchema = z.object({
uri: z.string().min(1),
mimeType: z.string().optional(),
fileSize: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
});
return z.object({
avatar: assetSchema
.refine((file) => (file.fileSize ?? MAX_FILE_SIZE) <= MAX_FILE_SIZE, {
message: "error.tooBig.file.inclusive",
path: ["avatar"],
})
.refine(
(file) =>
!file.mimeType || ACCEPTED_IMAGE_TYPES.includes(file.mimeType),
{
message: "error.file.type",
path: ["avatar"],
},
),
});
};
interface AvatarFormContextValue extends AvatarFormProps {
previewUrl: string | null;
setPreviewUrl: (previewUrl: string | null) => void;
}
const AvatarFormContext = createContext<AvatarFormContextValue | null>(null);
const useAvatarFormContext = () => {
const context = useContext(AvatarFormContext);
if (!context) {
throw new Error("useAvatarFormContext must be used within a AvatarForm!");
}
return context;
};
const AvatarForm = ({
id,
image,
update,
children,
}: AvatarFormProps & { children: React.ReactNode }) => {
const [previewUrl, setPreviewUrl] = useState(image ?? null);
const _avatarSchema = useAvatarFormSchema();
const form = useForm<z.infer<typeof _avatarSchema>>();
return (
<AvatarFormContext.Provider
value={{ id, image, update, previewUrl, setPreviewUrl }}
>
<FormProvider {...form}>{children}</FormProvider>
</AvatarFormContext.Provider>
);
};
const AvatarFormUploadButton = ({
className,
onUpload,
disabled,
...props
}: React.ComponentProps<typeof Button> & { onUpload?: () => void }) => {
const { t } = useTranslation(["common", "validation"]);
const { pick } = useImagePicker();
const { image, setPreviewUrl, id, update } = useAvatarFormContext();
const avatarSchema = useAvatarFormSchema();
const { setError, clearErrors } =
useFormContext<z.infer<typeof avatarSchema>>();
const upload = useMutation({
...mutations.upload,
onError: (error) => {
setPreviewUrl(image ?? null);
Alert.alert(
t("common:error.title"),
error.message || t("common:error.general"),
);
},
onSuccess: async ({ publicUrl, oldImage }) => {
clearErrors();
setPreviewUrl(publicUrl);
if (oldImage) {
const oldPath = oldImage.split("/").pop();
if (oldPath) {
const { url: deleteUrl } = await handle(api.storage.delete.$get)({
query: { path: `avatars/${oldPath}` },
});
void fetch(deleteUrl, { method: "DELETE" });
}
}
onUpload?.();
},
});
const [removeStatus] = useMutationState({
filters: { mutationKey: mutations.remove.mutationKey },
select: (mutation) => mutation.state.status,
});
const handlePick = async () => {
const asset = await pick();
if (!asset) {
return;
}
const result = avatarSchema.safeParse({ avatar: asset });
if (!result.success) {
const firstIssue = result.error.issues[0];
const firstMsg = firstIssue?.message ?? "";
setError("avatar", { message: firstMsg });
Alert.alert(t("common:error.title"), firstMsg);
return;
}
setPreviewUrl(asset.uri);
upload.mutate({
avatar: asset,
id,
image,
update,
});
};
return (
<Button
hitSlop={4}
variant="outline"
size="icon"
className={cn(
"dark:bg-background active:bg-muted absolute -right-2 -bottom-2.5 rounded-full",
className,
)}
onPress={handlePick}
disabled={disabled ?? (upload.isPending || removeStatus === "pending")}
{...props}
>
<Icons.Pencil size={14} className="text-foreground" />
</Button>
);
};
const AvatarFormPreview = ({
className,
fallback,
...props
}: React.ComponentProps<typeof Avatar> & { fallback?: React.ReactNode }) => {
const { previewUrl } = useAvatarFormContext();
const _avatarSchema = useAvatarFormSchema();
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
const mutationStatuses = useMutationState({
filters: {
predicate: (mutation) =>
mutation.options.mutationKey === mutations.upload.mutationKey ||
mutation.options.mutationKey === mutations.remove.mutationKey,
},
select: (mutation) => mutation.state.status,
});
const hasError =
Boolean(formState.errors.avatar) ||
mutationStatuses.some((s) => s === "error");
return (
<View className="relative">
<Avatar
className={cn(
"size-26",
hasError ? "ring-destructive ring-2 ring-offset-2" : "",
className,
)}
{...props}
>
{previewUrl && <AvatarImage source={{ uri: previewUrl }} />}
{mutationStatuses.some((status) => status === "pending") && (
<>
<View className="bg-background absolute inset-0 rounded-full opacity-50" />
<View className="absolute inset-0 items-center justify-center rounded-full">
<Spin>
<Icons.Loader2 className="text-muted-foreground" size={28} />
</Spin>
</View>
</>
)}
<AvatarFallback>
{fallback ?? (
<Icons.UserRound
width={50}
height={50}
className="text-foreground"
/>
)}
</AvatarFallback>
</Avatar>
</View>
);
};
const AvatarFormRemoveButton = ({
className,
onRemove,
...props
}: React.ComponentProps<typeof Button> & { onRemove?: () => void }) => {
const { image, update, previewUrl, setPreviewUrl } = useAvatarFormContext();
const { clearErrors } = useFormContext();
const [uploadStatus] = useMutationState({
filters: {
mutationKey: mutations.upload.mutationKey,
},
select: (mutation) => mutation.state.status,
});
const remove = useMutation({
...mutations.remove,
onMutate: () => {
setPreviewUrl(null);
},
onSuccess: () => {
setPreviewUrl(null);
onRemove?.();
},
});
if (!previewUrl || uploadStatus === "pending") {
return null;
}
return (
<Button
variant="outline"
size="icon"
className={cn(
"dark:bg-background active:bg-muted absolute -top-2 -right-2 rounded-full",
className,
)}
disabled={remove.isPending}
onPress={() => {
clearErrors();
remove.mutate({ image, update });
}}
{...props}
>
<Icons.X size={16} strokeWidth={2} className="text-foreground" />
</Button>
);
};
const AvatarFormErrorMessage = ({
className,
...props
}: React.ComponentProps<typeof Text>) => {
const _avatarSchema = useAvatarFormSchema();
const { formState } = useFormContext<z.infer<typeof _avatarSchema>>();
if (!formState.errors.avatar) {
return null;
}
return (
<Text className={cn("text-destructive text-xs", className)} {...props}>
{formState.errors.avatar.message}
</Text>
);
};
export {
AvatarForm,
AvatarFormPreview,
AvatarFormRemoveButton,
AvatarFormUploadButton,
AvatarFormErrorMessage,
};

View File

@@ -0,0 +1,21 @@
import * as Clipboard from "expo-clipboard";
import { useCallback, useState } from "react";
import { logger } from "@turbostarter/shared/logger";
export const useCopyToClipboard = () => {
const [copiedText, setCopiedText] = useState<string | null>(null);
const copy = useCallback(async (text: string): Promise<boolean> => {
try {
await Clipboard.setStringAsync(text);
setCopiedText(text);
return true;
} catch (error) {
logger.error("Failed to copy to clipboard:", error);
return false;
}
}, []);
return [copiedText, copy] as const;
};

View File

@@ -0,0 +1,36 @@
import * as ExpoImagePicker from "expo-image-picker";
import { useCallback } from "react";
import { logger } from "@turbostarter/shared/logger";
export const useImagePicker = () => {
const pick = useCallback(async () => {
try {
const result = await ExpoImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
quality: 0.6,
});
if (result.canceled) {
return;
}
const pendingResult = await ExpoImagePicker.getPendingResultAsync();
const image =
result.assets[0] ??
(pendingResult && "assets" in pendingResult
? pendingResult.assets?.[0]
: null);
return image;
} catch (e) {
logger.error("Error on image pick: ", e);
}
}, []);
return {
pick,
};
};

View File

@@ -0,0 +1,19 @@
import { focusManager } from "@tanstack/react-query";
import { useEffect } from "react";
import { AppState, Platform } from "react-native";
import type { AppStateStatus } from "react-native";
export function useRefetchOnAppFocus() {
useEffect(() => {
const onAppStateChange = (status: AppStateStatus) => {
if (Platform.OS !== "web") {
focusManager.setFocused(status === "active");
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, []);
}

View File

@@ -0,0 +1,17 @@
import { useFocusEffect } from "@react-navigation/native";
import React from "react";
export function useRefetchOnFocus<T>(refetch: () => Promise<T>) {
const firstTimeRef = React.useRef(true);
useFocusEffect(
React.useCallback(() => {
if (firstTimeRef.current) {
firstTimeRef.current = false;
return;
}
void refetch();
}, [refetch]),
);
}

View File

@@ -0,0 +1,75 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useCallback, useEffect, useMemo } from "react";
import { useColorScheme } from "react-native";
import { Uniwind } from "uniwind";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { ThemeMode, themes } from "@turbostarter/ui";
import { appConfig } from "~/config/app";
import type { ColorVariable, ThemeConfig } from "@turbostarter/ui";
const useThemeConfig = create<{
config: ThemeConfig;
setConfig: (config: ThemeConfig) => void;
}>()(
persist(
(set) => ({
config: appConfig.theme,
setConfig: (config) => set({ config }),
}),
{
name: "theme-config",
storage: createJSONStorage(() => AsyncStorage),
},
),
);
export const useTheme = () => {
const colorScheme = useColorScheme();
const { config, setConfig } = useThemeConfig();
const isDark = useMemo(
() =>
config.mode === ThemeMode.DARK ||
(config.mode === ThemeMode.SYSTEM && colorScheme === ThemeMode.DARK),
[config.mode, colorScheme],
);
const resolvedTheme = useMemo(
() => (isDark ? ThemeMode.DARK : ThemeMode.LIGHT),
[isDark],
);
const updateTheme = useCallback(() => {
Uniwind.setTheme(config.mode);
const colors = themes[config.color][resolvedTheme];
Uniwind.updateCSSVariables(
resolvedTheme,
Object.entries(colors).reduce(
(acc, [key, value]: [string, ColorVariable]) => {
const [l, c, h, a] = value;
acc[`--${key}`] =
a !== undefined
? `oklch(${l} ${c} ${h} / ${a * 100}%)`
: `oklch(${l} ${c} ${h})`;
return acc;
},
{} as Record<string, string>,
),
);
}, [resolvedTheme, config]);
useEffect(() => {
updateTheme();
}, [updateTheme]);
return {
config,
setConfig,
resolvedTheme,
};
};

View File

@@ -0,0 +1,71 @@
import { Platform, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Text } from "@turbostarter/ui-mobile/text";
import { AccountSwitcher } from "~/modules/organization/account-switcher";
interface BaseHeaderProps {
readonly onBack?: () => void;
readonly title?: string;
}
export const BaseHeader = ({ onBack, title }: BaseHeaderProps) => {
const insets = useSafeAreaInsets();
return (
<View
className="bg-background"
style={{
paddingTop: Platform.select({
ios: insets.top + 8,
android: insets.top + 16,
}),
}}
>
<View className="h-12 w-full flex-row items-center justify-center gap-3 pb-1">
{onBack && (
<Button
size="icon"
variant="outline"
onPress={() => onBack()}
className="absolute bottom-2 left-6"
>
<Icons.ChevronLeft
width={22}
height={22}
className="text-muted-foreground"
/>
</Button>
)}
{title && (
<Text className="font-sans-medium mt-0.5 leading-none">{title}</Text>
)}
</View>
</View>
);
};
export const UserHeader = () => {
const insets = useSafeAreaInsets();
return (
<View
className="bg-background w-full flex-row items-center justify-between pr-6 pb-1 pl-4"
style={{
paddingTop: Platform.select({
ios: insets.top,
android: insets.top + 8,
}),
}}
>
<AccountSwitcher />
<Button size="icon" variant="ghost">
<Icons.Bell size={20} className="text-muted-foreground" />
</Button>
</View>
);
};

View File

@@ -0,0 +1,69 @@
import { Pressable } from "react-native";
import { cn } from "@turbostarter/ui";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { TextClassContext } from "@turbostarter/ui-mobile/text";
interface SettingsTileProps {
readonly icon: React.ElementType;
readonly children: React.ReactNode;
readonly onPress?: () => void;
readonly destructive?: boolean;
readonly loading?: boolean;
readonly disabled?: boolean;
}
export const SettingsTile = ({
icon: Icon,
onPress,
children,
destructive,
loading = false,
disabled = false,
}: SettingsTileProps) => {
return (
<Pressable
hitSlop={4}
className={cn(
"bg-background active:bg-accent dark:active:bg-accent/50 flex-row items-center justify-between gap-4 px-6 py-3.5 transition-colors",
{
"opacity-50": disabled,
},
)}
onPress={onPress}
disabled={disabled}
>
<Icon
width={24}
height={24}
className={cn("text-muted-foreground", {
"text-destructive": destructive,
})}
/>
<TextClassContext.Provider
value={cn("mr-auto text-base", {
"text-destructive": destructive,
})}
>
{children}
</TextClassContext.Provider>
{loading ? (
<Spin>
<Icons.Loader2
className="text-muted-foreground"
width={20}
height={20}
/>
</Spin>
) : (
<Icons.ChevronRight
className="text-muted-foreground"
width={20}
height={20}
/>
)}
</Pressable>
);
};

View File

@@ -0,0 +1,39 @@
import { Portal } from "@rn-primitives/portal";
import { Fragment } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
import { isIOS } from "~/utils/device";
const FullWindowOverlay = isIOS ? RNFullWindowOverlay : Fragment;
interface SpinnerProps {
readonly modal?: boolean;
}
export const Spinner = ({ modal = true }: SpinnerProps) => {
if (!modal) {
return (
<View
style={StyleSheet.absoluteFill}
className="bg-background flex-1 items-center justify-center"
>
<ActivityIndicator size="large" colorClassName="accent-primary" />
</View>
);
}
return (
<Portal name="spinner">
<FullWindowOverlay>
<View
style={StyleSheet.absoluteFill}
className="flex-1 items-center justify-center"
>
<View style={StyleSheet.absoluteFill} className="bg-background/80" />
<ActivityIndicator size="large" colorClassName="accent-primary" />
</View>
</FullWindowOverlay>
</Portal>
);
};

View File

@@ -0,0 +1,39 @@
import { BlurView as NativeBlurView } from "expo-blur";
import { GlassView as NativeGlassView } from "expo-glass-effect";
import { Link as NativeLink } from "expo-router";
import { ScrollView as NativeScrollView } from "react-native-gesture-handler";
import { KeyboardAvoidingView as NativeKeyboardAvoidingView } from "react-native-keyboard-controller";
import { SafeAreaView as NativeSafeAreaView } from "react-native-safe-area-context";
import { withUniwind } from "uniwind";
import { cn } from "@turbostarter/ui";
import { Text } from "@turbostarter/ui-mobile/text";
import { WIDTH } from "~/utils/device";
export const KeyboardAvoidingView = withUniwind(NativeKeyboardAvoidingView);
export const Link = withUniwind(NativeLink);
export const ScrollView = withUniwind(NativeScrollView);
export const SafeAreaView = withUniwind(NativeSafeAreaView);
export const BlurView = withUniwind(NativeBlurView);
export const GlassView = withUniwind(NativeGlassView);
export const TabBarLabel = ({
children,
focused,
}: {
children: string;
focused: boolean;
}) => {
return (
<Text
className={cn(
"text-muted-foreground text-xs",
focused && "text-primary",
WIDTH > 640 && "ml-3 text-sm",
)}
>
{children}
</Text>
);
};

View File

@@ -0,0 +1,298 @@
import { isLiquidGlassAvailable } from "expo-glass-effect";
import {
useUpdates,
reloadAsync,
fetchUpdateAsync,
checkForUpdateAsync,
} from "expo-updates";
import { createContext, useContext, useEffect, useState } from "react";
import { AppState, Platform, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { scheduleOnRN } from "react-native-worklets";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-mobile/button";
import { Icons } from "@turbostarter/ui-mobile/icons";
import { Progress } from "@turbostarter/ui-mobile/progress";
import { Spin } from "@turbostarter/ui-mobile/spin";
import { Text } from "@turbostarter/ui-mobile/text";
import { BlurView, GlassView } from "./styled";
import type { ViewProps } from "react-native";
const UpdatesContext = createContext<{
visible: boolean;
setVisible: (visible: boolean) => void;
}>({
visible: false,
setVisible: () => void 0,
});
const Wrapper = ({ className, style, ...props }: ViewProps) => {
const insets = useSafeAreaInsets();
const sharedClassName =
"absolute left-0 mx-5 gap-3 overflow-hidden rounded-lg border-border border p-5";
const offset = insets.top + (Platform.select({ ios: 4, android: 8 }) ?? 0);
const sharedStyle = {
top: offset,
};
if (isLiquidGlassAvailable()) {
return (
<GlassView
className={cn(sharedClassName, className)}
style={[sharedStyle, style]}
{...props}
/>
);
}
return (
<BlurView
className={cn(sharedClassName, className)}
style={[sharedStyle, style]}
intensity={200}
{...props}
/>
);
};
const Available = () => {
const { t } = useTranslation(["marketing", "common"]);
const { isUpdatePending } = useUpdates();
const { setVisible } = useContext(UpdatesContext);
return (
<>
<View className="gap-0.5">
<Text className="font-sans-medium text-lg leading-tight">
{t("update.available.title")}
</Text>
<Text className="text-muted-foreground text-sm">
{t("update.available.description")}
</Text>
</View>
<View className="flex-row gap-2">
<Button
variant="outline"
size="sm"
className="grow"
onPress={() => setVisible(false)}
>
<Text>{t("dismiss")}</Text>
</Button>
<Button
size="sm"
className="grow"
onPress={() => {
if (isUpdatePending) {
return reloadAsync();
}
return fetchUpdateAsync();
}}
>
<Text>{t("install")}</Text>
</Button>
</View>
</>
);
};
const Installing = () => {
const { t } = useTranslation(["marketing", "common"]);
const { downloadProgress, isUpdatePending } = useUpdates();
useEffect(() => {
if (isUpdatePending && (downloadProgress ?? 0) >= 1) {
void reloadAsync();
}
}, [isUpdatePending, downloadProgress]);
return (
<>
<View className="gap-0.5">
<View className="flex-row items-center justify-between gap-2">
<Text className="font-sans-medium text-lg leading-tight">
{t("update.installing.title")}
</Text>
<Spin>
<Icons.Loader2 className="text-primary" size={16} />
</Spin>
</View>
<Text className="text-muted-foreground text-sm">
{t("update.installing.description")}
</Text>
</View>
<Progress value={(downloadProgress ?? 0) * 100} />
</>
);
};
const DownloadError = () => {
const { t } = useTranslation("common");
const { isUpdatePending, downloadError } = useUpdates();
const { setVisible } = useContext(UpdatesContext);
if (!downloadError) {
return null;
}
return (
<>
<View className="gap-0.5">
<Text className="font-sans-medium text-lg leading-tight">
{t("error.general")}
</Text>
<Text className="text-muted-foreground text-sm">
{downloadError.message}
</Text>
</View>
<View className="flex-row gap-2">
<Button
variant="outline"
size="sm"
className="grow"
onPress={() => setVisible(false)}
>
<Text>{t("dismiss")}</Text>
</Button>
<Button
size="sm"
className="grow"
onPress={() => {
if (isUpdatePending) {
return reloadAsync();
}
return fetchUpdateAsync();
}}
>
<Text>{t("tryAgain")}</Text>
</Button>
</View>
</>
);
};
const Content = () => {
const insets = useSafeAreaInsets();
const { visible, setVisible } = useContext(UpdatesContext);
const { isUpdateAvailable, isUpdatePending, isDownloading, downloadError } =
useUpdates();
const positionY = useSharedValue(-1000);
const startY = useSharedValue(0);
const height = useSharedValue(0);
const offset = insets.top + (Platform.select({ ios: 4, android: 8 }) ?? 0);
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [{ translateY: withSpring(positionY.value) }],
};
});
const pan = Gesture.Pan()
.onBegin(() => {
startY.value = positionY.value;
})
.onChange((event) => {
const next = startY.value + event.translationY;
positionY.value = Math.min(0, next);
})
.onFinalize((event) => {
const threshold = Math.max(64, (height.value + offset) * 0.4);
const shouldDismiss =
positionY.value < -threshold || event.velocityY < -500;
if (shouldDismiss) {
positionY.value = withSpring(-(height.value + offset));
scheduleOnRN(setVisible, false);
} else {
positionY.value = withSpring(0);
}
});
useEffect(() => {
if (visible) {
positionY.value = withSpring(0);
} else {
positionY.value = withSpring(
height.value > 0 ? -(height.value + offset) : -1000,
);
}
}, [visible, positionY, offset, height]);
if (!isUpdateAvailable || !isUpdatePending) {
return null;
}
return (
<GestureDetector gesture={pan}>
<Animated.View style={[animatedStyle, { zIndex: 50 }]}>
<Wrapper
onLayout={(e) => {
const h = e.nativeEvent.layout.height;
height.value = h;
if (!visible) {
positionY.value = -(h + offset);
}
}}
>
{isDownloading ? (
<Installing />
) : downloadError ? (
<DownloadError />
) : (
<Available />
)}
</Wrapper>
</Animated.View>
</GestureDetector>
);
};
export const Updates = () => {
const { isUpdateAvailable, isUpdatePending } = useUpdates();
const [visible, setVisible] = useState(isUpdateAvailable || isUpdatePending);
useEffect(() => {
if (isUpdateAvailable || isUpdatePending) {
setVisible(true);
}
}, [isUpdateAvailable, isUpdatePending]);
useEffect(() => {
const subscription = AppState.addEventListener("change", (state) => {
if (state !== "active") {
return;
}
void checkForUpdateAsync()
.then(({ isAvailable }) => {
setVisible(isAvailable);
})
.catch(() => {
setVisible(false);
});
});
return () => {
subscription.remove();
};
}, []);
return (
<UpdatesContext.Provider value={{ visible, setVisible }}>
<Content />
</UpdatesContext.Provider>
);
};