Initial commit: expo-popcore-app

This commit is contained in:
imeepos
2025-12-25 16:25:55 +08:00
commit 02d0c807cd
160 changed files with 39560 additions and 0 deletions

71
components/ui/Block.tsx Normal file
View File

@@ -0,0 +1,71 @@
import React, { forwardRef } from 'react'
import { View, TouchableOpacity, ViewStyle, Pressable } from 'react-native'
import Animated from 'react-native-reanimated'
const AnimatedView = Animated.createAnimatedComponent(View)
interface BlockProps {
onClick?: () => void
onLongPress?: () => void
animated?: boolean
style?: ViewStyle
children?: React.ReactNode
opacity?: number
touchProps?: any
className?: string
}
const Block = forwardRef<View, BlockProps>((props, ref) => {
const {
onClick = null,
onLongPress,
animated,
children,
opacity,
touchProps,
className = '',
...reset
} = props
const handlePress = () => {
onClick && onClick()
}
if ((onClick || onLongPress) && opacity) {
return (
<TouchableOpacity
activeOpacity={opacity || 0.85}
onPress={handlePress}
onLongPress={onLongPress}
>
<View className={className} {...reset} ref={ref}>
{children}
</View>
</TouchableOpacity>
)
} else if (onClick || onLongPress) {
return (
<Pressable onPress={handlePress} onLongPress={onLongPress} {...touchProps}>
<View className={className} {...reset} ref={ref}>
{children}
</View>
</Pressable>
)
} else if (animated) {
return (
<AnimatedView className={className} {...reset} ref={ref}>
{children}
</AnimatedView>
)
} else {
return (
<View className={className} {...reset} ref={ref}>
{children}
</View>
)
}
})
// 添加displayName以便在React DevTools中显示组件名称
Block.displayName = 'Block'
export default Block

80
components/ui/Img.tsx Normal file
View File

@@ -0,0 +1,80 @@
import React, { forwardRef, useMemo } from 'react'
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image'
import { TouchableOpacity, TouchableOpacityProps } from 'react-native'
interface ImgProps extends ExpoImageProps {
onClick?: () => void
touchProps?: TouchableOpacityProps
className?: string
src?: string | number
errorSource?: string | number
}
const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
const {
onClick,
touchProps = {},
className = '',
style,
src,
errorSource,
source: propSource,
...reset
} = props
// 判断是否为网络图片
const isNetworkImage = (uri: string | number): boolean => {
if (typeof uri === 'number') return false
return uri.startsWith('http://') || uri.startsWith('https://')
}
// 构建图片源
const imageSource = useMemo(() => {
// 如果提供了source属性优先使用
if (propSource) return propSource
if (!src) return undefined
if (typeof src === 'number') {
// 本地图片资源require导入的资源ID
return src
} else {
// 网络图片或本地文件路径
if (isNetworkImage(src)) {
return {
uri: src,
cache: 'immutable', // 使用expo-image的缓存机制
}
} else {
// 本地文件路径
return { uri: src }
}
}
}, [src, propSource])
const imgProps = {
style,
className,
ref,
source: imageSource,
errorSource,
...reset,
}
const handlePress = () => {
onClick && onClick()
}
if (onClick) {
return (
<TouchableOpacity onPress={handlePress} {...touchProps}>
<ExpoImage {...imgProps} />
</TouchableOpacity>
)
}
return <ExpoImage {...imgProps} />
})
Img.displayName = 'Img'
export default Img

View File

@@ -0,0 +1,300 @@
import React, {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
forwardRef,
} from 'react'
import Animated, {
Extrapolation,
FadeIn,
FadeInDown,
FadeInLeft,
FadeInRight,
FadeInUp,
FadeOut,
FadeOutDown,
FadeOutLeft,
FadeOutRight,
FadeOutUp,
ZoomIn,
ZoomOut,
interpolate,
runOnJS,
useAnimatedStyle,
useSharedValue,
withSpring,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { BackHandler, Pressable, StyleSheet, View, useWindowDimensions } from 'react-native'
import Overlay from './Overlay'
export const defaultDirection = 'down'
export const defaultConfig = {
animationInTiming: 150,
animationOutTiming: 150,
hideBackdrop: false,
backdropColor: 'rgba(0, 0, 0, 0.1)',
dampingFactor: 0.2,
swipeDirection: defaultDirection,
swipeVelocityThreshold: 500,
onBackButtonPress: undefined,
onBackdropPress: undefined,
style: {},
// 点击穿透
// pointerEvents: 'auto',
}
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
const defaultAnimationInMap = {
fade: FadeIn,
zoom: ZoomIn,
up: FadeInUp,
down: FadeInDown,
left: FadeInLeft,
right: FadeInRight,
}
const defaultAnimationOutMap = {
fade: FadeOut,
zoom: ZoomOut,
up: FadeOutUp,
down: FadeOutDown,
left: FadeOutLeft,
right: FadeOutRight,
}
const ModalPortal = forwardRef((props, ref) => {
const [config, setConfig] = useState(defaultConfig)
const [modalContent, setModalContent] = useState()
const translationX = useSharedValue(0)
const translationY = useSharedValue(0)
const prevTranslationX = useSharedValue(0)
const prevTranslationY = useSharedValue(0)
const onHideRef = useRef(() => {})
const { width, height } = useWindowDimensions()
const hide = useCallback(async (props) => {
setModalContent(undefined)
await new Promise((resolve) => {
setTimeout(resolve, config.animationOutTiming)
})
translationX.value = 0
translationY.value = 0
onHideRef.current(props)
}, [])
useImperativeHandle(ref, () => ({
hide,
show: async (newComponent, newConfig = {}) => {
if (modalContent) await hide()
const mergedConfig = { ...defaultConfig, ...newConfig }
setConfig(mergedConfig)
if (React.isValidElement(newComponent) && newComponent) {
setModalContent(newComponent)
}
return new Promise((resolve) => {
onHideRef.current = resolve
})
},
}))
const onBackdropPress = useMemo(() => {
return config.onBackdropPress ?? (() => hide())
}, [config.onBackdropPress, hide])
const isHorizontal = config.swipeDirection === 'left' || config.swipeDirection === 'right'
const rangeMap = useMemo(() => {
return {
up: -height,
down: height,
left: -width,
right: width,
}
}, [height, width])
const pan = Gesture.Pan()
.enabled(!!config.swipeDirection)
.minDistance(1)
.onStart(() => {
prevTranslationX.value = translationX.value
prevTranslationY.value = translationY.value
})
.onUpdate((event) => {
const translationValue = isHorizontal ? event.translationX : event.translationY
const prevTranslationValue = isHorizontal ? prevTranslationX.value : prevTranslationY.value
const shouldDampMap = {
up: translationValue > 0,
down: translationValue < 0,
left: translationValue > 0,
right: translationValue < 0,
}
const shouldDamp = shouldDampMap[config.swipeDirection ?? defaultDirection]
const dampedTranslation = shouldDamp
? prevTranslationValue + translationValue * config.dampingFactor
: prevTranslationValue + translationValue
if (isHorizontal) {
translationX.value = dampedTranslation
} else {
translationY.value = dampedTranslation
}
})
.onEnd((event) => {
const velocityThreshold = config.swipeVelocityThreshold
const shouldHideMap = {
up: event.velocityY < -velocityThreshold,
down: event.velocityY > velocityThreshold,
right: event.velocityX > velocityThreshold,
left: event.velocityX < -velocityThreshold,
}
const shouldHide = shouldHideMap[config.swipeDirection ?? defaultDirection]
if (!shouldHide) {
translationX.value = withSpring(0, {
velocity: event.velocityX,
damping: 75,
})
translationY.value = withSpring(0, {
velocity: event.velocityY,
damping: 75,
})
return
}
const mainTranslation = isHorizontal ? translationX : translationY
mainTranslation.value = withSpring(
rangeMap[config.swipeDirection ?? defaultDirection],
{ velocity: event.velocityX, overshootClamping: true },
(success) => success && runOnJS(hide)(),
)
return
})
const animatedStyles = useAnimatedStyle(
() => ({
transform: [{ translateX: translationX.value }, { translateY: translationY.value }],
}),
[translationX.value, translationY.value],
)
const animatedBackdropStyles = useAnimatedStyle(() => {
const translationValue = isHorizontal ? translationX.value : translationY.value
return {
opacity: interpolate(
translationValue,
[rangeMap[config.defaultDirection ?? defaultDirection], 0],
[0, 1],
Extrapolation.CLAMP,
),
}
}, [config.swipeDirection, translationX.value, translationY.value, rangeMap])
useEffect(() => {
if (!modalContent) return
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
if (config.onBackButtonPress) {
config.onBackButtonPress()
} else {
hide()
}
return true
})
return () => backHandler.remove()
}, [config.onBackButtonPress, hide, modalContent])
const isBackdropVisible = modalContent && !config.hideBackdrop
return (
<Overlay>
<View pointerEvents={isBackdropVisible ? 'auto' : 'box-none'} style={StyleSheet.absoluteFill}>
{modalContent && (
<>
<Animated.View
pointerEvents={isBackdropVisible ? 'auto' : 'none'}
entering={FadeIn.duration(config.animationInTiming)}
exiting={FadeOut.duration(config.animationOutTiming)}
style={styles.backdropContainer}
>
<AnimatedPressable
testID="magic-modal-backdrop"
style={[
styles.backdrop,
animatedBackdropStyles,
{
backgroundColor: isBackdropVisible ? config.backdropColor : 'transparent',
},
]}
onPress={onBackdropPress}
/>
</Animated.View>
<Animated.View pointerEvents="box-none" style={[styles.overlay, animatedStyles]}>
<Animated.View
pointerEvents="box-none"
style={[styles.overlay, config.style]}
// entering={FadeInUp}
entering={
config.entering ??
defaultAnimationInMap[config.swipeDirection ?? defaultDirection].duration(
config.animationInTiming,
)
}
exiting={
config.exiting ??
defaultAnimationOutMap[config.swipeDirection ?? defaultDirection].duration(
config.animationOutTiming,
)
}
>
<GestureDetector gesture={pan}>{modalContent}</GestureDetector>
</Animated.View>
</Animated.View>
</>
)}
</View>
</Overlay>
)
})
ModalPortal.displayName = 'ModalPortal'
export default ModalPortal
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'center',
},
backdrop: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
backdropContainer: {
position: 'absolute',
width: '100%',
height: '100%',
},
})

