WellNuo/components/ui/Toast.tsx
Sergei d453126c89 feat: Room location picker + robster credentials
- Backend: Update Legacy API credentials to robster/rob2
- Frontend: ROOM_LOCATIONS with icons and legacyCode mapping
- Device Settings: Modal picker for room selection
- api.ts: Bidirectional conversion (code ↔ name)
- Various UI/UX improvements across screens

PRD-DEPLOYMENT.md completed (Score: 9/10)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-24 15:22:40 -08:00

372 lines
9.1 KiB
TypeScript

import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
Animated,
TouchableOpacity,
Dimensions,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import {
AppColors,
BorderRadius,
FontSizes,
FontWeights,
Spacing,
Shadows,
} from '@/constants/theme';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
type ToastType = 'success' | 'error' | 'info' | 'warning';
interface ToastConfig {
type: ToastType;
title: string;
message?: string;
duration?: number;
action?: {
label: string;
onPress: () => void;
};
}
interface ToastContextValue {
show: (config: ToastConfig) => void;
success: (title: string, message?: string) => void;
error: (title: string, message?: string) => void;
info: (title: string, message?: string) => void;
hide: () => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
const toastConfig = {
success: {
icon: 'checkmark-circle' as const,
color: AppColors.success,
bgColor: AppColors.successLight,
},
error: {
icon: 'close-circle' as const,
color: AppColors.error,
bgColor: AppColors.errorLight,
},
info: {
icon: 'information-circle' as const,
color: AppColors.info,
bgColor: AppColors.infoLight,
},
warning: {
icon: 'warning' as const,
color: AppColors.warning,
bgColor: AppColors.warningLight,
},
};
interface ToastProviderProps {
children: React.ReactNode;
}
export function ToastProvider({ children }: ToastProviderProps) {
const insets = useSafeAreaInsets();
const [visible, setVisible] = useState(false);
const [config, setConfig] = useState<ToastConfig | null>(null);
const translateY = useRef(new Animated.Value(-100)).current;
const opacity = useRef(new Animated.Value(0)).current;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hide = useCallback(() => {
Animated.parallel([
Animated.timing(translateY, {
toValue: -100,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}),
]).start(() => {
setVisible(false);
setConfig(null);
});
}, [translateY, opacity]);
const show = useCallback((newConfig: ToastConfig) => {
// Clear any existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setConfig(newConfig);
setVisible(true);
// Animate in
Animated.parallel([
Animated.spring(translateY, {
toValue: 0,
tension: 80,
friction: 10,
useNativeDriver: true,
}),
Animated.timing(opacity, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
// Auto hide
const duration = newConfig.duration ?? 3000;
timeoutRef.current = setTimeout(() => {
hide();
}, duration);
}, [translateY, opacity, hide]);
const success = useCallback((title: string, message?: string) => {
show({ type: 'success', title, message });
}, [show]);
const error = useCallback((title: string, message?: string) => {
show({ type: 'error', title, message });
}, [show]);
const info = useCallback((title: string, message?: string) => {
show({ type: 'info', title, message });
}, [show]);
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const typeConfig = config ? toastConfig[config.type] : toastConfig.info;
return (
<ToastContext.Provider value={{ show, success, error, info, hide }}>
{children}
{visible && config && (
<Animated.View
style={[
styles.container,
{
top: insets.top + Spacing.sm,
transform: [{ translateY }],
opacity,
},
]}
pointerEvents="box-none"
>
<View style={styles.toast}>
{/* Icon */}
<View style={[styles.iconContainer, { backgroundColor: typeConfig.bgColor }]}>
<Ionicons
name={typeConfig.icon}
size={24}
color={typeConfig.color}
/>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.title}>{config.title}</Text>
{config.message && (
<Text style={styles.message} numberOfLines={2}>{config.message}</Text>
)}
</View>
{/* Action or Close */}
{config.action ? (
<TouchableOpacity
style={styles.actionButton}
onPress={() => {
config.action?.onPress();
hide();
}}
>
<Text style={styles.actionText}>{config.action.label}</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.closeButton} onPress={hide}>
<Ionicons name="close" size={20} color={AppColors.textMuted} />
</TouchableOpacity>
)}
</View>
</Animated.View>
)}
</ToastContext.Provider>
);
}
// Legacy Toast component for backwards compatibility
interface LegacyToastProps {
visible: boolean;
message: string;
icon?: keyof typeof Ionicons.glyphMap;
duration?: number;
onHide: () => void;
}
export function Toast({ visible, message, icon = 'checkmark-circle', duration = 2000, onHide }: LegacyToastProps) {
const fadeAnim = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(20)).current;
useEffect(() => {
if (visible) {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
]).start();
const timer = setTimeout(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}),
Animated.timing(translateY, {
toValue: 20,
duration: 200,
useNativeDriver: true,
}),
]).start(() => onHide());
}, duration);
return () => clearTimeout(timer);
}
}, [visible, duration, onHide]);
if (!visible) return null;
return (
<Animated.View
style={[
styles.legacyContainer,
{
opacity: fadeAnim,
transform: [{ translateY }],
},
]}
>
<View style={styles.legacyIconContainer}>
<Ionicons name={icon} size={20} color={AppColors.white} />
</View>
<Text style={styles.legacyMessage}>{message}</Text>
</Animated.View>
);
}
const styles = StyleSheet.create({
// New Toast Provider styles
container: {
position: 'absolute',
left: Spacing.md,
right: Spacing.md,
zIndex: 9999,
},
toast: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.xl,
padding: Spacing.md,
gap: Spacing.md,
...Shadows.lg,
borderWidth: 1,
borderColor: AppColors.borderLight,
},
iconContainer: {
width: 44,
height: 44,
borderRadius: BorderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
},
title: {
fontSize: FontSizes.base,
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
message: {
fontSize: FontSizes.sm,
color: AppColors.textSecondary,
marginTop: 2,
},
actionButton: {
paddingVertical: Spacing.sm,
paddingHorizontal: Spacing.md,
backgroundColor: AppColors.primaryLighter,
borderRadius: BorderRadius.md,
},
actionText: {
fontSize: FontSizes.sm,
fontWeight: FontWeights.semibold,
color: AppColors.primary,
},
closeButton: {
width: 32,
height: 32,
borderRadius: BorderRadius.md,
backgroundColor: AppColors.surfaceSecondary,
justifyContent: 'center',
alignItems: 'center',
},
// Legacy Toast styles
legacyContainer: {
position: 'absolute',
bottom: 100,
left: Spacing.xl,
right: Spacing.xl,
backgroundColor: AppColors.textPrimary,
borderRadius: BorderRadius.lg,
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.lg,
flexDirection: 'row',
alignItems: 'center',
gap: Spacing.sm,
...Shadows.lg,
},
legacyIconContainer: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: AppColors.success,
justifyContent: 'center',
alignItems: 'center',
},
legacyMessage: {
flex: 1,
fontSize: FontSizes.base,
fontWeight: FontWeights.medium,
color: AppColors.white,
},
});