feat: 完善训练

This commit is contained in:
2025-08-16 14:15:11 +08:00
parent 5a4d86ff7d
commit 4c6a0e0399
17 changed files with 3079 additions and 166 deletions

View File

@@ -0,0 +1,317 @@
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import React, { useEffect, useRef } from 'react';
import {
Animated,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { height: screenHeight } = Dimensions.get('window');
export interface ActionSheetOption {
id: string;
title: string;
subtitle?: string;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
destructive?: boolean;
onPress: () => void;
}
interface ActionSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
subtitle?: string;
options: ActionSheetOption[];
cancelText?: string;
}
export function ActionSheet({
visible,
onClose,
title,
subtitle,
options,
cancelText = '取消'
}: ActionSheetProps) {
const slideAnim = useRef(new Animated.Value(screenHeight)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
// 显示动画
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 0,
duration: 300,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
// 隐藏动画
Animated.parallel([
Animated.timing(slideAnim, {
toValue: screenHeight,
duration: 250,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
}
}, [visible]);
const handleOptionPress = (option: ActionSheetOption) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onClose();
// 延迟执行选项回调,让关闭动画先完成
setTimeout(() => {
option.onPress();
}, 100);
};
const handleCancel = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onClose();
};
if (!visible) return null;
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.container}>
{/* 背景遮罩 */}
<Animated.View
style={[
styles.backdrop,
{
opacity: opacityAnim,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
activeOpacity={1}
onPress={handleCancel}
/>
</Animated.View>
{/* 弹窗内容 */}
<Animated.View
style={[
styles.sheet,
{
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 拖拽指示器 */}
<View style={styles.dragIndicator} />
{/* 标题区域 */}
{(title || subtitle) && (
<View style={styles.header}>
{title && <Text style={styles.title}>{title}</Text>}
{subtitle && <Text style={styles.subtitle}>{subtitle}</Text>}
</View>
)}
{/* 选项列表 */}
<View style={styles.optionsContainer}>
{options.map((option, index) => (
<TouchableOpacity
key={option.id}
style={[
styles.option,
index === 0 && styles.firstOption,
index === options.length - 1 && styles.lastOption,
]}
onPress={() => handleOptionPress(option)}
activeOpacity={0.7}
>
<View style={styles.optionContent}>
{option.icon && (
<View style={styles.iconContainer}>
<Ionicons
name={option.icon}
size={20}
color={option.iconColor || (option.destructive ? '#EF4444' : '#374151')}
/>
</View>
)}
<View style={styles.textContainer}>
<Text
style={[
styles.optionTitle,
option.destructive && styles.destructiveText,
]}
>
{option.title}
</Text>
{option.subtitle && (
<Text style={styles.optionSubtitle}>{option.subtitle}</Text>
)}
</View>
</View>
<Ionicons name="chevron-forward" size={16} color="#9CA3AF" />
</TouchableOpacity>
))}
</View>
{/* 取消按钮 */}
<TouchableOpacity
style={styles.cancelButton}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={styles.cancelText}>{cancelText}</Text>
</TouchableOpacity>
{/* 底部安全区域 */}
<View style={styles.safeBottom} />
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
sheet: {
backgroundColor: '#FFFFFF',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
paddingTop: 8,
maxHeight: screenHeight * 0.8,
},
dragIndicator: {
width: 36,
height: 4,
backgroundColor: '#E5E7EB',
borderRadius: 2,
alignSelf: 'center',
marginBottom: 16,
},
header: {
paddingHorizontal: 20,
paddingBottom: 16,
borderBottomWidth: 1,
borderBottomColor: '#F3F4F6',
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
marginBottom: 4,
},
subtitle: {
fontSize: 14,
color: '#6B7280',
textAlign: 'center',
lineHeight: 20,
},
optionsContainer: {
paddingHorizontal: 20,
paddingTop: 8,
},
option: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: '#F9FAFB',
borderWidth: 1,
borderColor: '#E5E7EB',
marginBottom: 1,
},
firstOption: {
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
},
lastOption: {
borderBottomLeftRadius: 12,
borderBottomRightRadius: 12,
marginBottom: 0,
},
optionContent: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
iconContainer: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#FFFFFF',
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 2,
shadowOffset: { width: 0, height: 1 },
elevation: 1,
},
textContainer: {
flex: 1,
},
optionTitle: {
fontSize: 16,
fontWeight: '600',
color: '#111827',
marginBottom: 2,
},
optionSubtitle: {
fontSize: 13,
color: '#6B7280',
lineHeight: 18,
},
destructiveText: {
color: '#EF4444',
},
cancelButton: {
marginHorizontal: 20,
marginTop: 16,
paddingVertical: 16,
backgroundColor: '#F3F4F6',
borderRadius: 12,
alignItems: 'center',
},
cancelText: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
},
safeBottom: {
height: 34, // iPhone底部安全区域高度
},
});

View File