9
components/ui/Overlay.js Normal file
View File

@@ -0,0 +1,9 @@
import { Fragment } from 'react'
import { Platform } from 'react-native'
import { FullWindowOverlay } from 'react-native-screens'
// 为了兼容 iOS 和 Android我们需要使用 react-native-screens 的 FullWindowOverlay 组件
/** Don't use .ios file extension as bunchee can't bundle it properly */
const Overlay = Platform.OS === 'ios' ? FullWindowOverlay : Fragment
export default Overlay

48
components/ui/Text.tsx Normal file
View File

@@ -0,0 +1,48 @@
import React, { forwardRef } from 'react'
import {
Text as RNText,
TouchableOpacity,
TextProps as RNTextProps,
TouchableOpacityProps,
StyleProp,
TextStyle,
} from 'react-native'
import Animated from 'react-native-reanimated'
interface TextProps extends RNTextProps {
onClick?: () => void
touchProps?: TouchableOpacityProps
animated?: boolean
style?: StyleProp<TextStyle>
className?: string
}
const Text = forwardRef<RNText, TextProps>((props, ref) => {
const { onClick, touchProps = {}, animated, style, className = '', children, ...reset } = props
const textProps = {
className,
ref: ref,
...reset,
}
const handlePress = () => {
onClick && onClick()
}
if (animated) {
return <Animated.Text {...textProps}>{children}</Animated.Text>
}
if (onClick) {
return (
<TouchableOpacity activeOpacity={1} onPress={handlePress} {...touchProps}>
<RNText {...textProps}>{children}</RNText>
</TouchableOpacity>
)
}
return <RNText {...textProps}>{children}</RNText>
})
Text.displayName = 'Text'
export default Text

253
components/ui/Toast.tsx Normal file
View File

@@ -0,0 +1,253 @@
import React, { ReactNode } from 'react'
import Text from './Text'
import Block from './Block'
import { ActivityIndicator } from 'react-native'
// 定义insets接口
interface Insets {
safeAreaInsetsBottom: number
safeAreaInsetsTop: number
safeAreaInsetsLeft: number
safeAreaInsetsRight: number
}
// 定义全局变量接口
declare global {
interface Window {
toast?: {
show: (component: ReactNode, config?: any) => void
hide: () => void
}
loading?: {
show: (component: ReactNode, config?: any) => void
hide: () => void
}
modal?: {
show: (component: ReactNode, config?: any) => void
hide: () => void
}
actionSheet?: {
show: (component: ReactNode, config?: any) => void
hide: () => void
}
}
}
// 参数接口定义
interface ShowParams {
renderContent?: () => ReactNode
title?: string
duration?: number
hideBackdrop?: boolean
}
interface ShowActionSheetParams {
itemList?: string[]
renderContent?: () => ReactNode
}
const isString = (variable: any): variable is string =>
Object.prototype.toString.call(variable) === '[object String]'
const Toast = (function () {
// 初始化默认insets
let insets: Insets = {
safeAreaInsetsBottom: 0,
safeAreaInsetsTop: 0,
safeAreaInsetsLeft: 0,
safeAreaInsetsRight: 0,
}
// // 使用回调函数获取insets
// StaticSafeAreaInsets.getSafeAreaInsets((staticInsets) => {
// insets = staticInsets
// })
let toastTimer: number | null = null
const show = (params?: ShowParams): void => {
const { renderContent, title, duration = 2000, hideBackdrop = true } = params || {}
hide()
const renderBody = (): ReactNode => {
if (renderContent) {
return renderContent()
}
return title && <Text className="text-[16px] text-white">{title}</Text>
}
; (global as any).toast?.show(
<Block className="z-[9999] flex items-center justify-center">
<Block className="relative mx-[20px] mt-[-40px] rounded-[2px] bg-black/85 px-[12px] py-[8px]">
{renderBody()}
</Block>
</Block>,
{
style: {},
swipeDirection: null,
hideBackdrop,
onBackdropPress: () => { },
entering: false,
exiting: false,
},
)
if (duration > 0) {
toastTimer = setTimeout(() => {
hide()
}, duration)
}
}
const hide = (): void => {
; (global as any).toast?.hide()
if (toastTimer) {
clearTimeout(toastTimer)
toastTimer = null
}
}
// loading
let loadingTimer: number | null = null
const showLoading = (params?: ShowParams): void => {
const { renderContent, title, duration = 1500, hideBackdrop = false } = params || {}
hideLoading()
const renderBody = (): ReactNode => {
if (renderContent) {
return renderContent()
}
return (
<>
<ActivityIndicator size="small" />
{!!title && (
<Block className="mt-[12px] items-center px-[10px]">
<Text className="text-[14px] text-white">{title}</Text>
</Block>
)}
</>
)
}
; (global as any).loading?.show(
<Block className="z-[9999] flex items-center justify-center">
<Block className="mt-[-40px] min-h-[100px] min-w-[100px] items-center justify-center overflow-hidden rounded-[8px] bg-black/85 py-[20px]">
{renderBody()}
</Block>
</Block>,
{
style: {},
swipeDirection: null,
hideBackdrop,
backdropColor: 'rgba(0,0,0,0.1)',
onBackdropPress: () => { },
entering: false,
exiting: false,
},
)
if (duration > 0) {
loadingTimer = setTimeout(() => {
hideLoading()
}, duration)
}
}
const hideLoading = (): void => {
; (global as any).loading?.hide()
if (loadingTimer) {
clearTimeout(loadingTimer)
loadingTimer = null
}
}
// modal
const showModal = (ele: ReactNode, config: any = {}): void => {
; (global as any).modal?.show(ele, {
swipeDirection: null,
...config,
})
}
const hideModal = (): void => {
; (global as any).modal?.hide()
}
// actionsheet
// 兼容半屏组件
const showActionSheet = ({ itemList, renderContent }: ShowActionSheetParams): Promise<number> => {
return new Promise((resolve, reject) => {
const handleSelect = (item: string): void => {
hideActionSheet()
const itemIndex = itemList?.findIndex((i) => i === item) || -1
resolve(itemIndex)
}
const handleCancel = (): void => {
hideActionSheet()
reject()
}
const renderBody = (): ReactNode => {
if (Array.isArray(itemList)) {
return (
<Block className="bg-white2 mb-[-1000px] gap-[1px] overflow-hidden rounded-t-[12px]">
{itemList.map((item, index) => (
<Block
onClick={() => handleSelect(item)}
key={index}
className="items-center justify-center bg-white py-[16px]"
>
<Text className="text-[16px] text-black">{item}</Text>
</Block>
))}
<Block className="bg-white2 mt-[8px] w-full" />
<Block
onClick={handleCancel}
style={{ paddingBottom: insets.safeAreaInsetsBottom + 20 + 1000 }}
className="flex items-center justify-center bg-white py-[16px]"
>
<Text className="text-[16px] text-black"></Text>
</Block>
</Block>
)
}
if (!renderContent) return null
return (
<Block className="">
{/* <ModalHeader /> */}
{/* 安卓闪动向上2px */}
<Block className="mt-[-2px]">{renderContent()}</Block>
<Block
className="mb-[-1000px] w-full bg-white"
style={{ height: insets.safeAreaInsetsBottom + 20 + 1000, position: 'relative' }}
/>
</Block>
)
}
; (global as any).actionSheet?.show(<Block className="">{renderBody()}</Block>, {
style: { justifyContent: 'flex-end' },
})
})
}
const hideActionSheet = (): void => {
; (global as any).actionSheet?.hide()
}
return {
showActionSheet,
hideActionSheet,
showModal,
hideModal,
showLoading,
hideLoading,
show,
hide,
}
})()
export default Toast

