refactor(native): replace custom toast implementation with burnt

Swap the hand-rolled `ToastView`/`ToastProvider`/queue system for the
`burnt` library, which delegates to native iOS/Android toast APIs.

- Add `burnt@0.13.0` dependency
- Delete `toast.tsx` and `toast-provider.tsx`; remove `<ToastProvider />`
  from `_layout.tsx`
- Rewrite `lib/toast.ts` to wrap `burnt` — maps success/error/info/warning
  to burnt presets and haptic types; duration converted from ms to seconds
This commit is contained in:
2026-03-13 19:09:48 -04:00
parent b4c2a1d343
commit e7ffceb278
6 changed files with 27 additions and 227 deletions
+1
View File
@@ -34,6 +34,7 @@
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.24",
"better-auth": "catalog:",
"burnt": "0.13.0",
"date-fns": "catalog:",
"expo": "55.0.6",
"expo-application": "55.0.9",
-2
View File
@@ -15,7 +15,6 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
import { Uniwind, useResolveClassNames } from "uniwind";
import { OfflineBanner } from "@/components/ui/offline-banner";
import { ToastProvider } from "@/components/ui/toast-provider";
import { authClient } from "@/lib/auth-client";
import { queryPersister } from "@/lib/mmkv";
import { applyTrackingTransparency, posthog } from "@/lib/posthog";
@@ -91,7 +90,6 @@ function AppContent() {
<>
<StatusBar style="light" />
<OfflineBanner />
<ToastProvider />
<Stack
screenOptions={{
headerShown: false,
@@ -1,42 +0,0 @@
import { useEffect, useSyncExternalStore } from "react";
import { ToastView } from "@/components/ui/toast";
import { dismiss, getSnapshot, subscribe } from "@/lib/toast";
import * as Haptics from "@/utils/haptics";
const hapticMap = {
success: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success),
error: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error),
warning: () =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning),
info: () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light),
} as const;
export function ToastProvider() {
const queue = useSyncExternalStore(subscribe, getSnapshot);
const activeToast = queue[0];
const activeId = activeToast?.id;
const activeType = activeToast?.type;
const activeDuration = activeToast?.duration;
useEffect(() => {
if (!activeId || !activeType || activeDuration == null) return;
hapticMap[activeType]();
const timer = setTimeout(() => {
dismiss(activeId);
}, activeDuration);
return () => clearTimeout(timer);
}, [activeId, activeType, activeDuration]);
if (!activeToast) return null;
return (
<ToastView key={activeToast.id} toast={activeToast} onDismiss={dismiss} />
);
}
-127
View File
@@ -1,127 +0,0 @@
import {
IconAlertOctagon,
IconAlertTriangle,
IconCircleCheck,
IconInfoCircle,
} from "@tabler/icons-react-native";
import { Pressable, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import Animated, {
interpolate,
SlideInDown,
SlideOutDown,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { scheduleOnRN } from "react-native-worklets";
import { useCSSVariable } from "uniwind";
import { Text } from "@/components/ui/text";
import type { ToastItem } from "@/lib/toast";
const DISMISS_THRESHOLD = 50;
const iconMap = {
success: IconCircleCheck,
error: IconAlertOctagon,
warning: IconAlertTriangle,
info: IconInfoCircle,
} as const;
const colorVarMap = {
success: "--color-status-completed",
error: "--color-destructive",
warning: "--color-primary",
info: "--color-muted-foreground",
} as const;
interface ToastViewProps {
toast: ToastItem;
onDismiss: (id: string) => void;
}
export function ToastView({ toast, onDismiss }: ToastViewProps) {
const insets = useSafeAreaInsets();
const translateY = useSharedValue(0);
const Icon = iconMap[toast.type];
const iconColor = useCSSVariable(colorVarMap[toast.type]);
const handleDismiss = () => {
onDismiss(toast.id);
};
const pan = Gesture.Pan()
.onUpdate((e) => {
translateY.value = e.translationY;
})
.onEnd((e) => {
if (Math.abs(e.translationY) > DISMISS_THRESHOLD) {
const target = e.translationY > 0 ? 200 : -200;
translateY.value = withTiming(target, { duration: 200 }, () => {
scheduleOnRN(handleDismiss);
});
} else {
translateY.value = withSpring(0, { damping: 15, stiffness: 300 });
}
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
opacity: interpolate(Math.abs(translateY.value), [0, 100], [1, 0]),
}));
return (
<Animated.View
entering={SlideInDown.duration(350)}
exiting={SlideOutDown.duration(200)}
style={{
position: "absolute",
bottom: insets.bottom + 4,
left: 0,
right: 0,
zIndex: 200,
}}
>
<GestureDetector gesture={pan}>
<Animated.View style={animatedStyle}>
<View
className="mx-4 flex-row items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
style={{ borderCurve: "continuous" }}
>
<Icon size={18} color={iconColor as string} />
<View className="flex-1 gap-0.5">
<Text className="font-sans-medium text-[14px] text-foreground">
{toast.message}
</Text>
{toast.description ? (
<Text className="text-[13px] text-muted-foreground">
{toast.description}
</Text>
) : null}
</View>
{toast.action ? (
<Pressable
onPress={() => {
toast.action?.onPress();
handleDismiss();
}}
className="rounded-lg bg-secondary px-3 py-1.5"
style={{ borderCurve: "continuous" }}
>
<Text className="font-sans-medium text-[13px] text-primary">
{toast.action.label}
</Text>
</Pressable>
) : null}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
);
}
+21 -56
View File
@@ -1,68 +1,33 @@
type ToastType = "success" | "error" | "info" | "warning";
import { toast as burntToast } from "burnt";
interface ToastAction {
label: string;
onPress: () => void;
}
interface ToastOptions {
type ToastOptions = {
description?: string;
duration?: number;
action?: ToastAction;
}
};
export interface ToastItem {
id: string;
type: ToastType;
message: string;
description?: string;
duration: number;
action?: ToastAction;
}
let queue: ToastItem[] = [];
const listeners = new Set<() => void>();
function emit() {
for (const listener of listeners) listener();
}
export function getSnapshot(): ToastItem[] {
return queue;
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function addToast(type: ToastType, message: string, options?: ToastOptions) {
const item: ToastItem = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
type,
message,
description: options?.description,
duration: options?.duration ?? 4000,
action: options?.action,
};
queue = [...queue, item];
emit();
}
export function dismiss(id: string) {
queue = queue.filter((t) => t.id !== id);
emit();
function show(
preset: "done" | "error" | "none",
haptic: "success" | "warning" | "error" | "none",
message: string,
options?: ToastOptions,
) {
burntToast({
title: message,
message: options?.description,
preset,
haptic,
duration: options?.duration ? options.duration / 1000 : 4,
from: "bottom",
});
}
export const toast = {
success: (message: string, options?: ToastOptions) =>
addToast("success", message, options),
show("done", "success", message, options),
error: (message: string, options?: ToastOptions) =>
addToast("error", message, options),
show("error", "error", message, options),
info: (message: string, options?: ToastOptions) =>
addToast("info", message, options),
show("none", "none", message, options),
warning: (message: string, options?: ToastOptions) =>
addToast("warning", message, options),
show("none", "warning", message, options),
};
+5
View File
@@ -34,6 +34,7 @@
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-persist-client": "5.90.24",
"better-auth": "catalog:",
"burnt": "0.13.0",
"date-fns": "catalog:",
"expo": "55.0.6",
"expo-application": "55.0.9",
@@ -1332,6 +1333,8 @@
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
"burnt": ["burnt@0.13.0", "", { "dependencies": { "sf-symbols-typescript": "^1.0.0", "sonner": "^2.0.1" }, "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-LjlQa7CLkGWUdz08YUIaGCJ8BLXib31/ztKqowgwqd7UH283A/kmdCj+1PYAQwDQEMPNmvSUfFHrjXbcwZibFQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -2856,6 +2859,8 @@
"bl/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"burnt/sf-symbols-typescript": ["sf-symbols-typescript@1.0.0", "", {}, "sha512-DkS7q3nN68dEMb4E18HFPDAvyrjDZK9YAQQF2QxeFu9gp2xRDXFMF8qLJ1EmQ/qeEGQmop4lmMM1WtYJTIcCMw=="],
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"chromium-edge-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],