@@ -0,0 +1,251 @@
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import React, { useEffect, useRef } from 'react';
import {
Animated,
Dimensions,
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
const { width: screenWidth } = Dimensions.get('window');
interface ConfirmDialogProps {
visible: boolean;
onClose: () => void;
title: string;
message?: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
destructive?: boolean;
icon?: keyof typeof Ionicons.glyphMap;
iconColor?: string;
}
export function ConfirmDialog({
visible,
onClose,
title,
message,
confirmText = '确定',
cancelText = '取消',
onConfirm,
destructive = false,
icon,
iconColor,
}: ConfirmDialogProps) {
const scaleAnim = useRef(new Animated.Value(0.8)).current;
const opacityAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (visible) {
// 显示动画
Animated.parallel([
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
tension: 100,
friction: 8,
}),
Animated.timing(opacityAnim, {
toValue: 1,
duration: 200,
useNativeDriver: true,
}),
]).start();
} else {
// 隐藏动画
Animated.parallel([
Animated.timing(scaleAnim, {
toValue: 0.8,
duration: 150,
useNativeDriver: true,
}),
Animated.timing(opacityAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}),
]).start();
}
}, [visible]);
const handleConfirm = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
onClose();
// 延迟执行确认回调,让关闭动画先完成
setTimeout(() => {
onConfirm();
}, 100);
};
const handleCancel = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onClose();
};
if (!visible) return null;
const defaultIconColor = destructive ? '#EF4444' : '#3B82F6';
const confirmButtonColor = destructive ? '#EF4444' : '#3B82F6';
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
<View style={styles.container}>
{/* 背景遮罩 */}
<Animated.View
style={[
styles.backdrop,
{
opacity: opacityAnim,
},
]}
>
<TouchableOpacity
style={StyleSheet.absoluteFillObject}
activeOpacity={1}
onPress={handleCancel}
/>
</Animated.View>
{/* 弹窗内容 */}
<Animated.View
style={[
styles.dialog,
{
transform: [{ scale: scaleAnim }],
opacity: opacityAnim,
},
]}
>
{/* 图标 */}
{icon && (
<View style={[styles.iconContainer, { backgroundColor: `${iconColor || defaultIconColor}15` }]}>
<Ionicons
name={icon}
size={32}
color={iconColor || defaultIconColor}
/>
</View>
)}
{/* 标题 */}
<Text style={styles.title}>{title}</Text>
{/* 消息 */}
{message && <Text style={styles.message}>{message}</Text>}
{/* 按钮组 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.button, styles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>{cancelText}</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.button,
styles.confirmButton,
{ backgroundColor: confirmButtonColor },
]}
onPress={handleConfirm}
activeOpacity={0.7}
>
<Text style={styles.confirmButtonText}>{confirmText}</Text>
</TouchableOpacity>
</View>
</Animated.View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
},
backdrop: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
dialog: {
backgroundColor: '#FFFFFF',
borderRadius: 20,
paddingTop: 32,
paddingBottom: 24,
paddingHorizontal: 24,
width: '100%',
maxWidth: screenWidth - 80,
alignItems: 'center',
shadowColor: '#000',
shadowOpacity: 0.15,
shadowRadius: 20,
shadowOffset: { width: 0, height: 10 },
elevation: 10,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 20,
},
title: {
fontSize: 18,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
marginBottom: 8,
},
message: {
fontSize: 15,
color: '#6B7280',
textAlign: 'center',
lineHeight: 22,
marginBottom: 24,
},
buttonContainer: {
flexDirection: 'row',
gap: 12,
width: '100%',
},
button: {
flex: 1,
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
},
cancelButton: {
backgroundColor: '#F3F4F6',
},
confirmButton: {
backgroundColor: '#3B82F6',
},
cancelButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#374151',
},
confirmButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#FFFFFF',
},
});

View File

@@ -0,0 +1,67 @@
import React, { createContext, useContext } from 'react';
import { useDialog, type ActionSheetConfig, type ActionSheetOption, type DialogConfig } from '@/hooks/useDialog';
import { ActionSheet } from './ActionSheet';
import { ConfirmDialog } from './ConfirmDialog';
interface DialogContextType {
showConfirm: (config: DialogConfig, onConfirm: () => void) => void;
showActionSheet: (config: ActionSheetConfig, options: ActionSheetOption[]) => void;
}
const DialogContext = createContext<DialogContextType | null>(null);
export function DialogProvider({ children }: { children: React.ReactNode }) {
const {
confirmDialog,
showConfirm,
hideConfirm,
actionSheet,
showActionSheet,
hideActionSheet,
} = useDialog();
const contextValue: DialogContextType = {
showConfirm,
showActionSheet,
};
return (
<DialogContext.Provider value={contextValue}>
{children}
{/* 确认弹窗 */}
<ConfirmDialog
visible={confirmDialog.visible}
onClose={hideConfirm}
title={confirmDialog.config.title}
message={confirmDialog.config.message}
confirmText={confirmDialog.config.confirmText}
cancelText={confirmDialog.config.cancelText}
onConfirm={confirmDialog.onConfirm}
destructive={confirmDialog.config.destructive}
icon={confirmDialog.config.icon}
iconColor={confirmDialog.config.iconColor}
/>
{/* ActionSheet */}
<ActionSheet
visible={actionSheet.visible}
onClose={hideActionSheet}
title={actionSheet.config.title}
subtitle={actionSheet.config.subtitle}
cancelText={actionSheet.config.cancelText}
options={actionSheet.options}
/>
</DialogContext.Provider>
);
}
export function useGlobalDialog() {
const context = useContext(DialogContext);
if (!context) {
throw new Error('useGlobalDialog must be used within a DialogProvider');
}
return context;
}