View File

@@ -0,0 +1,428 @@
import * as React from "react";
import {
Modal,
View,
Text,
Pressable,
type ViewStyle,
type TextStyle,
StyleSheet,
StatusBar,
Platform,
} from "react-native";
import { BlurView } from "expo-blur";
import { cn } from "../../lib/utils";
import { Button, buttonVariants } from "./button";
interface AlertDialogContextValue {
open: boolean;
setOpen: (open: boolean) => void;
}
const AlertDialogContext = React.createContext<AlertDialogContextValue | null>(
null
);
const useAlertDialogContext = () => {
const context = React.useContext(AlertDialogContext);
if (!context) {
throw new Error(
"AlertDialog components must be used within AlertDialog provider"
);
}
return context;
};
interface AlertDialogProps {
children: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function AlertDialog({ children, open, onOpenChange }: AlertDialogProps) {
const [internalOpen, setInternalOpen] = React.useState(false);
const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const handleSetOpen = React.useCallback(
(value: boolean) => {
if (!isControlled) {
setInternalOpen(value);
}
onOpenChange?.(value);
},
[isControlled, onOpenChange]
);
const value: AlertDialogContextValue = {
open: isOpen,
setOpen: handleSetOpen,
};
return (
<AlertDialogContext.Provider value={value}>
{children}
</AlertDialogContext.Provider>
);
}
interface AlertDialogTriggerProps {
children: React.ReactNode;
asChild?: boolean;
style?: ViewStyle;
}
function AlertDialogTrigger({
children,
asChild,
style,
}: AlertDialogTriggerProps) {
const { setOpen } = useAlertDialogContext();
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<any>, {
onPress: () => setOpen(true),
});
}
return (
<Pressable onPress={() => setOpen(true)} style={style}>
{children}
</Pressable>
);
}
interface AlertDialogPortalProps {
children: React.ReactNode;
}
function AlertDialogPortal({ children }: AlertDialogPortalProps) {
return <>{children}</>;
}
interface AlertDialogOverlayProps {
className?: string;
style?: ViewStyle;
}
function AlertDialogOverlay({ className, style }: AlertDialogOverlayProps) {
return (
<View
className={cn("absolute inset-0", className)}
style={[styles.overlay, style]}
>
{Platform.OS === 'ios' ? (
<BlurView
intensity={40}
tint="dark"
style={StyleSheet.absoluteFill}
/>
) : null}
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: "#00000080" },
]}
/>
</View>
);
}
interface AlertDialogContentProps {
children: React.ReactNode;
className?: string;
style?: ViewStyle;
}
function AlertDialogContent({
children,
className,
style,
}: AlertDialogContentProps) {
const { open, setOpen } = useAlertDialogContext();
React.useEffect(() => {
if (Platform.OS === "android") {
if (open) {
StatusBar.setBackgroundColor("rgba(0,0,0,0.5)", true);
} else {
StatusBar.setBackgroundColor("transparent", true);
}
}
}, [open]);
if (!open) return null;
return (
<Modal
transparent
visible={open}
animationType="none"
statusBarTranslucent={Platform.OS === 'android'}
onRequestClose={() => setOpen(false)}
hardwareAccelerated
>
<View style={styles.modalContainer}>
<Pressable
style={styles.backdrop}
onPress={() => setOpen(false)}
android_ripple={null}
>
<AlertDialogOverlay />
</Pressable>
<View
className={cn(
"bg-background w-[90%] max-w-[500px] rounded-lg border border-border p-6 shadow-2xl gap-4",
className
)}
style={[styles.content, style]}
>
{children}
</View>
</View>
</Modal>
);
}
interface AlertDialogHeaderProps {
children: React.ReactNode;
className?: string;
style?: ViewStyle;
}
function AlertDialogHeader({
children,
className,
style,
}: AlertDialogHeaderProps) {
return (
<View
className={cn("flex-col gap-2", className)}
style={style}
>
{children}
</View>
);
}
interface AlertDialogFooterProps {
children: React.ReactNode;
className?: string;
style?: ViewStyle;
}
function AlertDialogFooter({
children,
className,
style,
}: AlertDialogFooterProps) {
return (
<View
className={cn("flex flex-row gap-2 justify-end", className)}
style={style}
>
{children}
</View>
);
}
interface AlertDialogTitleProps {
children: React.ReactNode;
className?: string;
style?: TextStyle;
}
function AlertDialogTitle({
children,
className,
style,
}: AlertDialogTitleProps) {
return (
<Text
className={cn("text-lg font-semibold text-foreground", className)}
style={style}
>
{children}
</Text>
);
}
interface AlertDialogDescriptionProps {
children: React.ReactNode;
className?: string;
style?: TextStyle;
}
function AlertDialogDescription({
children,
className,
style,
}: AlertDialogDescriptionProps) {
return (
<Text
className={cn("text-sm text-muted-foreground", className)}
style={style}
>
{children}
</Text>
);
}
interface AlertDialogActionProps {
children: React.ReactNode;
className?: string;
onPress?: () => void;
style?: ViewStyle;
}
function AlertDialogAction({
children,
className,
onPress,
style,
}: AlertDialogActionProps) {
const { setOpen } = useAlertDialogContext();
const handlePress = () => {
onPress?.();
setOpen(false);
};
return (
<Button
onPress={handlePress}
className={cn("flex-1", className)}
style={style}
>
{children}
</Button>
);
}
interface AlertDialogCancelProps {
children: React.ReactNode;
className?: string;
onPress?: () => void;
style?: ViewStyle;
}
function AlertDialogCancel({
children,
className,
onPress,
style,
}: AlertDialogCancelProps) {
const { setOpen } = useAlertDialogContext();
const handlePress = () => {
onPress?.();
setOpen(false);
};
return (
<Button
variant="outline"
onPress={handlePress}
className={cn("flex-1", className)}
style={style}
>
{children}
</Button>
);
}
interface DeleteAlertDialogActionProps {
onPress?: () => void;
style?: ViewStyle;
className?: string;
}
function DeleteAlertDialogAction({
onPress,
style,
className,
}: DeleteAlertDialogActionProps) {
return (
<AlertDialogAction
onPress={onPress}
className={className}
style={{ ...styles.deleteActionButton, ...style }}
>
<Text style={styles.deleteActionText}></Text>
</AlertDialogAction>
);
}
function CancelAlertDialogAction({
onPress,
style,
className,
}: DeleteAlertDialogActionProps) {
return (
<AlertDialogAction
onPress={onPress}
className={className}
style={{ ...styles.cancelActionButton, ...style }}
>
<Text style={styles.cancelActionText}></Text>
</AlertDialogAction>
);
}
const styles = StyleSheet.create({
modalContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
...Platform.select({
android: {
backgroundColor: 'transparent',
},
}),
},
backdrop: {
...StyleSheet.absoluteFillObject,
},
overlay: {
zIndex: 1,
},
content: {
zIndex: 2,
},
deleteActionButton: {
backgroundColor: "#FA3F2C",
alignItems: 'center'
},
deleteActionText: {
color: "#FFFFFF",
fontSize: 14,
fontWeight: "500",
},
cancelActionButton: {
backgroundColor: '#262A31',
alignItems: 'center'
},
cancelActionText: {
color: "#CCCCCC",
fontSize: 14,
}
});
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
DeleteAlertDialogAction,
CancelAlertDialogAction
};

