diff --git a/__mocks__/react-native-gesture-handler.ts b/__mocks__/react-native-gesture-handler.ts
new file mode 100644
index 0000000..a101230
--- /dev/null
+++ b/__mocks__/react-native-gesture-handler.ts
@@ -0,0 +1,29 @@
+import React from 'react';
+import { View } from 'react-native';
+
+const makePanGesture = () => {
+ const gesture = {
+ onUpdate: () => gesture,
+ onEnd: () => gesture,
+ onStart: () => gesture,
+ onBegin: () => gesture,
+ onFinalize: () => gesture,
+ enabled: () => gesture,
+ minDistance: () => gesture,
+ activeOffsetX: () => gesture,
+ activeOffsetY: () => gesture,
+ };
+ return gesture;
+};
+
+const Gesture = {
+ Pan: makePanGesture,
+};
+
+const GestureDetector = ({ children }: { children: React.ReactNode }) =>
+ React.createElement(View, null, children);
+
+module.exports = {
+ Gesture,
+ GestureDetector,
+};
diff --git a/__mocks__/react-native-reanimated.ts b/__mocks__/react-native-reanimated.ts
index 5c916ae..d8ff5f3 100644
--- a/__mocks__/react-native-reanimated.ts
+++ b/__mocks__/react-native-reanimated.ts
@@ -5,6 +5,8 @@ const useSharedValue = (init: unknown) => ({ value: init });
const useAnimatedStyle = (fn: () => object) => fn();
const withTiming = (value: unknown) => value;
const withSpring = (value: unknown) => value;
+const interpolate = (_value: unknown, _input: unknown, output: number[]) =>
+ output[0];
const interpolateColor = (_value: unknown, _range: unknown, colors: string[]) =>
colors[0];
const runOnJS = (fn: (...args: unknown[]) => unknown) => fn;
@@ -42,6 +44,7 @@ module.exports = {
useAnimatedGestureHandler,
withTiming,
withSpring,
+ interpolate,
interpolateColor,
runOnJS,
runOnUI,
diff --git a/example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx
new file mode 100644
index 0000000..b15f105
--- /dev/null
+++ b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.stories.tsx
@@ -0,0 +1,99 @@
+import type { Meta, StoryObj } from '@storybook/react-native';
+import React, { useState } from 'react';
+import { Text, TouchableOpacity, View } from 'react-native';
+import { Toaster, ToasterApi } from 'rubber-duck-ui';
+import { styles } from './Toaster.styles';
+
+const Trigger = ({ label, onPress }: { label: string; onPress: () => void }) => (
+
+ {label}
+
+);
+
+const DefaultDemo = () => (
+
+
+ ToasterApi.show({
+ type: 'success',
+ title: 'Success!',
+ description: 'Your action was completed.',
+ })
+ }
+ />
+
+ ToasterApi.show({
+ type: 'error',
+ title: 'Error!',
+ description: 'Something went wrong.',
+ })
+ }
+ />
+
+ ToasterApi.show({
+ type: 'warning',
+ title: 'Warning!',
+ description: 'Please check your input.',
+ })
+ }
+ />
+
+ ToasterApi.show({
+ type: 'info',
+ title: 'Info',
+ description: 'Here is some information.',
+ })
+ }
+ />
+
+
+);
+
+type ToasterType = 'success' | 'error' | 'warning' | 'info';
+
+const TYPES: ToasterType[] = ['success', 'error', 'warning', 'info'];
+
+const InteractiveDemo = () => {
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const showNext = () => {
+ const type = TYPES[currentIndex % TYPES.length];
+ ToasterApi.show({
+ type,
+ title: `${type.charAt(0).toUpperCase()}${type.slice(1)} toast`,
+ description: `This is a ${type} notification.`,
+ });
+ setCurrentIndex((prev) => (prev + 1) % TYPES.length);
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+const meta = {
+ title: 'Organisms/Toaster',
+ component: View,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => ,
+};
+
+export const Interactive: Story = {
+ render: () => ,
+};
diff --git a/example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts
new file mode 100644
index 0000000..4327ee5
--- /dev/null
+++ b/example/.rnstorybook/stories/Organisms/Toaster/Toaster.styles.ts
@@ -0,0 +1,24 @@
+import { StyleSheet } from 'react-native';
+
+export const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 12,
+ padding: 24,
+ },
+ trigger: {
+ paddingHorizontal: 20,
+ paddingVertical: 12,
+ borderRadius: 8,
+ backgroundColor: '#FFD600',
+ alignItems: 'center',
+ width: '100%',
+ },
+ triggerText: {
+ color: '#000',
+ fontWeight: '600',
+ fontSize: 14,
+ },
+});
diff --git a/package.json b/package.json
index be0e3ec..217afe4 100644
--- a/package.json
+++ b/package.json
@@ -110,7 +110,8 @@
"node_modules/(?!(react-native|@react-native|lucide-react-native)/)"
],
"moduleNameMapper": {
- "^react-native-reanimated$": "/__mocks__/react-native-reanimated.ts"
+ "^react-native-reanimated$": "/__mocks__/react-native-reanimated.ts",
+ "^react-native-gesture-handler$": "/__mocks__/react-native-gesture-handler.ts"
},
"modulePathIgnorePatterns": [
"/example/node_modules",
diff --git a/src/components/Molecules/Button/index.tsx b/src/components/Molecules/Button/index.tsx
index 0dd861c..ecb4463 100644
--- a/src/components/Molecules/Button/index.tsx
+++ b/src/components/Molecules/Button/index.tsx
@@ -23,7 +23,7 @@ export const Button: React.FC = (props) => {
return (
diff --git a/src/components/Molecules/RadioButton/index.tsx b/src/components/Molecules/RadioButton/index.tsx
index 8e474db..ba9e4de 100644
--- a/src/components/Molecules/RadioButton/index.tsx
+++ b/src/components/Molecules/RadioButton/index.tsx
@@ -36,7 +36,7 @@ export const RadioButton: React.FC = (props) => {
+ activeOpacity={0.8}>
diff --git a/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx b/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx
index 26978ac..19ea35e 100644
--- a/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx
+++ b/src/components/Organisms/BottomListModal/components/ModalList/library/mountRenderItem.tsx
@@ -55,7 +55,7 @@ export const mountRenderItem = (params: IMountRenderItemParams): ListRenderItem<
case 'avatar':
return (
-
+
{item.title}
@@ -67,7 +67,7 @@ export const mountRenderItem = (params: IMountRenderItemParams): ListRenderItem<
case 'icon':
if (!item.iconName) { return null; }
return (
-
+
{
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ ToasterApi.setRef({ current: null });
+ });
+
+ it('Renders without crashing', () => {
+ const { toJSON } = render();
+ expect(toJSON()).toBeTruthy();
+ });
+
+ it('Shows title after ToasterApi.show is called', () => {
+ render();
+
+ act(() => {
+ ToasterApi.show({ type: 'success', title: 'Hello World' });
+ });
+
+ expect(screen.getByText('Hello World')).toBeTruthy();
+ });
+
+ it('Shows both title and description when description is provided', () => {
+ render();
+
+ act(() => {
+ ToasterApi.show({
+ type: 'info',
+ title: 'Info Title',
+ description: 'Info Description',
+ });
+ });
+
+ expect(screen.getByText('Info Title')).toBeTruthy();
+ expect(screen.getByText('Info Description')).toBeTruthy();
+ });
+
+ it('Replaces content when show is called twice', () => {
+ render();
+
+ act(() => {
+ ToasterApi.show({ type: 'success', title: 'First' });
+ });
+
+ act(() => {
+ ToasterApi.show({ type: 'error', title: 'Second' });
+ jest.advanceTimersByTime(100);
+ });
+
+ expect(screen.queryByText('First')).toBeNull();
+ expect(screen.getByText('Second')).toBeTruthy();
+ });
+
+ it('ToasterApi.hide does not throw', () => {
+ render();
+
+ expect(() => {
+ act(() => {
+ ToasterApi.hide();
+ });
+ }).not.toThrow();
+ });
+});
diff --git a/src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts b/src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts
new file mode 100644
index 0000000..06cbaa9
--- /dev/null
+++ b/src/components/Organisms/Toaster/constants/DEFAULT_TOAST_DURATION_MS.ts
@@ -0,0 +1 @@
+export const DEFAULT_TOAST_DURATION_MS = 3000;
diff --git a/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts
new file mode 100644
index 0000000..ce1dad9
--- /dev/null
+++ b/src/components/Organisms/Toaster/constants/TOAST_DRAG_DISMISS_THRESHOLD.ts
@@ -0,0 +1 @@
+export const TOAST_DRAG_DISMISS_THRESHOLD = 40;
diff --git a/src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts b/src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts
new file mode 100644
index 0000000..044fd8c
--- /dev/null
+++ b/src/components/Organisms/Toaster/constants/TOAST_SLIDE_OFFSET.ts
@@ -0,0 +1,3 @@
+import { Tokens } from '../../../../tokens/Tokens.class';
+
+export const TOAST_SLIDE_OFFSET = Tokens.spacer({ key: 'xl' });
diff --git a/src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts b/src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts
new file mode 100644
index 0000000..5b3a1b2
--- /dev/null
+++ b/src/components/Organisms/Toaster/constants/TOAST_SLINGSHOT_MAX_OFFSET.ts
@@ -0,0 +1,3 @@
+import { Tokens } from '../../../../tokens/Tokens.class';
+
+export const TOAST_SLINGSHOT_MAX_OFFSET = Tokens.spacer({ key: 'lg' });
diff --git a/src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts b/src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts
new file mode 100644
index 0000000..0af9be8
--- /dev/null
+++ b/src/components/Organisms/Toaster/constants/TYPE_ICON_MAP.ts
@@ -0,0 +1,9 @@
+import type { ToasterType } from '../types';
+import type { IconName } from '../../../Atoms/Icon/types/IconName';
+
+export const TYPE_ICON_MAP: Record = {
+ success: 'CircleCheck',
+ error: 'CircleX',
+ warning: 'TriangleAlert',
+ info: 'CircleAlert',
+};
diff --git a/src/components/Organisms/Toaster/constants/index.ts b/src/components/Organisms/Toaster/constants/index.ts
new file mode 100644
index 0000000..26ae8df
--- /dev/null
+++ b/src/components/Organisms/Toaster/constants/index.ts
@@ -0,0 +1,5 @@
+export * from './DEFAULT_TOAST_DURATION_MS';
+export * from './TOAST_DRAG_DISMISS_THRESHOLD';
+export * from './TOAST_SLINGSHOT_MAX_OFFSET';
+export * from './TOAST_SLIDE_OFFSET';
+export * from './TYPE_ICON_MAP';
diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts
new file mode 100644
index 0000000..6104649
--- /dev/null
+++ b/src/components/Organisms/Toaster/controllers/ToasterApi/__tests__/ToasterApi.test.ts
@@ -0,0 +1,76 @@
+import { ToasterApi } from '../index';
+
+const makeRef = (overrides: Partial<{ show: jest.Mock; hide: jest.Mock }> = {}) => ({
+ current: {
+ show: jest.fn(),
+ hide: jest.fn(),
+ ...overrides,
+ },
+});
+
+describe('ToasterApi', () => {
+ afterEach(() => {
+ ToasterApi.setRef({ current: null });
+ jest.clearAllMocks();
+ });
+
+ describe('show', () => {
+ it('Does not throw when no ref has been set', () => {
+ expect(() =>
+ ToasterApi.show({ type: 'success', title: 'Test' }),
+ ).not.toThrow();
+ });
+
+ it('Does not throw when ref.current is null', () => {
+ ToasterApi.setRef({ current: null });
+ expect(() =>
+ ToasterApi.show({ type: 'success', title: 'Test' }),
+ ).not.toThrow();
+ });
+
+ it('Calls ref.current.show with the provided props', () => {
+ const ref = makeRef();
+ ToasterApi.setRef(ref);
+
+ const props = { type: 'success' as const, title: 'Hello' };
+ ToasterApi.show(props);
+
+ expect(ref.current.show).toHaveBeenCalledTimes(1);
+ expect(ref.current.show).toHaveBeenCalledWith(props);
+ });
+ });
+
+ describe('hide', () => {
+ it('Does not throw when no ref has been set', () => {
+ expect(() => ToasterApi.hide()).not.toThrow();
+ });
+
+ it('Does not throw when ref.current is null', () => {
+ ToasterApi.setRef({ current: null });
+ expect(() => ToasterApi.hide()).not.toThrow();
+ });
+
+ it('Calls ref.current.hide', () => {
+ const ref = makeRef();
+ ToasterApi.setRef(ref);
+
+ ToasterApi.hide();
+
+ expect(ref.current.hide).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('setRef', () => {
+ it('Replaces the previous ref', () => {
+ const first = makeRef();
+ const second = makeRef();
+
+ ToasterApi.setRef(first);
+ ToasterApi.setRef(second);
+ ToasterApi.hide();
+
+ expect(first.current.hide).not.toHaveBeenCalled();
+ expect(second.current.hide).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/index.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/index.ts
new file mode 100644
index 0000000..2e7644b
--- /dev/null
+++ b/src/components/Organisms/Toaster/controllers/ToasterApi/index.ts
@@ -0,0 +1,18 @@
+import type { RefObject } from 'react';
+import type { IToasterApi } from './types';
+import type { IToasterRefProps } from '../../types';
+
+export class ToasterApi {
+ private static ref: RefObject | null = null;
+
+ static setRef(ref: RefObject) {
+ this.ref = ref;
+ }
+
+ static show = (props: IToasterRefProps) =>
+ this.ref?.current?.show(props);
+
+ static hide = () => {
+ this.ref?.current?.hide();
+ };
+}
diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts
new file mode 100644
index 0000000..424c35e
--- /dev/null
+++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types/IToasterApi.ts
@@ -0,0 +1,6 @@
+import type { IToasterRefProps } from '../../../types';
+
+export interface IToasterApi {
+ show: (props: IToasterRefProps) => void;
+ hide: () => void;
+}
diff --git a/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts
new file mode 100644
index 0000000..aa3236a
--- /dev/null
+++ b/src/components/Organisms/Toaster/controllers/ToasterApi/types/index.ts
@@ -0,0 +1 @@
+export * from './IToasterApi';
diff --git a/src/components/Organisms/Toaster/controllers/index.ts b/src/components/Organisms/Toaster/controllers/index.ts
new file mode 100644
index 0000000..1efc333
--- /dev/null
+++ b/src/components/Organisms/Toaster/controllers/index.ts
@@ -0,0 +1,2 @@
+export * from './ToasterApi';
+export * from './ToasterApi/types';
diff --git a/src/components/Organisms/Toaster/hooks/index.ts b/src/components/Organisms/Toaster/hooks/index.ts
new file mode 100644
index 0000000..8a50dc4
--- /dev/null
+++ b/src/components/Organisms/Toaster/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from './useToasterViewModel';
+export * from './useReanimatedStyles';
diff --git a/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts
new file mode 100644
index 0000000..7f5eed8
--- /dev/null
+++ b/src/components/Organisms/Toaster/hooks/useReanimatedStyles/index.ts
@@ -0,0 +1,57 @@
+import { useEffect } from 'react';
+import { Gesture } from 'react-native-gesture-handler';
+import {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+ withTiming,
+} from 'react-native-reanimated';
+import {
+ TOAST_DRAG_DISMISS_THRESHOLD,
+ TOAST_SLINGSHOT_MAX_OFFSET,
+ TOAST_SLIDE_OFFSET,
+} from '../../constants';
+
+export const useReanimatedStyles = (isVisible: boolean, onDismiss: () => void) => {
+ const opacity = useSharedValue(0);
+ const translateY = useSharedValue(TOAST_SLIDE_OFFSET);
+ const dragY = useSharedValue(0);
+
+ useEffect(() => {
+ if (isVisible) {
+ dragY.value = 0;
+ opacity.value = withTiming(1, { duration: 200 });
+ translateY.value = withSpring(0, { damping: 18, stiffness: 180 });
+ } else {
+ opacity.value = withTiming(0, { duration: 200 });
+ translateY.value = withTiming(TOAST_SLIDE_OFFSET, { duration: 200 });
+ }
+ }, [isVisible, opacity, translateY, dragY]);
+
+ const gesture = Gesture.Pan()
+ .onUpdate((e) => {
+ if (e.translationY < 0) {
+ dragY.value = Math.max(e.translationY, -TOAST_SLINGSHOT_MAX_OFFSET);
+ } else {
+ dragY.value = e.translationY;
+ }
+ })
+ .onEnd((e) => {
+ if (e.translationY > TOAST_DRAG_DISMISS_THRESHOLD) {
+ runOnJS(onDismiss)();
+ } else {
+ dragY.value = withSpring(0);
+ }
+ });
+
+ const wrapper = useAnimatedStyle(() => ({
+ opacity: opacity.value,
+ transform: [{ translateY: translateY.value + dragY.value }],
+ }), []);
+
+ return {
+ wrapper,
+ gesture,
+ };
+};
diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts
new file mode 100644
index 0000000..77e4bc8
--- /dev/null
+++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/__tests__/useToasterViewModel.test.ts
@@ -0,0 +1,127 @@
+import { renderHook, act } from '@testing-library/react-native';
+import { useToasterViewModel } from '../index';
+import { ToasterApi } from '../../../controllers';
+
+jest.spyOn(ToasterApi, 'setRef');
+
+describe('useToasterViewModel', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ ToasterApi.setRef({ current: null });
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('Initial state', () => {
+ it('toasterProps starts as undefined', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+ expect(result.current.toasterProps).toBeUndefined();
+ });
+
+ it('isVisible starts as false', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+ expect(result.current.isVisible).toBe(false);
+ });
+
+ it('Registers with ToasterApi on mount', () => {
+ renderHook(() => useToasterViewModel());
+ expect(ToasterApi.setRef).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('show', () => {
+ it('Sets toasterProps and isVisible to true', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+
+ act(() => {
+ ToasterApi.show({ type: 'success', title: 'Hello' });
+ });
+
+ expect(result.current.toasterProps).toEqual({ type: 'success', title: 'Hello' });
+ expect(result.current.isVisible).toBe(true);
+ });
+
+ it('Auto-dismisses after default 3000ms', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+
+ act(() => {
+ ToasterApi.show({ type: 'info', title: 'Auto-dismiss' });
+ });
+
+ expect(result.current.isVisible).toBe(true);
+
+ act(() => {
+ jest.advanceTimersByTime(3000);
+ });
+
+ expect(result.current.isVisible).toBe(false);
+ });
+
+ it('Honors custom duration', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+
+ act(() => {
+ ToasterApi.show({ type: 'warning', title: 'Custom', duration: 1000 });
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(999);
+ });
+
+ expect(result.current.isVisible).toBe(true);
+
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+
+ expect(result.current.isVisible).toBe(false);
+ });
+
+ it('Calling show twice resets the timer', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+
+ act(() => {
+ ToasterApi.show({ type: 'success', title: 'First' });
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ act(() => {
+ ToasterApi.show({ type: 'error', title: 'Second' });
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ expect(result.current.isVisible).toBe(true);
+
+ act(() => {
+ jest.advanceTimersByTime(1000);
+ });
+
+ expect(result.current.isVisible).toBe(false);
+ });
+ });
+
+ describe('hide', () => {
+ it('Sets isVisible to false and clears timer', () => {
+ const { result } = renderHook(() => useToasterViewModel());
+
+ act(() => {
+ ToasterApi.show({ type: 'success', title: 'Hello' });
+ });
+
+ act(() => {
+ ToasterApi.hide();
+ });
+
+ expect(result.current.isVisible).toBe(false);
+ });
+ });
+});
diff --git a/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts
new file mode 100644
index 0000000..a19add1
--- /dev/null
+++ b/src/components/Organisms/Toaster/hooks/useToasterViewModel/index.ts
@@ -0,0 +1,48 @@
+import type { IToasterApi } from '../../controllers/ToasterApi/types';
+import type { IToasterRefProps } from '../../types';
+import { useState, useRef, useEffect } from 'react';
+import { ToasterApi } from '../../controllers';
+import { DEFAULT_TOAST_DURATION_MS } from '../../constants';
+
+export const useToasterViewModel = () => {
+ const [toasterProps, setToasterPropsState] = useState();
+ const [isVisible, setIsVisible] = useState(false);
+
+ const timeoutRef = useRef | null>(null);
+ const imperativeRef = useRef(null);
+
+ const clearTimer = () => {
+ if (timeoutRef.current !== null) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ };
+
+ const show = (props: IToasterRefProps) => {
+ clearTimer();
+ setToasterPropsState(props);
+ setIsVisible(true);
+ timeoutRef.current = setTimeout(() => setIsVisible(false), props.duration ?? DEFAULT_TOAST_DURATION_MS);
+ };
+
+ const hide = () => {
+ clearTimer();
+ setIsVisible(false);
+ };
+
+ imperativeRef.current = { show, hide };
+
+ useEffect(() => {
+ ToasterApi.setRef(imperativeRef);
+ return () => {
+ clearTimer();
+ ToasterApi.setRef({ current: null });
+ };
+ }, []);
+
+ return {
+ toasterProps,
+ isVisible,
+ hide,
+ };
+};
diff --git a/src/components/Organisms/Toaster/index.tsx b/src/components/Organisms/Toaster/index.tsx
new file mode 100644
index 0000000..bb4b599
--- /dev/null
+++ b/src/components/Organisms/Toaster/index.tsx
@@ -0,0 +1,60 @@
+import type { IToasterProps } from './types';
+import React from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import Animated from 'react-native-reanimated';
+import { GestureDetector } from 'react-native-gesture-handler';
+import { useStyles } from './styles';
+import { useToasterViewModel, useReanimatedStyles } from './hooks';
+import { TYPE_ICON_MAP } from './constants';
+import { Text } from '../../Atoms/Text';
+import { Icon } from '../../Atoms/Icon';
+
+export const Toaster: React.FC = () => {
+ const { toasterProps, isVisible, hide } = useToasterViewModel();
+ const styles = useStyles(toasterProps?.type);
+ const { wrapper, gesture } = useReanimatedStyles(isVisible, hide);
+
+ return (
+
+
+ {toasterProps
+ ?
+ <>
+
+
+
+ {toasterProps.title}
+
+ {toasterProps.description
+ ?
+
+ {toasterProps.description}
+
+ : null
+ }
+
+
+
+
+
+ >
+ : null
+ }
+
+
+ );
+};
+
+export type { IToasterApi } from './controllers/ToasterApi/types';
+export type { IToasterRefProps, ToasterType } from './types';
+export { ToasterApi } from './controllers';
diff --git a/src/components/Organisms/Toaster/styles.ts b/src/components/Organisms/Toaster/styles.ts
new file mode 100644
index 0000000..f42dd0b
--- /dev/null
+++ b/src/components/Organisms/Toaster/styles.ts
@@ -0,0 +1,40 @@
+import type { ToasterType } from './types';
+import { StyleSheet } from 'react-native';
+import { useRubberDuckStore } from '../../../store';
+import { Tokens } from '../../../tokens/Tokens.class';
+
+export const useStyles = (type: ToasterType | undefined) => {
+ const colors = useRubberDuckStore((s) => s.colors);
+
+ const backgroundColorMap: Record = {
+ success: colors.success,
+ error: colors.error,
+ warning: colors.warning,
+ info: colors.info,
+ };
+
+ const backgroundColor = type ? backgroundColorMap[type] : colors.info;
+
+ return StyleSheet.create({
+ wrapper: {
+ position: 'absolute',
+ bottom: Tokens.spacer({ key: 'lg' }),
+ left: Tokens.spacer({ key: 'md' }),
+ right: Tokens.spacer({ key: 'md' }),
+ padding: Tokens.spacer({ key: 'md' }),
+ borderRadius: Tokens.radii({ key: 'lg' }),
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: Tokens.spacer({ key: 'sm' }),
+ backgroundColor,
+ },
+
+ textWrapper: {
+ flex: 1,
+ },
+
+ closeButton: {
+ padding: Tokens.spacer({ key: 'xxs' }),
+ },
+ });
+};
diff --git a/src/components/Organisms/Toaster/types/IToasterProps.ts b/src/components/Organisms/Toaster/types/IToasterProps.ts
new file mode 100644
index 0000000..b201b01
--- /dev/null
+++ b/src/components/Organisms/Toaster/types/IToasterProps.ts
@@ -0,0 +1 @@
+export interface IToasterProps {}
diff --git a/src/components/Organisms/Toaster/types/IToasterRefProps.ts b/src/components/Organisms/Toaster/types/IToasterRefProps.ts
new file mode 100644
index 0000000..85f5bed
--- /dev/null
+++ b/src/components/Organisms/Toaster/types/IToasterRefProps.ts
@@ -0,0 +1,8 @@
+import type { ToasterType } from './ToasterType';
+
+export interface IToasterRefProps {
+ type: ToasterType;
+ title: string;
+ description?: string;
+ duration?: number;
+}
diff --git a/src/components/Organisms/Toaster/types/ToasterType.ts b/src/components/Organisms/Toaster/types/ToasterType.ts
new file mode 100644
index 0000000..1cfa1a4
--- /dev/null
+++ b/src/components/Organisms/Toaster/types/ToasterType.ts
@@ -0,0 +1 @@
+export type ToasterType = 'success' | 'error' | 'warning' | 'info';
diff --git a/src/components/Organisms/Toaster/types/index.ts b/src/components/Organisms/Toaster/types/index.ts
new file mode 100644
index 0000000..5661fb8
--- /dev/null
+++ b/src/components/Organisms/Toaster/types/index.ts
@@ -0,0 +1,3 @@
+export * from './ToasterType';
+export * from './IToasterRefProps';
+export * from './IToasterProps';
diff --git a/src/components/Organisms/index.ts b/src/components/Organisms/index.ts
index a1e9f50..61e0858 100644
--- a/src/components/Organisms/index.ts
+++ b/src/components/Organisms/index.ts
@@ -1,2 +1,3 @@
export * from './BottomModal';
export * from './BottomListModal';
+export * from './Toaster';