frontend: introduce toast components/context
Parents:
f74f10c4 file(s) changed
- frontend/app/_layout.tsx +3 -0
- frontend/components/ui/Toast.tsx +92 -0
- frontend/context/AuthContext.tsx +7 -18
- frontend/context/ToastContext.tsx +36 -0
frontend/app/_layout.tsx
1 1
import {
2 2
DarkTheme,
3 3
DefaultTheme,
4 4
ThemeProvider,
5 5
} from "@react-navigation/native";
▸ 1 unchanged lines
7 7
import { useColorScheme } from "react-native";
8 8
import { QueryClientProvider } from "@tanstack/react-query";
9 9
import { queryClient } from "@/lib/queryClient";
10 10
import { useNotificationObserver } from "@/hooks/useNotificationObserver";
11 11
import { AuthProvider } from "@/context/AuthContext";
12 +
import { ToastProvider } from "@/context/ToastContext";
12 13
13 14
export const unstable_settings = {
14 15
initialRouteName: "(tabs)",
15 16
};
16 17
▸ 6 unchanged lines
23 24
const colorScheme = useColorScheme();
24 25
25 26
return (
26 27
<QueryClientProvider client={queryClient}>
27 28
<ThemeProvider value={DefaultTheme}>
29 +
<ToastProvider>
28 30
<AuthProvider>
29 31
<AppBootstrap />
30 32
<Stack>
31 33
<Stack.Screen name="index" options={{ headerShown: false }} />
32 34
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
▸ 84 unchanged lines
117 119
name="modal"
118 120
options={{ presentation: "modal", title: "Modal" }}
119 121
/>
120 122
</Stack>
121 123
</AuthProvider>
124 +
</ToastProvider>
122 125
</ThemeProvider>
123 126
</QueryClientProvider>
124 127
);
125 128
}
frontend/components/ui/Toast.tsx
@@ -0,0 +1,92 @@
1 + import React, { useEffect, useRef } from 'react';
2 + import { Animated, StyleSheet, Text } from 'react-native';
3 + import { useSafeAreaInsets } from 'react-native-safe-area-context';
4 +
5 + export type ToastVariant = 'error' | 'success' | 'warning';
6 +
7 + interface ToastProps {
8 + message: string;
9 + variant: ToastVariant;
10 + visible: boolean;
11 + }
12 +
13 + const VARIANT_COLORS: Record<ToastVariant, string> = {
14 + error: '#EF4444',
15 + success: '#22c55e',
16 + warning: '#F97316',
17 + };
18 +
19 + export function Toast({ message, variant, visible }: ToastProps) {
20 + const insets = useSafeAreaInsets();
21 + const translateY = useRef(new Animated.Value(-120)).current;
22 + const opacity = useRef(new Animated.Value(0)).current;
23 +
24 + useEffect(() => {
25 + if (visible) {
26 + Animated.parallel([
27 + Animated.spring(translateY, {
28 + toValue: 0,
29 + useNativeDriver: true,
30 + tension: 80,
31 + friction: 10,
32 + }),
33 + Animated.timing(opacity, {
34 + toValue: 1,
35 + duration: 200,
36 + useNativeDriver: true,
37 + }),
38 + ]).start();
39 + } else {
40 + Animated.parallel([
41 + Animated.timing(translateY, {
42 + toValue: -120,
43 + duration: 250,
44 + useNativeDriver: true,
45 + }),
46 + Animated.timing(opacity, {
47 + toValue: 0,
48 + duration: 200,
49 + useNativeDriver: true,
50 + }),
51 + ]).start();
52 + }
53 + }, [visible]);
54 +
55 + return (
56 + <Animated.View
57 + style={[
58 + styles.container,
59 + { top: insets.top + 12, backgroundColor: VARIANT_COLORS[variant] },
60 + { transform: [{ translateY }], opacity },
61 + ]}
62 + pointerEvents="none"
63 + >
64 + <Text style={styles.message} numberOfLines={3}>
65 + {message}
66 + </Text>
67 + </Animated.View>
68 + );
69 + }
70 +
71 + const styles = StyleSheet.create({
72 + container: {
73 + position: 'absolute',
74 + left: 16,
75 + right: 16,
76 + zIndex: 9999,
77 + borderRadius: 10,
78 + paddingHorizontal: 16,
79 + paddingVertical: 12,
80 + shadowColor: '#000',
81 + shadowOffset: { width: 0, height: 4 },
82 + shadowOpacity: 0.15,
83 + shadowRadius: 8,
84 + elevation: 8,
85 + },
86 + message: {
87 + color: '#fff',
88 + fontSize: 14,
89 + fontWeight: '500',
90 + lineHeight: 20,
91 + },
92 + });
frontend/context/AuthContext.tsx
1 1
import React, { createContext, useContext, useEffect, useState } from "react";
2 2
import { apiClient, User } from "@/lib/api";
3 3
import { queryClient, storage } from "@/lib/queryClient";
4 4
5 5
interface AuthProviderProps {
▸ 88 unchanged lines
94 94
95 95
const login = async (
96 96
email: string,
97 97
password: string,
98 98
): Promise<{ user: User } | null> => {
99 -
setIsLoading(true);
99 +
const response = await apiClient.login(email, password);
100 100
101 -
try {
102 -
const response = await apiClient.login(email, password);
101 +
if (response.user) {
102 +
setUser(response.user);
103 +
}
103 104
104 -
if (response.user) {
105 -
setUser(response.user);
106 -
}
107 -
108 -
return response;
109 -
} finally {
110 -
setIsLoading(false);
111 -
}
105 +
return response;
112 106
};
113 107
114 108
const signup = async (userData: {
115 109
firstName: string;
116 110
lastName?: string;
117 111
email: string;
118 112
password: string;
119 113
}) => {
120 -
setIsLoading(true);
121 -
try {
122 -
const response = await apiClient.signup(userData);
123 -
setUser(response.user);
124 -
} finally {
125 -
setIsLoading(false);
126 -
}
114 +
const response = await apiClient.signup(userData);
115 +
setUser(response.user);
127 116
};
128 117
129 118
const logout = async () => {
130 119
setIsLoading(true);
131 120
try {
▸ 43 unchanged lines
175 164
requestActivateCode,
176 165
};
177 166
178 167
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
179 168
};
frontend/context/ToastContext.tsx
@@ -0,0 +1,36 @@
1 + import React, { createContext, useCallback, useContext, useRef, useState } from 'react';
2 + import { Toast, ToastVariant } from '@/components/ui/Toast';
3 +
4 + interface ToastContextValue {
5 + showToast: (message: string, variant?: ToastVariant) => void;
6 + }
7 +
8 + const ToastContext = createContext<ToastContextValue>({
9 + showToast: () => {},
10 + });
11 +
12 + export function useToast() {
13 + return useContext(ToastContext);
14 + }
15 +
16 + export function ToastProvider({ children }: { children: React.ReactNode }) {
17 + const [message, setMessage] = useState('');
18 + const [variant, setVariant] = useState<ToastVariant>('error');
19 + const [visible, setVisible] = useState(false);
20 + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
21 +
22 + const showToast = useCallback((msg: string, v: ToastVariant = 'error') => {
23 + if (timerRef.current) clearTimeout(timerRef.current);
24 + setMessage(msg);
25 + setVariant(v);
26 + setVisible(true);
27 + timerRef.current = setTimeout(() => setVisible(false), 3500);
28 + }, []);
29 +
30 + return (
31 + <ToastContext.Provider value={{ showToast }}>
32 + {children}
33 + <Toast message={message} variant={variant} visible={visible} />
34 + </ToastContext.Provider>
35 + );
36 + }