116
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,116 @@
import * as React from "react";
import { Pressable, Text, type PressableProps, type ViewStyle } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "../../lib/utils";
const buttonVariants = cva(
"flex-row items-center justify-center rounded-md active:opacity-70",
{
variants: {
variant: {
default: "bg-primary",
destructive: "bg-destructive",
outline: "border border-input bg-background",
secondary: "bg-secondary",
ghost: "active:bg-accent",
link: "",
gradient: "",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
const buttonTextVariants = cva("text-sm font-medium text-center", {
variants: {
variant: {
default: "text-primary-foreground",
destructive: "text-destructive-foreground",
outline: "text-foreground",
secondary: "text-secondary-foreground",
ghost: "text-foreground",
link: "text-primary",
gradient: "text-white",
},
},
defaultVariants: {
variant: "default",
},
});
export interface ButtonProps
extends Omit<PressableProps, "style">,
VariantProps<typeof buttonVariants> {
children?: React.ReactNode;
style?: ViewStyle;
}
const Button = React.forwardRef<
React.ElementRef<typeof Pressable>,
ButtonProps
>(({ className, variant, size, children, disabled, style, ...props }, ref) => {
const isGradient = variant === "gradient";
const content = typeof children === "string" ? (
<Text className={cn(buttonTextVariants({ variant }))}>{children}</Text>
) : (
children
);
// 合并样式,确保 borderRadius 正确应用
const borderRadius = style?.borderRadius ?? (isGradient ? 12 : undefined);
const mergedStyle = borderRadius
? [style, { borderRadius }]
: style;
return (
<Pressable
ref={ref}
className={cn(
buttonVariants({ variant, size }),
disabled && "opacity-50",
isGradient && "overflow-hidden",
className
)}
disabled={disabled}
style={mergedStyle}
{...props}
>
{isGradient ? (
<LinearGradient
colors={["#9966FF", "#FF6699", "#FF9966"]}
locations={[0.0015, 0.4985, 0.9956]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={{
flex: 1,
alignSelf: "stretch",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
borderRadius: borderRadius ?? 12,
}}
>
{content}
</LinearGradient>
) : (
content
)}
</Pressable>
);
});
Button.displayName = "Button";
export { Button, buttonVariants };

151
components/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,151 @@
import React, { useState, useRef } from "react";
import { View as RNView, Dimensions, type ViewProps, type StyleProp, type ViewStyle } from "react-native";
import RNCarousel, { ICarouselInstance } from "react-native-reanimated-carousel";
import { cn } from "../../lib/utils";
// 扩展 View 以支持 classNameNativeWind
const View = RNView as React.ComponentType<ViewProps & { className?: string }>;
const { width: screenWidth } = Dimensions.get("window");
export type CarouselApi = ICarouselInstance;
type CarouselProps = {
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
className?: string;
children: React.ReactNode;
width?: number;
height?: number;
autoPlay?: boolean;
autoPlayInterval?: number;
loop?: boolean;
};
type CarouselContextProps = {
api: CarouselApi | null;
orientation: "horizontal" | "vertical";
width: number;
height: number;
autoPlay: boolean;
autoPlayInterval: number;
loop: boolean;
};
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
setApi,
className,
children,
width = screenWidth,
height = 410,
autoPlay = false,
autoPlayInterval = 3000,
loop = true,
onIndexChange,
}: CarouselProps & { onIndexChange?: (index: number) => void }) {
const [api, setInternalApi] = useState<CarouselApi | null>(null);
const carouselRef = useRef<ICarouselInstance>(null);
React.useEffect(() => {
if (carouselRef.current) {
const instance = carouselRef.current;
setInternalApi(instance);
setApi?.(instance);
}
}, [setApi]);
// 提取子元素到数组,过滤掉非 ReactElement 的内容
// 如果子元素是 CarouselContent需要提取其内部的子元素
const childrenArray = React.Children.toArray(children);
const items = childrenArray.reduce<React.ReactElement[]>((acc, child) => {
if (React.isValidElement(child)) {
const props = child.props as { children?: React.ReactNode };
if (props.children) {
const innerChildren = React.Children.toArray(props.children).filter(
(innerChild): innerChild is React.ReactElement => React.isValidElement(innerChild)
);
return [...acc, ...innerChildren];
}
return [...acc, child];
}
return acc;
}, []);
return (
<CarouselContext.Provider
value={{
api,
orientation,
width,
height,
autoPlay,
autoPlayInterval,
loop,
}}
>
<View className={cn("relative", className)}>
<RNCarousel
ref={carouselRef}
loop={loop}
width={width}
height={height}
autoPlay={autoPlay}
autoPlayInterval={autoPlayInterval}
data={items}
scrollAnimationDuration={500}
vertical={orientation === "vertical"}
renderItem={({ item }) => item as React.ReactElement}
onSnapToItem={onIndexChange}
/>
</View>
</CarouselContext.Provider>
);
}
function CarouselContent({
children,
...props
}: {
className?: string;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}) {
// CarouselContent 在新架构中主要用于包裹 CarouselItem
// 实际渲染由 Carousel 的 renderItem 处理
return <>{children}</>;
}
function CarouselItem({
className,
children,
...props
}: {
className?: string;
children: React.ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const { width, height } = useCarousel();
return (
<View
className={cn("flex justify-center items-center", className)}
style={{ width, height }}
{...props}
>
{children}
</View>
);
}
export { Carousel, CarouselContent, CarouselItem, useCarousel };

View File

@@ -0,0 +1,137 @@
import { StyleSheet, Platform } from 'react-native'
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
DeleteAlertDialogAction,
CancelAlertDialogAction,
} from './alert-dialog'
interface DeleteConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
title?: string
description?: string
}
export function DeleteConfirmDialog({
open,
onOpenChange,
onConfirm,
title = '删除该条记录?',
description = '删除后不可恢复',
}: DeleteConfirmDialogProps) {
const handleConfirm = () => {
onConfirm()
onOpenChange(false)
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent style={styles.dialogContent}>
<AlertDialogHeader>
<AlertDialogTitle style={styles.dialogTitle}>
{title}
</AlertDialogTitle>
<AlertDialogDescription style={styles.dialogDescription}>
{description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter style={styles.dialogFooter}>
<CancelAlertDialogAction
onPress={() => onOpenChange(false)}
style={styles.cancelButton}
/>
<DeleteAlertDialogAction
onPress={handleConfirm}
style={styles.confirmDeleteButton}
/>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
const styles = StyleSheet.create({
dialogContent: {
backgroundColor: '#1C1E22',
borderRadius: 16,
padding: 24,
width: '90%',
maxWidth: 320,
borderWidth: 0,
// Android 阴影效果
...Platform.select({
android: {
elevation: 24,
},
ios: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.3,
shadowRadius: 16,
},
}),
},
dialogTitle: {
color: '#F5F5F5',
fontSize: 15,
fontWeight: '500',
textAlign: 'center',
marginBottom: 8,
// iOS 字体渲染优化
...Platform.select({
ios: {
fontWeight: '600',
},
}),
},
dialogDescription: {
color: '#8A8A8A',
fontSize: 13,
textAlign: 'center',
lineHeight: 20,
},
dialogFooter: {
flexDirection: 'row',
gap: 12,
marginTop: 24,
justifyContent: 'center',
},
cancelButton: {
flex: 1,
height: 44,
borderRadius: 8,
backgroundColor: '#262A31',
justifyContent: 'center',
alignItems: 'center',
// Android 触摸反馈
...Platform.select({
android: {
elevation: 0,
},
}),
},
confirmDeleteButton: {
flex: 1,
height: 44,
borderRadius: 8,
backgroundColor: '#FA3F2C',
justifyContent: 'center',
alignItems: 'center',
// Android 触摸反馈
...Platform.select({
android: {
elevation: 0,
},
}),
},
})

443
components/ui/drawer.tsx Normal file
View File

@@ -0,0 +1,443 @@
import React, { useEffect, useImperativeHandle, forwardRef, ReactNode, useCallback, useMemo } from 'react'
import {
View,
StyleSheet,
Pressable,
useWindowDimensions,
BackHandler,
Modal,
Platform,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { CloseIcon } from '@/components/icon'
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
interpolate,
Extrapolation,
} from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
const AnimatedPressable = Animated.createAnimatedComponent(Pressable)
export type DrawerPosition = 'bottom' | 'top' | 'left' | 'right'
export interface DrawerProps {
/**
* 是否显示抽屉
*/
visible: boolean
/**
* 关闭回调
*/
onClose: () => void
/**
* 抽屉内容
*/
children: ReactNode
/**
* 抽屉位置,默认为 'bottom'
*/
position?: DrawerPosition
/**
* 抽屉高度position 为 'top' 或 'bottom' 时使用)
*/
height?: number | string
/**
* 抽屉宽度position 为 'left' 或 'right' 时使用)
*/
width?: number | string
/**
* 是否显示遮罩层,默认为 true
*/
showBackdrop?: boolean
/**
* 遮罩层颜色,默认为 'rgba(0, 0, 0, 0.5)'
*/
backdropColor?: string
/**
* 点击遮罩层是否关闭,默认为 true
*/
closeOnBackdropPress?: boolean
/**
* 是否支持手势拖拽关闭,默认为 true
*/
enableGesture?: boolean
/**
* 拖拽关闭的阈值(速度),默认为 500
*/
swipeVelocityThreshold?: number
/**
* 自定义样式
*/
style?: any
/**
* 遮罩层样式
*/
backdropStyle?: any
/**
* 是否显示关闭按钮,默认为 true
*/
showCloseButton?: boolean
}
export interface DrawerRef {
/**
* 关闭抽屉
*/
close: () => void
}
const Drawer = forwardRef<DrawerRef, DrawerProps>(
(
{
visible,
onClose,
children,
position = 'bottom',
height,
width,
showBackdrop = true,
backdropColor = 'rgba(0, 0, 0, 0.5)',
closeOnBackdropPress = true,
enableGesture = true,
swipeVelocityThreshold = 500,
style,
backdropStyle,
showCloseButton = true,
},
ref,
) => {
const { width: screenWidth, height: screenHeight } = useWindowDimensions()
const insets = useSafeAreaInsets()
const translateX = useSharedValue(0)
const translateY = useSharedValue(0)
const backdropOpacity = useSharedValue(0)
// 计算抽屉尺寸
const drawerHeight =
height === undefined
? screenHeight * 0.6
: typeof height === 'string'
? (parseFloat(height) / 100) * screenHeight
: height
const drawerWidth =
width === undefined
? screenWidth * 0.8
: typeof width === 'string'
? (parseFloat(width) / 100) * screenWidth
: width
// 根据位置计算最大偏移量
const getMaxOffset = () => {
switch (position) {
case 'bottom':
return drawerHeight
case 'top':
return -drawerHeight
case 'left':
return -drawerWidth
case 'right':
return drawerWidth
default:
return drawerHeight
}
}
const maxOffset = getMaxOffset()
const isHorizontal = position === 'left' || position === 'right'
// 关闭抽屉
const close = useCallback(() => {
translateX.value = 0
translateY.value = 0
backdropOpacity.value = withTiming(0, { duration: 200 })
onClose()
}, [onClose])
useImperativeHandle(ref, () => ({
close,
}), [close])
// 处理返回键(仅 Android
useEffect(() => {
if (!visible || Platform.OS !== 'android') return
const backHandler = BackHandler.addEventListener('hardwareBackPress', () => {
close()
return true
})
return () => backHandler.remove()
}, [visible, close])
// 显示/隐藏动画
useEffect(() => {
if (visible) {
backdropOpacity.value = withTiming(1, { duration: 200 })
translateX.value = 0
translateY.value = 0
} else {
backdropOpacity.value = withTiming(0, { duration: 200 })
translateX.value = 0
translateY.value = 0
}
}, [visible])
// 手势处理
const pan = Gesture.Pan()
.enabled(enableGesture && visible)
.onUpdate((event) => {
if (isHorizontal) {
const newTranslateX = event.translationX
// 根据位置限制拖拽方向
if (position === 'left' && newTranslateX > 0) {
translateX.value = newTranslateX
} else if (position === 'right' && newTranslateX < 0) {
translateX.value = newTranslateX
} else if (position === 'left' || position === 'right') {
translateX.value = newTranslateX
}
} else {
const newTranslateY = event.translationY
// 根据位置限制拖拽方向
if (position === 'bottom' && newTranslateY > 0) {
translateY.value = newTranslateY
} else if (position === 'top' && newTranslateY < 0) {
translateY.value = newTranslateY
} else if (position === 'bottom' || position === 'top') {
translateY.value = newTranslateY
}
}
// 更新遮罩层透明度
const currentOffset = isHorizontal ? translateX.value : translateY.value
const opacity = interpolate(
Math.abs(currentOffset),
[0, Math.abs(maxOffset)],
[1, 0],
Extrapolation.CLAMP,
)
backdropOpacity.value = opacity
})
.onEnd((event) => {
const velocity = isHorizontal ? event.velocityX : event.velocityY
const translation = isHorizontal ? translateX.value : translateY.value
// 判断是否应该关闭
const shouldClose =
Math.abs(velocity) > swipeVelocityThreshold ||
Math.abs(translation) > Math.abs(maxOffset) * 0.5
if (shouldClose) {
// 关闭动画
if (isHorizontal) {
translateX.value = withSpring(
maxOffset,
{
velocity: event.velocityX,
damping: 20,
stiffness: 90,
},
() => {
runOnJS(close)()
},
)
} else {
translateY.value = withSpring(
maxOffset,
{
velocity: event.velocityY,
damping: 20,
stiffness: 90,
},
() => {
runOnJS(close)()
},
)
}
} else {
// 回弹动画
if (isHorizontal) {
translateX.value = withSpring(0, {
velocity: event.velocityX,
damping: 20,
stiffness: 90,
})
} else {
translateY.value = withSpring(0, {
velocity: event.velocityY,
damping: 20,
stiffness: 90,
})
}
backdropOpacity.value = withTiming(1, { duration: 200 })
}
})
// 计算安全区域样式
const safeAreaStyle = useMemo(() => {
const safeStyle: any = {}
if (Platform.OS === 'ios') {
switch (position) {
case 'bottom':
safeStyle.paddingBottom = insets.bottom
break
case 'top':
safeStyle.paddingTop = insets.top
break
case 'left':
case 'right':
safeStyle.paddingTop = insets.top
safeStyle.paddingBottom = insets.bottom
break
}
}
return safeStyle
}, [position, insets])
// 抽屉内容动画样式
const drawerAnimatedStyle = useAnimatedStyle(() => {
const baseStyle: any = {
overflow: position === 'bottom' ? 'visible' : 'hidden',
}
switch (position) {
case 'bottom':
baseStyle.transform = [{ translateY: translateY.value }]
baseStyle.bottom = 0
baseStyle.left = 0
baseStyle.right = 0
baseStyle.height = drawerHeight
baseStyle.borderTopLeftRadius = 20
baseStyle.borderTopRightRadius = 20
break
case 'top':
baseStyle.transform = [{ translateY: translateY.value }]
baseStyle.top = 0
baseStyle.left = 0
baseStyle.right = 0
baseStyle.height = drawerHeight
baseStyle.borderBottomLeftRadius = 20
baseStyle.borderBottomRightRadius = 20
break
case 'left':
baseStyle.transform = [{ translateX: translateX.value }]
baseStyle.left = 0
baseStyle.top = 0
baseStyle.bottom = 0
baseStyle.width = drawerWidth
baseStyle.borderTopRightRadius = 20
baseStyle.borderBottomRightRadius = 20
break
case 'right':
baseStyle.transform = [{ translateX: translateX.value }]
baseStyle.right = 0
baseStyle.top = 0
baseStyle.bottom = 0
baseStyle.width = drawerWidth
baseStyle.borderTopLeftRadius = 20
baseStyle.borderBottomLeftRadius = 20
break
}
return baseStyle
}, [position, drawerHeight, drawerWidth])
// 遮罩层动画样式
const backdropAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: backdropOpacity.value,
}
})
if (!visible) {
return null
}
return (
<Modal
transparent
visible={visible}
animationType="none"
onRequestClose={close}
statusBarTranslucent={Platform.OS === 'android'}
>
<View style={styles.container}>
{/* 遮罩层 */}
{showBackdrop && (
<AnimatedPressable
style={[
styles.backdrop,
backdropAnimatedStyle,
{ backgroundColor: backdropColor },
backdropStyle,
]}
onPress={closeOnBackdropPress ? close : undefined}
/>
)}
{/* 抽屉内容 */}
<GestureDetector gesture={pan}>
<Animated.View
style={[
styles.drawer,
drawerAnimatedStyle,
getPositionStyles(position),
safeAreaStyle,
style,
]}
>
{showCloseButton && (
<Pressable
style={styles.closeButton}
onPress={close}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<CloseIcon />
</Pressable>
)}
{children}
</Animated.View>
</GestureDetector>
</View>
</Modal>
)
},
)
Drawer.displayName = 'Drawer'
// 根据位置获取样式(圆角已在动画样式中设置)
const getPositionStyles = (position: DrawerPosition) => {
return {}
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
backdrop: {
...StyleSheet.absoluteFillObject,
},
drawer: {
position: 'absolute',
backgroundColor: '#1C1E20',
},
closeButton: {
position: 'absolute',
top: Platform.select({ ios: 16, android: 16, default: 16 }),
right: Platform.select({ ios: 16, android: 16, default: 16 }),
zIndex: 10,
width: 24,
height: 24,
alignItems: 'center',
justifyContent: 'center',
},
})
export default Drawer

