229 lines
5.6 KiB
TypeScript
229 lines
5.6 KiB
TypeScript
import {
|
|
createContext,
|
|
FC,
|
|
PropsWithChildren,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import { Animated, Modal, TextStyle, View, ViewStyle } from "react-native"
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context"
|
|
|
|
import { Text } from "@/components/Text"
|
|
import { useAppTheme } from "@/theme/context"
|
|
import type { ThemedStyle } from "@/theme/types"
|
|
import { s } from "@/utils/responsive"
|
|
|
|
export type ToastType = "success" | "error" | "warning" | "info"
|
|
|
|
export interface ToastConfig {
|
|
message: string
|
|
type?: ToastType
|
|
duration?: number
|
|
}
|
|
|
|
export interface ToastContextType {
|
|
show: (config: ToastConfig) => void
|
|
success: (message: string, duration?: number) => void
|
|
error: (message: string, duration?: number) => void
|
|
warning: (message: string, duration?: number) => void
|
|
info: (message: string, duration?: number) => void
|
|
hide: () => void
|
|
}
|
|
|
|
// Default durations by type (in ms)
|
|
const DEFAULT_DURATIONS: Record<ToastType, number> = {
|
|
success: 3000,
|
|
error: 5000, // Errors show longer
|
|
warning: 4000,
|
|
info: 3000,
|
|
}
|
|
|
|
const ToastContext = createContext<ToastContextType | null>(null)
|
|
|
|
export const ToastProvider: FC<PropsWithChildren> = ({ children }) => {
|
|
const [visible, setVisible] = useState(false)
|
|
const [config, setConfig] = useState<ToastConfig>({ message: "" })
|
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const fadeAnim = useRef(new Animated.Value(0)).current
|
|
const insets = useSafeAreaInsets()
|
|
|
|
const {
|
|
themed,
|
|
theme: { colors },
|
|
} = useAppTheme()
|
|
|
|
// Clear timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current)
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
const hide = useCallback(() => {
|
|
// Clear any pending timeout
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current)
|
|
timeoutRef.current = null
|
|
}
|
|
// Fade out animation
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 0,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}).start(() => {
|
|
setVisible(false)
|
|
})
|
|
}, [fadeAnim])
|
|
|
|
const show = useCallback(
|
|
(newConfig: ToastConfig) => {
|
|
// Clear any existing timeout to prevent premature hide
|
|
if (timeoutRef.current) {
|
|
clearTimeout(timeoutRef.current)
|
|
timeoutRef.current = null
|
|
}
|
|
|
|
setConfig(newConfig)
|
|
setVisible(true)
|
|
|
|
// Fade in animation
|
|
fadeAnim.setValue(0)
|
|
Animated.timing(fadeAnim, {
|
|
toValue: 1,
|
|
duration: 200,
|
|
useNativeDriver: true,
|
|
}).start()
|
|
|
|
// Auto hide after duration (use type-specific default if not provided)
|
|
const type = newConfig.type ?? "info"
|
|
const duration = newConfig.duration ?? DEFAULT_DURATIONS[type]
|
|
timeoutRef.current = setTimeout(() => {
|
|
hide()
|
|
}, duration)
|
|
},
|
|
[hide, fadeAnim],
|
|
)
|
|
|
|
const success = useCallback(
|
|
(message: string, duration?: number) => {
|
|
show({ message, type: "success", duration })
|
|
},
|
|
[show],
|
|
)
|
|
|
|
const error = useCallback(
|
|
(message: string, duration?: number) => {
|
|
show({ message, type: "error", duration })
|
|
},
|
|
[show],
|
|
)
|
|
|
|
const warning = useCallback(
|
|
(message: string, duration?: number) => {
|
|
show({ message, type: "warning", duration })
|
|
},
|
|
[show],
|
|
)
|
|
|
|
const info = useCallback(
|
|
(message: string, duration?: number) => {
|
|
show({ message, type: "info", duration })
|
|
},
|
|
[show],
|
|
)
|
|
|
|
const value = useMemo(
|
|
() => ({ show, success, error, warning, info, hide }),
|
|
[show, success, error, warning, info, hide],
|
|
)
|
|
|
|
const getBackgroundColor = (type: ToastType = "info") => {
|
|
switch (type) {
|
|
case "success":
|
|
return colors.palette.primary500
|
|
case "error":
|
|
return colors.error
|
|
case "warning":
|
|
return colors.palette.accent500
|
|
case "info":
|
|
default:
|
|
return colors.palette.secondary400
|
|
}
|
|
}
|
|
|
|
const getTextColor = (type: ToastType = "info") => {
|
|
switch (type) {
|
|
case "warning":
|
|
return colors.palette.neutral800
|
|
default:
|
|
return colors.palette.neutral100
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ToastContext.Provider value={value}>
|
|
{children}
|
|
<Modal
|
|
visible={visible}
|
|
transparent
|
|
animationType="fade"
|
|
presentationStyle="overFullScreen"
|
|
statusBarTranslucent
|
|
onRequestClose={hide}
|
|
>
|
|
<Animated.View
|
|
pointerEvents="none"
|
|
style={[$overlay, { paddingTop: insets.top + 20 }, { opacity: fadeAnim }]}
|
|
>
|
|
<View
|
|
style={[themed($toastContainer), { backgroundColor: getBackgroundColor(config.type) }]}
|
|
>
|
|
<Text
|
|
text={config.message}
|
|
size="sm"
|
|
style={[themed($toastText), { color: getTextColor(config.type) }]}
|
|
/>
|
|
</View>
|
|
</Animated.View>
|
|
</Modal>
|
|
</ToastContext.Provider>
|
|
)
|
|
}
|
|
|
|
export const useToast = (): ToastContextType => {
|
|
const context = useContext(ToastContext)
|
|
if (!context) {
|
|
throw new Error("useToast must be used within a ToastProvider")
|
|
}
|
|
return context
|
|
}
|
|
|
|
const $overlay: ViewStyle = {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
}
|
|
|
|
const $toastContainer: ThemedStyle<ViewStyle> = ({ spacing }) => ({
|
|
minWidth: s(120),
|
|
maxWidth: "80%",
|
|
paddingVertical: spacing.md,
|
|
paddingHorizontal: spacing.lg,
|
|
borderRadius: s(8),
|
|
shadowColor: "#000",
|
|
shadowOffset: { width: 0, height: s(2) },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: s(4),
|
|
elevation: 5,
|
|
})
|
|
|
|
const $toastText: ThemedStyle<TextStyle> = () => ({
|
|
textAlign: "center",
|
|
})
|