402
components/ui/dropdown.tsx Normal file
View File

@@ -0,0 +1,402 @@
import { useState, useRef, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
Modal,
FlatList,
TouchableWithoutFeedback,
LayoutChangeEvent,
Platform,
useWindowDimensions,
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { DownArrowIcon } from '@/components/icon'
export interface DropdownOption<T = string> {
label: string
value: T
disabled?: boolean
}
export interface DropdownProps<T = string> {
/**
* 选项列表
*/
options: DropdownOption<T>[]
/**
* 当前选中的值
*/
value?: T
/**
* 选择回调
*/
onSelect?: (value: T, option: DropdownOption<T>) => void
/**
* 占位符文本
*/
placeholder?: string
/**
* 是否禁用
*/
disabled?: boolean
/**
* 自定义触发按钮渲染
*/
renderTrigger?: (selectedOption: DropdownOption<T> | undefined, isOpen: boolean, toggle: () => void) => React.ReactNode
/**
* 下拉列表的最大高度
*/
maxHeight?: number
/**
* 下拉菜单距离触发按钮的垂直偏移默认4px
*/
offsetTop?: number
/**
* 自定义样式
*/
style?: any
/**
* 触发按钮样式
*/
triggerStyle?: any
/**
* 下拉列表样式
*/
dropdownStyle?: any
/**
* 选项项样式
*/
optionStyle?: any
/**
* 选项文本样式
*/
optionTextStyle?: any
/**
* 自定义选项渲染函数
*/
renderOption?: (option: DropdownOption<T>, isSelected: boolean) => React.ReactNode
}
export default function Dropdown<T = string>({
options,
value,
onSelect,
placeholder = '请选择',
disabled = false,
renderTrigger,
maxHeight = 200,
offsetTop = 4,
style,
triggerStyle,
dropdownStyle,
optionStyle,
optionTextStyle,
renderOption,
}: DropdownProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const [triggerLayout, setTriggerLayout] = useState({ x: 0, y: 0, width: 0, height: 0 })
const triggerRef = useRef<View>(null)
const layoutRef = useRef({ width: 0, height: 0 })
const { width: screenWidth } = useWindowDimensions()
const insets = useSafeAreaInsets()
// 获取当前选中的选项
const selectedOption = options.find(option => option.value === value)
// 测量触发按钮的位置(跨平台兼容)
const measureTrigger = () => {
if (!triggerRef.current) return
if (Platform.OS === 'android') {
// Android 上使用 measure 方法,它返回相对于父组件的坐标
triggerRef.current.measure((_x, _y, width, height, pageX, pageY) => {
const finalWidth = width || layoutRef.current.width || 200
const finalHeight = height || layoutRef.current.height || 40
// pageY 在 Android 上已经包含了状态栏高度,所以直接使用
setTriggerLayout({
x: pageX,
y: pageY,
width: finalWidth,
height: finalHeight
})
})
} else {
// iOS 和 Web 使用 measureInWindow
triggerRef.current.measureInWindow((x, y, width, height) => {
const finalWidth = width || layoutRef.current.width || 200
const finalHeight = height || layoutRef.current.height || 40
if (Platform.OS === 'web' && (width === 0 || height === 0)) {
// Web 平台使用 measure 作为后备
triggerRef.current?.measure((_x, _y, w, h, pageX, pageY) => {
setTriggerLayout({
x: pageX || x,
y: pageY || y,
width: w || finalWidth,
height: h || finalHeight
})
})
} else {
setTriggerLayout({
x,
y,
width: finalWidth,
height: finalHeight
})
}
})
}
}
// 测量触发按钮的位置
const handleTriggerLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout
layoutRef.current = { width, height }
// 延迟测量以确保布局已完成
setTimeout(() => {
measureTrigger()
}, 0)
}
// 处理选项选择
const handleSelect = (option: DropdownOption<T>) => {
if (option.disabled) return
onSelect?.(option.value, option)
setIsOpen(false)
}
// 切换下拉菜单显示状态
const toggleDropdown = () => {
if (disabled) return
// 在打开时重新测量位置
if (!isOpen) {
// 使用 setTimeout 确保在打开前完成测量
// Android 上需要稍微延迟以确保测量准确
const delay = Platform.OS === 'android' ? 50 : 0
setTimeout(() => {
measureTrigger()
}, delay)
}
setIsOpen(!isOpen)
}
// 当打开时,重新测量位置(处理滚动等情况)
useEffect(() => {
if (isOpen) {
// Android 上需要稍微延迟以确保测量准确
const delay = Platform.OS === 'android' ? 50 : 0
setTimeout(() => {
measureTrigger()
}, delay)
}
}, [isOpen])
// 关闭下拉菜单
const closeDropdown = () => {
setIsOpen(false)
}
// 默认触发按钮渲染
const defaultTrigger = (
<Pressable
style={[styles.trigger, triggerStyle, disabled && styles.triggerDisabled]}
onPress={toggleDropdown}
disabled={disabled}
>
<Text style={[styles.triggerText, !selectedOption && styles.triggerTextPlaceholder]}>
{selectedOption ? selectedOption.label : placeholder}
</Text>
<View style={[styles.arrowIcon, isOpen && styles.arrowIconRotated]}>
<DownArrowIcon />
</View>
</Pressable>
)
return (
<View style={[styles.container, style]} ref={triggerRef} onLayout={handleTriggerLayout}>
{renderTrigger ? renderTrigger(selectedOption, isOpen, toggleDropdown) : defaultTrigger}
<Modal
visible={isOpen}
transparent
animationType="fade"
onRequestClose={closeDropdown}
>
<TouchableWithoutFeedback onPress={closeDropdown}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback>
{(() => {
const dropdownWidth = (dropdownStyle as any)?.minWidth || (dropdownStyle as any)?.width || Math.max(triggerLayout.width, 200)
const hasRightAlignment = (dropdownStyle as any)?.right !== undefined
const rightValue = (dropdownStyle as any)?.right
const calculatedLeft = hasRightAlignment
? screenWidth - (rightValue || 0) - dropdownWidth
: triggerLayout.x
// 从 dropdownStyle 中移除 right避免样式冲突
const { right, ...restDropdownStyle } = dropdownStyle || {}
return (
<View
style={[
styles.dropdown,
{
top: triggerLayout.y + triggerLayout.height + offsetTop,
left: calculatedLeft,
width: dropdownWidth,
maxHeight,
},
restDropdownStyle,
]}
>
<FlatList
data={options}
keyExtractor={(item, index) => `option-${index}-${String(item.value)}`}
renderItem={({ item }) => {
const isSelected = item.value === value
if (renderOption) {
return (
<Pressable
style={[
styles.option,
isSelected && styles.optionSelected,
item.disabled && styles.optionDisabled,
optionStyle,
]}
onPress={() => handleSelect(item)}
disabled={item.disabled}
>
{renderOption(item, isSelected)}
</Pressable>
)
}
return (
<Pressable
style={[
styles.option,
isSelected && styles.optionSelected,
item.disabled && styles.optionDisabled,
optionStyle,
]}
onPress={() => handleSelect(item)}
disabled={item.disabled}
>
<Text
style={[
styles.optionText,
isSelected && styles.optionTextSelected,
item.disabled && styles.optionTextDisabled,
optionTextStyle,
]}
>
{item.label}
</Text>
</Pressable>
)
}}
showsVerticalScrollIndicator={false}
/>
</View>
)
})()}
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
</View>
)
}
const styles = StyleSheet.create({
container: {
position: 'relative',
},
trigger: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
backgroundColor: '#272A30',
minHeight: 40,
},
triggerDisabled: {
opacity: 0.5,
},
triggerText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
flex: 1,
},
triggerTextPlaceholder: {
color: '#ABABAB',
},
arrowIcon: {
marginLeft: 8,
transform: [{ rotate: '0deg' }],
},
arrowIconRotated: {
transform: [{ rotate: '180deg' }],
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
dropdown: {
position: 'absolute',
backgroundColor: '#272A30',
borderRadius: 12,
overflow: 'hidden',
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
},
android: {
elevation: 8,
},
web: {
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 8,
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.3)',
},
}),
},
option: {
paddingHorizontal: 16,
paddingVertical: 12,
},
optionSelected: {
backgroundColor: '#3A3D42',
},
optionDisabled: {
opacity: 0.5,
},
optionText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '400',
},
optionTextSelected: {
color: '#FFCF00',
fontWeight: '500',
},
optionTextDisabled: {
color: '#ABABAB',
},
})

8
components/ui/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { default as Block } from './Block'
export { default as Text } from './Text'
export { default as Img } from './Img'
export { default as ModalPortal } from './ModalPortal'
export { default as Toast } from './Toast'
export { default as StartGeneratingNotification } from './notifications/StartGeneratingNotification'
export { default as Dropdown } from './dropdown'
export type { DropdownOption, DropdownProps } from './dropdown'

66
components/ui/media.tsx Normal file
View File

@@ -0,0 +1,66 @@
import { forwardRef, useMemo } from "react";
import { VideoView } from "expo-video";
import Image from "./Img";
import { VideoPlayer, type VideoPlayerProps } from "./video";
import { cn } from "@/lib/utils";
type MediaType = "image" | "video";
interface MediaProps extends Omit<VideoPlayerProps, "source"> {
source: string | { uri: string };
type?: MediaType;
poster?: string;
visible?: boolean;
}
const VIDEO_EXTENSIONS = /\.(mp4|mov|avi|mkv|webm|m4v|flv|wmv|3gp)$/i;
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|bmp|svg)$/i;
const detectMediaType = (source: string | { uri: string }): MediaType => {
const uri = typeof source === "string" ? source : source.uri;
if (VIDEO_EXTENSIONS.test(uri)) {
return "video";
}
if (IMAGE_EXTENSIONS.test(uri)) {
return "image";
}
return "image";
};
const Media = forwardRef<VideoView, MediaProps>(
({ source, type, poster, visible = true, className, loop = true, ...props }, ref) => {
const mediaType = useMemo(
() => type || detectMediaType(source),
[source, type]
);
if (mediaType === "video") {
return (
<VideoPlayer
ref={ref}
source={source}
poster={poster}
visible={visible}
className={cn('w-full h-full', className)}
autoPlay
loop={loop}
{...props}
/>
);
}
return (
<Image
source={source}
className={cn('w-full h-full', className)}
contentFit="cover"
/>
);
}
);
Media.displayName = "Media";
export { Media, type MediaProps, type MediaType };

View File

@@ -0,0 +1,142 @@
import React from 'react'
import { ViewStyle, Pressable, StyleSheet, View, Text, Platform } from 'react-native'
import { Image } from 'expo-image'
import { BlurView } from 'expo-blur'
import { RightArrowIcon } from '@/components/icon'
export interface NotificationProps {
/**
* 未读数量
*/
count?: number
/**
* 是否显示通知
*/
visible?: boolean
/**
* 点击事件
*/
onPress?: () => void
/**
* 自定义样式
*/
style?: ViewStyle
/**
* 最大显示数量,超过显示 99+
*/
maxCount?: number
/**
* 通知标题
*/
title?: string
/**
* 通知内容
*/
message?: string
}
/**
* 通知组件
* 基于 Figma 设计实现的通知横幅组件
* 尺寸: 359x60
*/
const Notification: React.FC<NotificationProps> = ({
count = 0,
visible = true,
onPress,
style,
title,
message,
}) => {
const shouldShow = visible && count > 0
if (!shouldShow) {
return null
}
return (
<Pressable
onPress={onPress}
style={[styles.container, style]}
disabled={!onPress}
>
<BlurView
intensity={Platform.OS === 'ios' ? 50 : 50}
tint={Platform.OS === 'ios' ? 'dark' : 'default'}
style={styles.notificationBanner}
>
<View style={styles.notificationBannerIcon}>
<Image
source={require('@/assets/images/generate.png')}
style={styles.iconImage}
contentFit="contain"
/>
</View>
<View style={styles.contentWrapper}>
{title && (
<Text style={styles.title}>
{title}
</Text>
)}
{message && (
<Text style={styles.message} numberOfLines={1}>
{message}
</Text>
)}
</View>
<View style={styles.badgeContainer}>
<RightArrowIcon/>
</View>
</BlurView>
</Pressable>
)
}
const styles = StyleSheet.create({
container: {
width: '100%',
},
notificationBanner: {
height: 60,
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: 12,
backgroundColor: Platform.OS === 'ios' ? '#FFFFFF1A' : '#FFFFFF1A',
overflow: 'hidden',
paddingHorizontal: 16,
},
notificationBannerIcon: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
iconImage: {
width: 32,
height: 32,
},
contentWrapper: {
flex: 1,
},
title: {
marginBottom: 2,
fontSize: 14,
fontWeight: '600',
color: '#F5F5F5',
},
message: {
fontSize: 12,
color: '#CCCCCC',
},
badgeContainer: {
marginLeft: 12,
minWidth: 20,
alignItems: 'center',
justifyContent: 'center',
},
})
export default Notification

239
components/ui/tooltip.tsx Normal file
View File

@@ -0,0 +1,239 @@
import * as React from "react";
import {
View,
Text,
Pressable,
Modal,
type ViewStyle,
type TextStyle,
LayoutChangeEvent,
StyleSheet,
} from "react-native";
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from "react-native-reanimated";
import { cn } from "../../lib/utils";
type Side = "top" | "bottom" | "left" | "right";
interface TooltipContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.MutableRefObject<View | null>;
side: Side;
sideOffset: number;
}
const TooltipContext = React.createContext<TooltipContextValue | null>(null);
const useTooltipContext = () => {
const context = React.useContext(TooltipContext);
if (!context) {
throw new Error("Tooltip components must be used within TooltipProvider");
}
return context;
};
interface TooltipProviderProps {
children: React.ReactNode;
delayDuration?: number;
}
const TooltipProvider: React.FC<TooltipProviderProps> = ({
children,
delayDuration = 200,
}) => {
return <>{children}</>;
};
interface TooltipProps {
children: React.ReactNode;
side?: Side;
sideOffset?: number;
}
const Tooltip: React.FC<TooltipProps> = ({
children,
side = "top",
sideOffset = 8,
}) => {
const [open, setOpen] = React.useState(false);
const triggerRef = React.useRef<View | null>(null);
const value: TooltipContextValue = {
open,
setOpen,
triggerRef,
side,
sideOffset,
};
return (
<TooltipContext.Provider value={value}>{children}</TooltipContext.Provider>
);
};
interface TooltipTriggerProps {
children: React.ReactNode;
asChild?: boolean;
style?: ViewStyle;
}
const TooltipTrigger = React.forwardRef<View, TooltipTriggerProps>(
({ children, asChild, style }, ref) => {
const { setOpen, triggerRef } = useTooltipContext();
const handleRef = (node: View | null) => {
triggerRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
};
if (asChild && React.isValidElement(children)) {
return React.cloneElement(children as React.ReactElement<any>, {
ref: handleRef,
onPressIn: () => setOpen(true),
onPressOut: () => setOpen(false),
});
}
return (
<Pressable
ref={handleRef as any}
onPressIn={() => setOpen(true)}
onPressOut={() => setOpen(false)}
style={style}
>
{children}
</Pressable>
);
}
);
TooltipTrigger.displayName = "TooltipTrigger";
interface TooltipContentProps {
children: React.ReactNode;
className?: string;
style?: ViewStyle;
textStyle?: TextStyle;
}
const TooltipContent = React.forwardRef<View, TooltipContentProps>(
({ children, className, style, textStyle }, ref) => {
const { open, triggerRef, side, sideOffset } = useTooltipContext();
const [layout, setLayout] = React.useState<{
width: number;
height: number;
} | null>(null);
const [triggerLayout, setTriggerLayout] = React.useState<{
x: number;
y: number;
width: number;
height: number;
} | null>(null);
const opacity = useSharedValue(0);
const scale = useSharedValue(0.95);
React.useEffect(() => {
if (open && triggerRef.current) {
triggerRef.current.measure((x, y, width, height, pageX, pageY) => {
setTriggerLayout({ x: pageX, y: pageY, width, height });
});
opacity.value = withTiming(1, { duration: 200 });
scale.value = withSpring(1, { damping: 15, stiffness: 300 });
} else {
opacity.value = withTiming(0, { duration: 150 });
scale.value = withTiming(0.95, { duration: 150 });
}
}, [open]);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }],
}));
const handleLayout = (event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setLayout({ width, height });
};
const getPosition = (): ViewStyle => {
if (!triggerLayout || !layout) return {};
let top = 0;
let left = 0;
switch (side) {
case "top":
top = triggerLayout.y - layout.height - sideOffset;
left = triggerLayout.x + triggerLayout.width / 2 - layout.width / 2;
break;
case "bottom":
top = triggerLayout.y + triggerLayout.height + sideOffset;
left = triggerLayout.x + triggerLayout.width / 2 - layout.width / 2;
break;
case "left":
top = triggerLayout.y + triggerLayout.height / 2 - layout.height / 2;
left = triggerLayout.x - layout.width - sideOffset;
break;
case "right":
top = triggerLayout.y + triggerLayout.height / 2 - layout.height / 2;
left = triggerLayout.x + triggerLayout.width + sideOffset;
break;
}
return { position: "absolute", top, left };
};
if (!open) return null;
return (
<Modal transparent visible={open} animationType="none">
<Animated.View
ref={ref as any}
onLayout={handleLayout}
style={[
styles.tooltip,
getPosition(),
animatedStyle,
style,
]}
className={cn(
"rounded-md border border-border bg-popover px-3 py-1.5 shadow-lg",
className
)}
>
{typeof children === "string" ? (
<Text
className="text-sm text-popover-foreground"
style={textStyle}
>
{children}
</Text>
) : (
children
)}
</Animated.View>
</Modal>
);
}
);
TooltipContent.displayName = "TooltipContent";
const styles = StyleSheet.create({
tooltip: {
zIndex: 9999,
},
});
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

136
components/ui/video.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { useVideoPlayer, VideoView } from "expo-video";
import { forwardRef, useEffect, useState } from "react";
import { View, Pressable, Text, ActivityIndicator } from "react-native";
import { cn } from "../../lib/utils";
import Image from "./Img";
interface VideoPlayerProps {
source: string | { uri: string };
poster?: string;
visible?: boolean;
className?: string;
autoPlay?: boolean;
loop?: boolean;
muted?: boolean;
controls?: boolean;
onError?: (error: string) => void;
onLoad?: () => void;
}
const VideoPlayer = forwardRef<VideoView, VideoPlayerProps>(
(
{
source,
poster,
visible = true,
className,
autoPlay = false,
loop = false,
muted = false,
controls = true,
onError,
onLoad,
},
ref
) => {
const videoSource = typeof source === "string" ? { uri: source } : source;
const player = useVideoPlayer(videoSource, (player) => {
player.loop = loop;
player.muted = muted;
});
const [isPlaying, setIsPlaying] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [showPoster, setShowPoster] = useState(!!poster);
useEffect(() => {
if (!visible) {
player.pause();
if (poster) {
setShowPoster(true);
}
return;
}
if (autoPlay && visible) {
player.play();
}
}, [visible, autoPlay, player, poster]);
useEffect(() => {
const playingSubscription = player.addListener("playingChange", ({ isPlaying }) => {
setIsPlaying(isPlaying);
if (isPlaying) {
setShowPoster(false);
}
});
const statusSubscription = player.addListener("statusChange", (payload) => {
if (payload.status === "readyToPlay") {
setIsLoading(false);
onLoad?.();
}
if (payload.status === "error" && payload.error) {
onError?.(payload.error.message);
}
});
return () => {
playingSubscription.remove();
statusSubscription.remove();
};
}, [player, onError, onLoad]);
const togglePlayback = () => {
if (isPlaying) {
player.pause();
} else {
player.play();
setShowPoster(false);
}
};
return (
<View className={cn("!relative !bg-black !overflow-hidden", className)}>
{visible && (
<VideoView
ref={ref}
player={player}
className="!absolute !top-0 !left-0 !bottom-0 !right-0 !inset-0 !w-full !h-full"
style={{ width: '100%', height: '100%', position: 'absolute', left: 0, top: 0 }}
nativeControls={false}
contentFit="cover"
/>
)}
{showPoster && poster && (
<Image
source={poster}
className="!absolute !top-0 !left-0 !bottom-0 !right-0 !inset-0 !w-full !h-full"
contentFit="cover"
/>
)}
{controls && !isLoading && visible && (
<Pressable
onPress={togglePlayback}
className="absolute inset-0 items-center justify-center active:opacity-80"
>
{(!isPlaying || showPoster) && (
<View className="w-16 h-16 items-center justify-center bg-black/50 rounded-full">
<Text className="text-white text-2xl"></Text>
</View>
)}
</Pressable>
)}
</View>
);
}
);
VideoPlayer.displayName = "VideoPlayer";
export { VideoPlayer, type VideoPlayerProps };