Initial commit: expo-popcore-app
This commit is contained in:
75
components/GradientText.example.tsx
Normal file
75
components/GradientText.example.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import GradientText from './GradientText'
|
||||
|
||||
/**
|
||||
* 渐变文字使用示例
|
||||
*/
|
||||
export default function GradientTextExample() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 水平渐变 */}
|
||||
<GradientText
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.text}
|
||||
>
|
||||
水平渐变文字
|
||||
</GradientText>
|
||||
|
||||
{/* 垂直渐变 */}
|
||||
<GradientText
|
||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 0, y: 1 }}
|
||||
style={styles.text}
|
||||
>
|
||||
垂直渐变文字
|
||||
</GradientText>
|
||||
|
||||
{/* 对角线渐变 */}
|
||||
<GradientText
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.largeText}
|
||||
>
|
||||
对角线渐变
|
||||
</GradientText>
|
||||
|
||||
{/* 两色渐变 */}
|
||||
<GradientText
|
||||
colors={['#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.boldText}
|
||||
>
|
||||
两色渐变
|
||||
</GradientText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
gap: 30,
|
||||
},
|
||||
text: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
largeText: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
boldText: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
})
|
||||
|
||||
140
components/GradientText.tsx
Normal file
140
components/GradientText.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { TextProps, StyleSheet, TextStyle, View, Text, ViewStyle } from 'react-native'
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Stop, Text as SvgText } from 'react-native-svg'
|
||||
|
||||
interface GradientTextProps extends Omit<TextProps, 'style'> {
|
||||
colors: [string, string, ...string[]]
|
||||
start?: { x: number; y: number }
|
||||
end?: { x: number; y: number }
|
||||
style?: TextStyle
|
||||
}
|
||||
|
||||
/**
|
||||
* 渐变文字组件(使用 SVG 实现,无需原生模块)
|
||||
*
|
||||
* @example
|
||||
* <GradientText
|
||||
* colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
* start={{ x: 0, y: 0 }}
|
||||
* end={{ x: 1, y: 0 }}
|
||||
* style={{ fontSize: 24, fontWeight: 'bold' }}
|
||||
* >
|
||||
* 渐变文字
|
||||
* </GradientText>
|
||||
*/
|
||||
export default function GradientText({
|
||||
colors,
|
||||
start = { x: 0, y: 0 },
|
||||
end = { x: 1, y: 0 },
|
||||
style,
|
||||
children,
|
||||
...textProps
|
||||
}: GradientTextProps) {
|
||||
const gradientId = useMemo(() => `gradient-${Math.random().toString(36).substr(2, 9)}`, [])
|
||||
|
||||
// 从 style 中提取字体相关属性
|
||||
const fontSize = (style?.fontSize as number) || 14
|
||||
const fontWeight = style?.fontWeight || 'normal'
|
||||
const fontFamily = style?.fontFamily
|
||||
const textAlign = style?.textAlign || 'left'
|
||||
|
||||
// 使用一个隐藏的 Text 来测量文字尺寸
|
||||
const [textWidth, setTextWidth] = React.useState(0)
|
||||
const [textHeight, setTextHeight] = React.useState(fontSize * 1.2)
|
||||
|
||||
// 计算渐变坐标(转换为百分比)
|
||||
const x1 = `${start.x * 100}%`
|
||||
const y1 = `${start.y * 100}%`
|
||||
const x2 = `${end.x * 100}%`
|
||||
const y2 = `${end.y * 100}%`
|
||||
|
||||
// 计算文字位置
|
||||
const textX = textAlign === 'center' ? (textWidth / 2) : textAlign === 'right' ? textWidth : 0
|
||||
const textY = textHeight * 0.75 // 垂直位置,稍微调整以匹配基线
|
||||
|
||||
// 从 style 中提取 ViewStyle 兼容的属性
|
||||
const containerStyle: ViewStyle = {
|
||||
...(style?.margin !== undefined && { margin: style.margin }),
|
||||
...(style?.marginTop !== undefined && { marginTop: style.marginTop }),
|
||||
...(style?.marginBottom !== undefined && { marginBottom: style.marginBottom }),
|
||||
...(style?.marginLeft !== undefined && { marginLeft: style.marginLeft }),
|
||||
...(style?.marginRight !== undefined && { marginRight: style.marginRight }),
|
||||
...(style?.marginHorizontal !== undefined && { marginHorizontal: style.marginHorizontal }),
|
||||
...(style?.marginVertical !== undefined && { marginVertical: style.marginVertical }),
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{/* 隐藏的 Text 用于测量尺寸和占据空间 */}
|
||||
<Text
|
||||
style={[
|
||||
styles.measureText,
|
||||
{
|
||||
fontSize,
|
||||
fontWeight,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
},
|
||||
]}
|
||||
onLayout={(e) => {
|
||||
const { width, height } = e.nativeEvent.layout
|
||||
setTextWidth(width || 0)
|
||||
setTextHeight(Math.max(height || fontSize * 1.2, fontSize * 1.2))
|
||||
}}
|
||||
>
|
||||
{String(children)}
|
||||
</Text>
|
||||
|
||||
{/* SVG 渐变文字 */}
|
||||
{textWidth > 0 && textHeight > 0 && (
|
||||
<Svg
|
||||
width={textWidth}
|
||||
height={textHeight}
|
||||
style={styles.svgOverlay}
|
||||
>
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1={x1} y1={y1} x2={x2} y2={y2}>
|
||||
{colors.map((color, index) => (
|
||||
<Stop
|
||||
key={index}
|
||||
offset={`${(index / (colors.length - 1)) * 100}%`}
|
||||
stopColor={color}
|
||||
/>
|
||||
))}
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<SvgText
|
||||
x={textX}
|
||||
y={textY}
|
||||
fontSize={fontSize}
|
||||
fontWeight={fontWeight}
|
||||
fontFamily={fontFamily}
|
||||
textAnchor={textAlign === 'center' ? 'middle' : textAlign === 'right' ? 'end' : 'start'}
|
||||
fill={`url(#${gradientId})`}
|
||||
>
|
||||
{String(children)}
|
||||
</SvgText>
|
||||
</Svg>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: 'transparent',
|
||||
position: 'relative',
|
||||
},
|
||||
measureText: {
|
||||
opacity: 0,
|
||||
includeFontPadding: false,
|
||||
// 确保文本占据空间
|
||||
minHeight: 1,
|
||||
},
|
||||
svgOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
184
components/SearchBar.tsx
Normal file
184
components/SearchBar.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LeftArrowIcon, CloseIcon } from '@/components/icon'
|
||||
|
||||
interface SearchBarProps {
|
||||
searchText: string
|
||||
onSearchTextChange: (text: string) => void
|
||||
onSearch: (text: string) => void
|
||||
onBack: () => void
|
||||
placeholder?: string
|
||||
autoFocus?: boolean
|
||||
inputRef?: React.RefObject<TextInput | null>
|
||||
showBackButton?: boolean
|
||||
showSearchButton?: boolean
|
||||
readOnly?: boolean
|
||||
onInputPress?: () => void
|
||||
onClearPress?: () => void
|
||||
marginBottom?: number
|
||||
}
|
||||
|
||||
export default function SearchBar({
|
||||
searchText,
|
||||
onSearchTextChange,
|
||||
onSearch,
|
||||
onBack,
|
||||
placeholder,
|
||||
autoFocus = false,
|
||||
inputRef: externalInputRef,
|
||||
showBackButton = true,
|
||||
showSearchButton = true,
|
||||
readOnly = false,
|
||||
onInputPress,
|
||||
onClearPress,
|
||||
marginBottom = 20,
|
||||
}: SearchBarProps) {
|
||||
const { t } = useTranslation()
|
||||
const internalInputRef = useRef<TextInput>(null)
|
||||
const inputRef = externalInputRef || internalInputRef
|
||||
const defaultPlaceholder = placeholder || t('searchBar.placeholder')
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus) {
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [autoFocus, inputRef])
|
||||
|
||||
const handleClear = () => {
|
||||
if (onClearPress) {
|
||||
onClearPress()
|
||||
} else {
|
||||
onSearchTextChange('')
|
||||
}
|
||||
}
|
||||
|
||||
const inputContainer = (
|
||||
<>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.searchInput}
|
||||
value={searchText}
|
||||
onChangeText={readOnly ? undefined : onSearchTextChange}
|
||||
placeholder={defaultPlaceholder}
|
||||
placeholderTextColor="#ABABAB"
|
||||
underlineColorAndroid="transparent"
|
||||
selectionColor="#F5F5F5"
|
||||
autoCorrect={false}
|
||||
autoCapitalize="none"
|
||||
editable={!readOnly}
|
||||
pointerEvents={readOnly ? 'none' : 'auto'}
|
||||
onSubmitEditing={() => {
|
||||
if (searchText.trim()) {
|
||||
onSearch(searchText.trim())
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<Pressable
|
||||
onPress={(e) => {
|
||||
if (readOnly && onInputPress) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
handleClear()
|
||||
}}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={[styles.topBar, { marginBottom }]}>
|
||||
{showBackButton && (
|
||||
<Pressable onPress={onBack}>
|
||||
<LeftArrowIcon />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{readOnly && onInputPress ? (
|
||||
<Pressable
|
||||
style={styles.searchInputContainer}
|
||||
onPress={onInputPress}
|
||||
>
|
||||
{inputContainer}
|
||||
</Pressable>
|
||||
) : (
|
||||
<View style={styles.searchInputContainer}>
|
||||
{inputContainer}
|
||||
</View>
|
||||
)}
|
||||
{showSearchButton && (
|
||||
<Pressable
|
||||
style={styles.searchButton}
|
||||
onPress={() => {
|
||||
if (searchText.trim()) {
|
||||
onSearch(searchText.trim())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.searchButtonText}>{t('searchBar.button')}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
topBar: {
|
||||
height: 44,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
marginTop: 8,
|
||||
marginBottom: 20,
|
||||
},
|
||||
searchInputContainer: {
|
||||
flex: 1,
|
||||
marginLeft: 4,
|
||||
marginRight: 12,
|
||||
position: 'relative',
|
||||
height: 40,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
height: 40,
|
||||
paddingVertical: 8,
|
||||
paddingLeft: 12,
|
||||
paddingRight: 36,
|
||||
borderRadius: 100,
|
||||
backgroundColor: '#16181B',
|
||||
},
|
||||
clearButton: {
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 4,
|
||||
},
|
||||
searchButton: {
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
searchButtonText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
},
|
||||
})
|
||||
|
||||
186
components/SearchResultsGrid.tsx
Normal file
186
components/SearchResultsGrid.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { WhiteStarIcon } from '@/components/icon'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
interface SearchResultItem {
|
||||
id: number
|
||||
title: string
|
||||
image: any
|
||||
height: number
|
||||
videoUrl?: any
|
||||
thumbnailUrl?: any
|
||||
duration?: string
|
||||
}
|
||||
|
||||
interface SearchResultsGridProps {
|
||||
results: SearchResultItem[]
|
||||
}
|
||||
|
||||
export default function SearchResultsGrid({ results }: SearchResultsGridProps) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const [gridWidth, setGridWidth] = useState(screenWidth)
|
||||
|
||||
const horizontalPadding = 8 * 2 // gridContainer 的左右 padding
|
||||
const cardGap = 5 // 两个卡片之间的间距
|
||||
const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2
|
||||
|
||||
const handleSameStylePress = (item: SearchResultItem) => {
|
||||
// 构建模板数据,传递给 generateVideo 页面
|
||||
const templateData = {
|
||||
id: item.id,
|
||||
videoUrl: item.videoUrl || item.image, // 如果没有 videoUrl,使用 image
|
||||
thumbnailUrl: item.thumbnailUrl || item.image, // 如果没有 thumbnailUrl,使用 image
|
||||
title: item.title,
|
||||
duration: item.duration || '00:00', // 默认时长
|
||||
}
|
||||
router.push({
|
||||
pathname: '/generateVideo' as any,
|
||||
params: {
|
||||
template: JSON.stringify(templateData),
|
||||
},
|
||||
} as any)
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>{t('searchResults.noResults')}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View
|
||||
style={styles.gridContainer}
|
||||
onLayout={(event) => {
|
||||
const { width } = event.nativeEvent.layout
|
||||
setGridWidth(width)
|
||||
}}
|
||||
>
|
||||
{results.map((item, index) => (
|
||||
<View
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.card,
|
||||
{ width: cardWidth },
|
||||
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.cardImageContainer,
|
||||
{ height: item.height },
|
||||
]}
|
||||
>
|
||||
<Image
|
||||
source={item.image}
|
||||
style={styles.cardImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.sameStyleButton}
|
||||
onPress={() => handleSameStylePress(item)}
|
||||
>
|
||||
<WhiteStarIcon />
|
||||
<Text style={styles.sameStyleText}>{t('searchResults.makeSame')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
// paddingBottom: 100,
|
||||
},
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 8,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#16181B',
|
||||
borderBottomLeftRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardLeft: {
|
||||
marginRight: 0,
|
||||
},
|
||||
cardRight: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
cardImageContainer: {
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
position: 'relative',
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
sameStyleButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
backgroundColor: '#262A31',
|
||||
height:32,
|
||||
marginHorizontal: 8,
|
||||
marginTop: 10,
|
||||
marginBottom: 8,
|
||||
borderRadius: 100,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sameStyleText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
lineHeight: 17,
|
||||
},
|
||||
cardTitle: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
color: '#8A8A8A',
|
||||
fontSize: 14,
|
||||
},
|
||||
})
|
||||
|
||||
250
components/WorksGallery.tsx
Normal file
250
components/WorksGallery.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
Pressable,
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
const GALLERY_GAP = 1
|
||||
const GALLERY_COLUMNS = 4
|
||||
const GALLERY_ITEM_SIZE = Math.floor(
|
||||
(screenWidth - GALLERY_GAP * (GALLERY_COLUMNS - 1)) / GALLERY_COLUMNS
|
||||
)
|
||||
|
||||
type Category = '全部' | '萌宠' | '写真' | '合拍'
|
||||
|
||||
interface WorkItem {
|
||||
id: number
|
||||
date: Date | string
|
||||
duration: string
|
||||
category: Category
|
||||
}
|
||||
|
||||
interface WorksGalleryProps {
|
||||
categories: Category[]
|
||||
selectedCategory: Category
|
||||
onCategoryChange: (category: Category) => void
|
||||
groupedWorks: Record<string, WorkItem[]>
|
||||
onWorkPress: (id: number) => void
|
||||
}
|
||||
|
||||
export default function WorksGallery({
|
||||
categories,
|
||||
selectedCategory,
|
||||
onCategoryChange,
|
||||
groupedWorks,
|
||||
onWorkPress,
|
||||
}: WorksGalleryProps) {
|
||||
const { i18n } = useTranslation()
|
||||
|
||||
// 格式化日期函数
|
||||
const formatDate = (date: Date | string): string => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
||||
const locale = i18n.language === 'zh-CN' ? 'zh-CN' : 'en-US'
|
||||
|
||||
if (locale === 'zh-CN') {
|
||||
// 中文格式:2025年11月28日
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(dateObj).replace(/\//g, '年').replace(/(\d+)$/, '$1日')
|
||||
} else {
|
||||
// 英文格式:November 28, 2025
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(dateObj)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 分类标签 */}
|
||||
<View style={styles.categoryContainer}>
|
||||
{categories.map((category) => {
|
||||
const isSelected = selectedCategory === category
|
||||
return (
|
||||
<Pressable
|
||||
key={category}
|
||||
style={styles.categoryTagWrapper}
|
||||
onPress={() => onCategoryChange(category)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.categoryTagGradient}
|
||||
>
|
||||
<View style={styles.categoryTag}>
|
||||
<Text
|
||||
style={[
|
||||
styles.categoryTagText,
|
||||
styles.categoryTagTextActive,
|
||||
]}
|
||||
>
|
||||
{category}
|
||||
</Text>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={styles.categoryTag}>
|
||||
<Text style={styles.categoryTagText}>
|
||||
{category}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 作品列表 */}
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{Object.entries(groupedWorks).map(([dateKey, works]) => {
|
||||
// 从第一个作品获取日期对象
|
||||
const dateObj = works[0]?.date
|
||||
const formattedDate = dateObj ? formatDate(dateObj) : dateKey
|
||||
|
||||
return (
|
||||
<View key={dateKey}>
|
||||
<Text style={styles.dateText}>{formattedDate}</Text>
|
||||
<View style={styles.galleryGrid}>
|
||||
{works.map((item, index) => (
|
||||
<Pressable
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.galleryItem,
|
||||
// 每行的前几个 item 有右边距,最后一个没有(4 列)
|
||||
index % GALLERY_COLUMNS !== GALLERY_COLUMNS - 1 &&
|
||||
styles.galleryItemMarginRight,
|
||||
// 所有item都有下边距(最后一行也会有,但影响不大)
|
||||
styles.galleryItemMarginBottom,
|
||||
]}
|
||||
onPress={() => onWorkPress(item.id)}
|
||||
>
|
||||
<Image
|
||||
source={require('@/assets/images/membership.png')}
|
||||
style={styles.galleryImage}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<View style={styles.durationBadge}>
|
||||
<Text style={styles.durationText}>
|
||||
{item.duration}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
categoryContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 14,
|
||||
paddingTop: 16,
|
||||
gap: 8,
|
||||
},
|
||||
categoryTagWrapper: {
|
||||
minWidth: 50,
|
||||
height: 30,
|
||||
},
|
||||
categoryTagGradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 8,
|
||||
padding: 1,
|
||||
},
|
||||
categoryTag: {
|
||||
flex: 1,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#1C1E22',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
categoryTagText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
categoryTagTextActive: {
|
||||
color: '#F5F5F5',
|
||||
fontWeight: '500',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
dateText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 8,
|
||||
marginTop: 16,
|
||||
paddingLeft: 14,
|
||||
},
|
||||
galleryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 8,
|
||||
},
|
||||
galleryItem: {
|
||||
width: GALLERY_ITEM_SIZE,
|
||||
// 使用等比例 1:1,保证容器永远是正方形
|
||||
aspectRatio: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1C1E22',
|
||||
position: 'relative',
|
||||
},
|
||||
galleryItemMarginRight: {
|
||||
marginRight: GALLERY_GAP,
|
||||
},
|
||||
galleryItemMarginBottom: {
|
||||
marginBottom: GALLERY_GAP,
|
||||
},
|
||||
galleryImage: {
|
||||
width: '100%',
|
||||
// 高度由 aspectRatio 决定,避免拉伸
|
||||
height: undefined,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
durationBadge: {
|
||||
position: 'absolute',
|
||||
right: 2,
|
||||
bottom: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#00000080',
|
||||
},
|
||||
durationText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 10,
|
||||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
|
||||
export type { Category, WorkItem }
|
||||
|
||||
142
components/blocks/AuthForm.tsx
Normal file
142
components/blocks/AuthForm.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from "react";
|
||||
import { View, TextInput, ActivityIndicator } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "../ui/button";
|
||||
import Text from "../ui/Text";
|
||||
import { authClient, useSession } from "../../lib/auth";
|
||||
import { Block } from "../ui";
|
||||
|
||||
type AuthMode = "login" | "register";
|
||||
|
||||
type AuthResponse = { data: unknown; error: { message: string } | null };
|
||||
type SignInFn = (params: { username: string; password: string }) => Promise<AuthResponse>;
|
||||
type SignUpFn = (params: { email: string; password: string; name: string }) => Promise<AuthResponse>;
|
||||
|
||||
const signIn = authClient.signIn as unknown as { username: SignInFn };
|
||||
const signUp = authClient.signUp as unknown as { email: SignUpFn };
|
||||
|
||||
interface AuthFormProps {
|
||||
mode?: AuthMode;
|
||||
onSuccess?: () => void;
|
||||
onModeChange?: (mode: AuthMode) => void;
|
||||
}
|
||||
|
||||
export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [currentMode, setCurrentMode] = useState<AuthMode>(mode);
|
||||
const [username, setUsername] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isLogin = currentMode === "login";
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError(t("authForm.fillCompleteInfo"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLogin && !email.trim()) {
|
||||
setError(t("authForm.fillEmail"));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
const res = await signIn.username({ username, password });
|
||||
if (res.error) throw new Error(res.error.message);
|
||||
} else {
|
||||
const res = await signUp.email({ email, password, name: username });
|
||||
if (res.error) throw new Error(res.error.message);
|
||||
}
|
||||
onSuccess?.();
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : (isLogin ? t("authForm.loginFailed") : t("authForm.registerFailed"));
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
const newMode = isLogin ? "register" : "login";
|
||||
setCurrentMode(newMode);
|
||||
onModeChange?.(newMode);
|
||||
setEmail("");
|
||||
setError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Block className="w-full px-6">
|
||||
<Text className="text-2xl font-bold text-center text-white mb-8">
|
||||
{isLogin ? t("authForm.login") : t("authForm.register")}
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
className="w-full h-12 px-4 mb-4 bg-white/10 rounded-lg"
|
||||
style={{ color: '#ffffff' }}
|
||||
placeholder={t("authForm.username")}
|
||||
placeholderTextColor="#999"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
{!isLogin && (
|
||||
<TextInput
|
||||
className="w-full h-12 px-4 mb-4 bg-white/10 rounded-lg"
|
||||
style={{ color: '#ffffff' }}
|
||||
placeholder={t("authForm.email")}
|
||||
placeholderTextColor="#999"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
className="w-full h-12 px-4 mb-4 bg-white/10 rounded-lg"
|
||||
style={{ color: '#ffffff' }}
|
||||
placeholder={t("authForm.password")}
|
||||
placeholderTextColor="#999"
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Text className="text-red-500 text-sm mb-4 text-center">{error}</Text>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="gradient"
|
||||
className="w-full h-16 rounded-lg px-0"
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text className="text-white font-medium">
|
||||
{isLogin ? t("authForm.login") : t("authForm.register")}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Text
|
||||
className="text-gray-400 text-center mt-6"
|
||||
onClick={toggleMode}
|
||||
>
|
||||
{isLogin ? t("authForm.noAccountRegister") : t("authForm.haveAccountLogin")}
|
||||
</Text>
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
||||
export { useSession };
|
||||
76
components/blocks/MediaCarousel.tsx
Normal file
76
components/blocks/MediaCarousel.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { Dimensions, View } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Carousel, CarouselContent, CarouselItem } from '../ui/carousel';
|
||||
import { Media } from '../ui/media';
|
||||
import { useResource } from '@/hooks/use-resource';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
interface MediaCarouselProps {
|
||||
sources: any[];
|
||||
width?: number;
|
||||
height?: number;
|
||||
autoPlay?: boolean;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export function MediaCarousel({
|
||||
sources,
|
||||
width = screenWidth,
|
||||
height = 410,
|
||||
autoPlay = false,
|
||||
loop = true,
|
||||
}: MediaCarouselProps) {
|
||||
const [current, setCurrent] = useState(0);
|
||||
|
||||
return (
|
||||
<View className="!relative !w-full" style={{ height }}>
|
||||
<Carousel
|
||||
className='relative w-full'
|
||||
width={width}
|
||||
height={height}
|
||||
loop={loop}
|
||||
autoPlay={autoPlay}
|
||||
onIndexChange={setCurrent}
|
||||
>
|
||||
<CarouselContent>
|
||||
{sources.map((_source, index) => {
|
||||
const { source, poster } = useResource(_source, { width: screenWidth })
|
||||
return (
|
||||
<CarouselItem key={index} className='!w-full !h-full'>
|
||||
<Media
|
||||
source={source}
|
||||
poster={poster}
|
||||
visible={current === index}
|
||||
loop={true}
|
||||
className='!w-full !h-full'
|
||||
/>
|
||||
</CarouselItem>
|
||||
)
|
||||
})}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
<LinearGradient
|
||||
colors={['rgba(9, 10, 11, 0)', 'rgba(9, 10, 11, 1)']}
|
||||
locations={[0.0964, 1]}
|
||||
className="!absolute !left-0 !right-0 !h-[83px]"
|
||||
style={{ top: 327 }}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
|
||||
<View className="!absolute !flex-row !gap-1" style={{ top: 366, left: 16 }} pointerEvents="none">
|
||||
{sources.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
className={`!h-1 !rounded-sm ${current === index
|
||||
? '!w-[10px] !bg-white'
|
||||
: '!w-1 !bg-white/50'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
2
components/blocks/index.ts
Normal file
2
components/blocks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MediaCarousel } from './MediaCarousel';
|
||||
export { AuthForm, useSession } from './AuthForm';
|
||||
213
components/drawer/AIGenerationRecordDrawer.tsx
Normal file
213
components/drawer/AIGenerationRecordDrawer.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
|
||||
import { CloseIcon } from '@/components/icon'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
type DrawerType = 'ai-record' | 'recent'
|
||||
|
||||
interface AIGenerationRecordDrawerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onSelectImage?: (imageUri: any) => void
|
||||
type?: DrawerType
|
||||
}
|
||||
|
||||
// 模拟 AI 生成记录图片数据
|
||||
const mockAIRecordImages = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
uri: require('@/assets/images/android-icon-background.png'),
|
||||
}))
|
||||
|
||||
// 模拟最近用过的图片数据
|
||||
const mockRecentImages = Array.from({ length: 12 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
uri: require('@/assets/images/membership.png'),
|
||||
}))
|
||||
|
||||
export default function AIGenerationRecordDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
onSelectImage,
|
||||
type = 'ai-record',
|
||||
}: AIGenerationRecordDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
|
||||
const snapPoints = useMemo(() => ['98%'], [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.expand()
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const handleImageSelect = (imageSource: any) => {
|
||||
onSelectImage?.(imageSource)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const title = type === 'ai-record' ? t('aiGenerationRecord.title') : t('aiGenerationRecord.recentUsed')
|
||||
const images = type === 'ai-record' ? mockAIRecordImages : mockRecentImages
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const renderImageItem = ({ item, index }: { item: typeof mockAIRecordImages[0]; index: number }) => {
|
||||
const gap = 2
|
||||
const itemWidth = (screenWidth - gap * 2) / 3
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.imageItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
marginRight: (index + 1) % 3 !== 0 ? gap : 0,
|
||||
marginBottom: gap,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleImageSelect(item.uri)}
|
||||
android_ripple={{ color: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
<Image
|
||||
source={item.uri}
|
||||
style={styles.image}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={visible ? 0 : -1}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.bottomSheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView style={styles.container}>
|
||||
{/* 顶部标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
<Pressable
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 图片网格 */}
|
||||
<FlatList
|
||||
data={images}
|
||||
renderItem={renderImageItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
numColumns={3}
|
||||
contentContainerStyle={styles.imageGrid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
maxToRenderPerBatch={Platform.OS === 'ios' ? 10 : 5}
|
||||
updateCellsBatchingPeriod={Platform.OS === 'ios' ? 50 : 100}
|
||||
initialNumToRender={Platform.OS === 'ios' ? 15 : 10}
|
||||
windowSize={Platform.OS === 'ios' ? 10 : 5}
|
||||
getItemLayout={(data, index) => {
|
||||
const gap = 2
|
||||
const itemWidth = (screenWidth - gap * 2) / 3
|
||||
const rowIndex = Math.floor(index / 3)
|
||||
return {
|
||||
length: itemWidth,
|
||||
offset: rowIndex * (itemWidth + gap),
|
||||
index,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: '#16181B',
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: '#666666',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#16181B',
|
||||
paddingTop: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
title: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
imageGrid: {
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: Platform.OS === 'ios' ? 20 : 16,
|
||||
},
|
||||
imageItem: {
|
||||
// aspectRatio = width / height
|
||||
// 1 : 1.3 (width : height) => 1 / 1.3
|
||||
aspectRatio: 1 / 1.3,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#262A31',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
258
components/drawer/EditProfileDrawer.tsx
Normal file
258
components/drawer/EditProfileDrawer.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
Platform,
|
||||
Keyboard,
|
||||
ScrollView,
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
|
||||
import { CloseIcon, AvatarUploadIcon } from '@/components/icon'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
|
||||
|
||||
interface EditProfileDrawerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
initialName?: string
|
||||
initialAvatar?: any
|
||||
onSave?: (name: string) => void
|
||||
}
|
||||
|
||||
export default function EditProfileDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
initialName = '乔乔乔',
|
||||
initialAvatar,
|
||||
onSave,
|
||||
}: EditProfileDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const [name, setName] = useState(initialName)
|
||||
const insets = useSafeAreaInsets()
|
||||
|
||||
const snapPoints = useMemo(() => [280], [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.expand()
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// 当抽屉打开时,重置名字为初始值
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setName(initialName)
|
||||
}
|
||||
}, [visible, initialName])
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
Keyboard.dismiss()
|
||||
onSave?.(name)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
Keyboard.dismiss()
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={visible ? 0 : -1}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.bottomSheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backdropComponent={renderBackdrop}
|
||||
keyboardBehavior="interactive"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={styles.container}>
|
||||
{/* 顶部关闭按钮 */}
|
||||
<Pressable
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
{/* 头像区域 */}
|
||||
<View style={styles.avatarContainer}>
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Image
|
||||
source={initialAvatar || require('@/assets/images/icon.png')}
|
||||
style={styles.avatar}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<Pressable style={styles.cameraButton}>
|
||||
<View style={styles.cameraIconContainer}>
|
||||
<AvatarUploadIcon />
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 输入框 */}
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder={t('editProfile.namePlaceholder')}
|
||||
placeholderTextColor="#666666"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
blurOnSubmit={true}
|
||||
/>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<Pressable
|
||||
style={styles.saveButtonContainer}
|
||||
onPress={handleSave}
|
||||
android_ripple={{ color: 'rgba(255, 255, 255, 0.1)' }}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#9966FF', '#FF6699', '#FF9966']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.saveButton}
|
||||
>
|
||||
<Text style={styles.saveButtonText}>{t('editProfile.save')}</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: '#1C1E22',
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: '#666666',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#1C1E22',
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingTop: 32,
|
||||
paddingBottom: Platform.OS === 'ios' ? 25 : 17,
|
||||
paddingHorizontal: 0,
|
||||
flexGrow: 1,
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
top: Platform.OS === 'ios' ? 16 : 16,
|
||||
right: 16,
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
avatarContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
width: 88,
|
||||
height: 88,
|
||||
},
|
||||
avatar: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 50,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
cameraButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 16,
|
||||
backgroundColor: '#16181B',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: '#FFFFFF',
|
||||
},
|
||||
cameraIconContainer: {
|
||||
width: 13,
|
||||
height: 13,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#262A31',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
marginVertical: 24,
|
||||
paddingVertical: 14,
|
||||
color: '#F5F5F5',
|
||||
fontWeight: '500',
|
||||
fontSize: 14,
|
||||
height: 48,
|
||||
},
|
||||
saveButtonContainer: {
|
||||
width: '100%',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
height: 48,
|
||||
},
|
||||
saveButton: {
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
height: 48,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
})
|
||||
|
||||
448
components/drawer/PointsDrawer.tsx
Normal file
448
components/drawer/PointsDrawer.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop, BottomSheetScrollView } from '@gorhom/bottom-sheet'
|
||||
import { CloseIcon } from '@/components/icon'
|
||||
import TopUpDrawer, { TopUpOption } from '@/components/drawer/TopUpDrawer'
|
||||
|
||||
export type PointsTabType = 'all' | 'consume' | 'obtain'
|
||||
|
||||
export interface PointsTransaction {
|
||||
id: string
|
||||
title: string
|
||||
date: string
|
||||
points: number // 正数表示获得,负数表示消耗
|
||||
}
|
||||
|
||||
export interface PointsDrawerProps {
|
||||
/**
|
||||
* 是否显示抽屉
|
||||
*/
|
||||
visible: boolean
|
||||
/**
|
||||
* 关闭回调
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* 当前积分总额
|
||||
*/
|
||||
totalPoints?: number
|
||||
/**
|
||||
* 订阅积分
|
||||
*/
|
||||
subscriptionPoints?: number
|
||||
/**
|
||||
* 额外充值积分
|
||||
*/
|
||||
topUpPoints?: number
|
||||
/**
|
||||
* 交易记录列表
|
||||
*/
|
||||
transactions?: PointsTransaction[]
|
||||
}
|
||||
|
||||
export default function PointsDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
totalPoints = 60,
|
||||
subscriptionPoints = 0,
|
||||
topUpPoints = 0,
|
||||
transactions = [],
|
||||
}: PointsDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { height: screenHeight } = useWindowDimensions()
|
||||
const insets = useSafeAreaInsets()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const [pointsTab, setPointsTab] = useState<PointsTabType>('all')
|
||||
const [topUpDrawerVisible, setTopUpDrawerVisible] = useState(false)
|
||||
|
||||
const snapPoints = useMemo(() => [screenHeight * 0.85], [screenHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.expand()
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
// 标签页配置
|
||||
const tabOptions: Array<{ value: PointsTabType; label: string }> = [
|
||||
{ value: 'all', label: t('pointsDrawer.all') },
|
||||
{ value: 'consume', label: t('pointsDrawer.consume') },
|
||||
{ value: 'obtain', label: t('pointsDrawer.obtain') },
|
||||
]
|
||||
|
||||
// 根据标签页过滤交易记录
|
||||
const filteredTransactions = transactions.filter((transaction) => {
|
||||
if (pointsTab === 'all') return true
|
||||
if (pointsTab === 'consume') return transaction.points < 0
|
||||
if (pointsTab === 'obtain') return transaction.points > 0
|
||||
return true
|
||||
})
|
||||
|
||||
// 如果没有提供交易记录,使用示例数据
|
||||
const displayTransactions =
|
||||
filteredTransactions.length > 0
|
||||
? filteredTransactions
|
||||
: [
|
||||
{
|
||||
id: '1',
|
||||
title: t('pointsDrawer.dailyFreePoints'),
|
||||
date: '2025年11月28日 10:33',
|
||||
points: 60,
|
||||
},
|
||||
...Array.from({ length: 60 }, (_, i) => ({
|
||||
id: `example-${i + 2}`,
|
||||
title: t('pointsDrawer.dailyFreePoints'),
|
||||
date: '2025年11月28日 10:33',
|
||||
points: -60,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={visible ? 0 : -1}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.bottomSheetBackground}
|
||||
handleComponent={null}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView style={styles.container}>
|
||||
{/* 顶部标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<Text></Text>
|
||||
<Pressable
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title}>{t('pointsDrawer.title')}</Text>
|
||||
{/* 积分总额 */}
|
||||
<View style={styles.balance}>
|
||||
<Text style={styles.balanceValue}>{totalPoints}</Text>
|
||||
</View>
|
||||
|
||||
{/* 积分类型细分 */}
|
||||
<View style={styles.breakdown}>
|
||||
<Text style={styles.breakdownText}>{t('pointsDrawer.subscriptionPoints')}
|
||||
<Text style={styles.breakdownTextValue}>{subscriptionPoints}</Text>
|
||||
</Text>
|
||||
<View style={styles.breakdownTextSeparator} />
|
||||
<Text style={styles.breakdownText}>{t('pointsDrawer.topUpPoints')}
|
||||
<Text style={styles.breakdownTextValue}>{topUpPoints}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 标签页 */}
|
||||
<View style={styles.tabs}>
|
||||
{tabOptions.map((tab) => {
|
||||
const isActive = pointsTab === tab.value
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.value}
|
||||
style={[styles.tab]}
|
||||
onPress={() => setPointsTab(tab.value)}
|
||||
>
|
||||
<View style={styles.tabContent}>
|
||||
{isActive && (
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.tabGradient}
|
||||
/>
|
||||
)}
|
||||
<Text style={[styles.tabText, isActive && styles.tabTextActive]}>{tab.label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 交易历史列表 */}
|
||||
<BottomSheetScrollView
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{displayTransactions.map((transaction) => (
|
||||
<View key={transaction.id} style={styles.item}>
|
||||
<View style={styles.itemLeft}>
|
||||
<Text style={styles.itemTitle}>{transaction.title}</Text>
|
||||
<Text style={styles.itemDate}>{transaction.date}</Text>
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.itemPoints,
|
||||
transaction.points < 0 && styles.itemPointsNegative,
|
||||
]}
|
||||
>
|
||||
{transaction.points > 0 ? '+' : ''}
|
||||
{transaction.points}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||
<Pressable
|
||||
style={styles.subscribeButton}
|
||||
onPress={() => {
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.subscribeButtonGradient}
|
||||
|
||||
>
|
||||
<Text style={styles.subscribeButtonText}>{t('pointsDrawer.subscribeForPoints')}</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.topUpButton}
|
||||
onPress={() => {
|
||||
setTopUpDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.topUpButtonText}>{t('pointsDrawer.topUpPointsButton')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
|
||||
{/* 充值抽屉 */}
|
||||
<TopUpDrawer
|
||||
visible={topUpDrawerVisible}
|
||||
onClose={() => setTopUpDrawerVisible(false)}
|
||||
onNavigate={() => {
|
||||
setTopUpDrawerVisible(false)
|
||||
onClose()
|
||||
}}
|
||||
requiredPoints={100}
|
||||
remainingPoints={totalPoints}
|
||||
topUpTitle={t('topUp.title')}
|
||||
onConfirm={(option: TopUpOption) => {
|
||||
// 处理充值确认逻辑
|
||||
console.log('确认充值:', option)
|
||||
setTopUpDrawerVisible(false)
|
||||
}}
|
||||
/>
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: '#090A0B',
|
||||
borderTopLeftRadius: 24,
|
||||
borderTopRightRadius: 24,
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: '#666666',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
titleContainer: {
|
||||
paddingLeft: 20,
|
||||
marginTop: -4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#3A3A3A',
|
||||
},
|
||||
title: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
closeButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
balance: {
|
||||
marginBottom: 13,
|
||||
},
|
||||
balanceValue: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 40,
|
||||
fontWeight: '500',
|
||||
},
|
||||
breakdown: {
|
||||
flexDirection: 'row',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
},
|
||||
breakdownText: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
breakdownTextValue: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginLeft: 6,
|
||||
},
|
||||
breakdownTextSeparator: {
|
||||
width: 1,
|
||||
height: 14,
|
||||
backgroundColor: '#3A3A3A',
|
||||
marginTop: 2,
|
||||
},
|
||||
tabs: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginTop:20,
|
||||
marginBottom:24,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabContent: {
|
||||
position: 'relative',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
tabGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 10,
|
||||
backgroundColor: '#FF9966',
|
||||
},
|
||||
tabText: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tabTextActive: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
},
|
||||
list: {
|
||||
height: 400,
|
||||
},
|
||||
listContent: {
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#1C1E20',
|
||||
},
|
||||
itemLeft: {
|
||||
flex: 1,
|
||||
},
|
||||
itemTitle: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemDate: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
itemPoints: {
|
||||
color: '#4CAF50',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
itemPointsNegative: {
|
||||
color: '#F5F5F5',
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 20,
|
||||
paddingHorizontal: 16,
|
||||
gap: 4,
|
||||
},
|
||||
subscribeButton: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
subscribeButtonGradient: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
},
|
||||
subscribeButtonText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
topUpButton: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
},
|
||||
topUpButtonText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
})
|
||||
|
||||
433
components/drawer/TopUpDrawer.tsx
Normal file
433
components/drawer/TopUpDrawer.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
useWindowDimensions,
|
||||
} from 'react-native'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
|
||||
import { CloseIcon, CheckIcon, UncheckedIcon, PointsIcon } from '@/components/icon'
|
||||
|
||||
export interface TopUpOption {
|
||||
id: string
|
||||
points: number
|
||||
price: number
|
||||
}
|
||||
|
||||
export interface TopUpDrawerProps {
|
||||
/**
|
||||
* 是否显示抽屉
|
||||
*/
|
||||
visible: boolean
|
||||
/**
|
||||
* 关闭回调
|
||||
*/
|
||||
onClose: () => void
|
||||
/**
|
||||
* 需要消耗的积分
|
||||
*/
|
||||
requiredPoints?: number
|
||||
/**
|
||||
* 当前剩余积分
|
||||
*/
|
||||
remainingPoints?: number
|
||||
/**
|
||||
* 充值选项列表
|
||||
*/
|
||||
options?: TopUpOption[]
|
||||
/**
|
||||
* 确认充值回调
|
||||
*/
|
||||
onConfirm?: (option: TopUpOption) => void
|
||||
/**
|
||||
* 充值标题
|
||||
*/
|
||||
topUpTitle?: string
|
||||
/**
|
||||
* 充值描述
|
||||
*/
|
||||
topUpDescription?: string
|
||||
/**
|
||||
* 导航回调,用于在导航时关闭父级抽屉(如 PointsDrawer)
|
||||
*/
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
const defaultOptions: TopUpOption[] = [
|
||||
{ id: '1', points: 1000, price: 20 },
|
||||
{ id: '2', points: 2500, price: 20 },
|
||||
{ id: '3', points: 5000, price: 20 },
|
||||
{ id: '4', points: 10000, price: 20 },
|
||||
]
|
||||
|
||||
export default function TopUpDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
options = defaultOptions,
|
||||
onConfirm,
|
||||
topUpTitle,
|
||||
topUpDescription,
|
||||
onNavigate,
|
||||
}: TopUpDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const router = useRouter()
|
||||
const insets = useSafeAreaInsets()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const [selectedOption, setSelectedOption] = useState<TopUpOption | null>(
|
||||
options[0] || null
|
||||
)
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
|
||||
const snapPoints = useMemo(() => [420], [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.expand()
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
bottomSheetRef.current?.close()
|
||||
onClose()
|
||||
}, [onClose])
|
||||
|
||||
// 如果没有传入标题,使用默认翻译
|
||||
const displayTitle = topUpTitle || t('topUp.title')
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedOption && agreed) {
|
||||
onConfirm?.(selectedOption)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={visible ? 0 : -1}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.bottomSheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
handleComponent={null}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView style={styles.container}>
|
||||
{displayTitle && (
|
||||
// 这个绝对定位的标题层会盖在右上角关闭按钮上,必须允许触摸事件“穿透”
|
||||
<View style={styles.titleContainer} pointerEvents="none">
|
||||
{/* 主文字层 */}
|
||||
<Text style={[styles.titleText, styles.titleFill]}>{displayTitle}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.header}>
|
||||
<Text></Text>
|
||||
<Pressable
|
||||
style={styles.closeButton}
|
||||
onPress={handleClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{topUpDescription && (
|
||||
<View style={styles.infoSection}>
|
||||
<Text style={styles.infoText}>{topUpDescription}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{/* 充值选项网格 */}
|
||||
<View style={styles.optionsGrid}>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedOption?.id === option.id
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={styles.optionCardWrapper}
|
||||
onPress={() => setSelectedOption(option)}
|
||||
>
|
||||
{isSelected ? (
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.optionCardGradient}
|
||||
>
|
||||
<View style={styles.optionCard}>
|
||||
<View style={styles.optionContent}>
|
||||
<PointsIcon width={16} height={16} />
|
||||
<Text style={styles.optionPoints}>
|
||||
{option.points.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.optionPrice}>${option.price}</Text>
|
||||
|
||||
</View>
|
||||
</LinearGradient>
|
||||
) : (
|
||||
<View style={styles.optionCard}>
|
||||
<View style={styles.optionContent}>
|
||||
<PointsIcon width={16} height={16} />
|
||||
<Text style={styles.optionPoints}>
|
||||
{option.points.toLocaleString()}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={styles.optionPrice}>${option.price}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 底部按钮和协议 */}
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
|
||||
<Pressable
|
||||
style={styles.confirmButton}
|
||||
onPress={handleConfirm}
|
||||
disabled={!agreed || !selectedOption}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF9966', '#FF6699', '#9966FF']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.confirmButtonGradient,
|
||||
(!agreed || !selectedOption) && styles.confirmButtonDisabled,
|
||||
]}
|
||||
>
|
||||
<Text style={styles.confirmButtonText}>{t('topUp.confirm')}</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
<View style={styles.agreementContainer}>
|
||||
<Pressable
|
||||
style={styles.checkbox}
|
||||
onPress={() => setAgreed(!agreed)}
|
||||
>
|
||||
{agreed ? <CheckIcon /> : <UncheckedIcon />}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => setAgreed(!agreed)}
|
||||
>
|
||||
<Text style={styles.agreementText}>
|
||||
{t('topUp.agreementText')}{' '}
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
onNavigate?.()
|
||||
router.push('/terms')
|
||||
}}
|
||||
>
|
||||
{t('topUp.terms')}
|
||||
</Text>
|
||||
<Text style={styles.agreementText}> {t('topUp.agreementAnd')} </Text>
|
||||
<Text
|
||||
style={styles.agreementLink}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
onNavigate?.()
|
||||
router.push('/privacy')
|
||||
}}
|
||||
>
|
||||
{t('topUp.privacy')}
|
||||
</Text>
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: '#666666',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: '#090A0B',
|
||||
paddingHorizontal: 12,
|
||||
overflow: 'visible',
|
||||
},
|
||||
titleContainer: {
|
||||
position: 'absolute',
|
||||
top:10,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
height: 40,
|
||||
overflow: 'visible',
|
||||
},
|
||||
strokeTextWrapper: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleText: {
|
||||
fontSize: 24,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
zIndex: 1,
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center',
|
||||
lineHeight: 28,
|
||||
},
|
||||
titleStroke: {
|
||||
color: '#000000',
|
||||
},
|
||||
titleFill: {
|
||||
color: '#F5F5F5',
|
||||
position: 'relative',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: 12,
|
||||
marginTop: 20,
|
||||
},
|
||||
closeButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
infoSection: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
infoText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
fontWeight: '400',
|
||||
marginBottom: 8,
|
||||
},
|
||||
subtitle: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
optionsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
optionCardWrapper: {
|
||||
width: '47%',
|
||||
aspectRatio: 2.3,
|
||||
},
|
||||
optionCardGradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: 12,
|
||||
padding: 2,
|
||||
},
|
||||
optionCard: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#16181B',
|
||||
overflow: 'hidden',
|
||||
gap: 4,
|
||||
},
|
||||
optionContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 2,
|
||||
},
|
||||
optionPoints: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 20,
|
||||
fontWeight: '500',
|
||||
},
|
||||
optionPrice: {
|
||||
color: '#ABABAB',
|
||||
fontSize: 12,
|
||||
fontWeight: '400',
|
||||
},
|
||||
footer: {
|
||||
paddingTop: 20,
|
||||
gap: 16,
|
||||
},
|
||||
confirmButton: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
confirmButtonGradient: {
|
||||
width: '100%',
|
||||
height: 48,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 12,
|
||||
},
|
||||
confirmButtonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
confirmButtonText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
agreementContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
checkbox: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 4,
|
||||
},
|
||||
agreementText: {
|
||||
color: '#8A8A8A',
|
||||
fontSize: 10,
|
||||
fontWeight: '400',
|
||||
},
|
||||
agreementLink: {
|
||||
color: '#ABABAB',
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
})
|
||||
|
||||
383
components/drawer/UploadReferenceImageDrawer.tsx
Normal file
383
components/drawer/UploadReferenceImageDrawer.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
FlatList,
|
||||
useWindowDimensions,
|
||||
Platform,
|
||||
} from 'react-native'
|
||||
import { Image } from 'expo-image'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BottomSheet, { BottomSheetView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'
|
||||
import { CloseIcon, DownArrowIcon } from '@/components/icon'
|
||||
import AIGenerationRecordDrawer from './AIGenerationRecordDrawer'
|
||||
|
||||
interface UploadReferenceImageDrawerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onSelectImage?: (imageUri: any) => void
|
||||
}
|
||||
|
||||
type TabType = 'ai-record' | 'recent'
|
||||
|
||||
// 模拟图片数据
|
||||
const mockImages = Array.from({ length: 120 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
uri: require('@/assets/images/android-icon-background.png'),
|
||||
}))
|
||||
|
||||
export default function UploadReferenceImageDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
onSelectImage,
|
||||
}: UploadReferenceImageDrawerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { width: screenWidth } = useWindowDimensions()
|
||||
const bottomSheetRef = useRef<BottomSheet>(null)
|
||||
const [activeTab, setActiveTab] = useState<TabType>('ai-record')
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'face'>('all')
|
||||
const [aiRecordDrawerVisible, setAiRecordDrawerVisible] = useState(false)
|
||||
|
||||
const snapPoints = useMemo(() => ['98%'], [])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
bottomSheetRef.current?.expand()
|
||||
} else {
|
||||
bottomSheetRef.current?.close()
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
const handleSheetChanges = useCallback((index: number) => {
|
||||
if (index === -1) {
|
||||
onClose()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const handleImageSelect = (imageSource: any) => {
|
||||
onSelectImage?.(imageSource)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const renderImageItem = ({ item, index }: { item: typeof mockImages[0]; index: number }) => {
|
||||
const paddingHorizontal = 0
|
||||
const gap = 2
|
||||
const itemWidth = (screenWidth - paddingHorizontal * 2 - gap * 2) / 3
|
||||
const isLastRow = index >= Math.floor(mockImages.length / 3) * 3
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.imageItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
marginRight: (index + 1) % 3 !== 0 ? gap : 0,
|
||||
marginBottom: isLastRow ? 0 : gap,
|
||||
},
|
||||
]}
|
||||
onPress={() => handleImageSelect(item.uri)}
|
||||
>
|
||||
<Image
|
||||
source={item.uri}
|
||||
style={styles.image}
|
||||
contentFit="cover"
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BottomSheet
|
||||
ref={bottomSheetRef}
|
||||
index={visible ? 0 : -1}
|
||||
snapPoints={snapPoints}
|
||||
onChange={handleSheetChanges}
|
||||
enablePanDownToClose
|
||||
backgroundStyle={styles.bottomSheetBackground}
|
||||
handleIndicatorStyle={styles.handleIndicator}
|
||||
backdropComponent={renderBackdrop}
|
||||
>
|
||||
<BottomSheetView style={styles.container}>
|
||||
{/* 顶部标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<View >
|
||||
<Text style={styles.title}>{t('uploadReference.selectImage')}</Text>
|
||||
<Text style={styles.title}>{t('uploadReference.generateAIVideo')}</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<CloseIcon />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 标签切换 */}
|
||||
<View style={styles.tabContainer}>
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === 'ai-record' && styles.tabActive]}
|
||||
onPress={() => {
|
||||
setActiveTab('ai-record')
|
||||
setAiRecordDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
<View style={styles.tabIconContainer}>
|
||||
<View style={styles.tabIcon} />
|
||||
</View>
|
||||
<Text style={[styles.tabText, activeTab === 'ai-record' && styles.tabTextActive]}>
|
||||
{t('uploadReference.aiRecord')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.tab, activeTab === 'recent' && styles.tabActive]}
|
||||
onPress={() => {
|
||||
setActiveTab('recent')
|
||||
setAiRecordDrawerVisible(true)
|
||||
}}
|
||||
>
|
||||
<View style={styles.tabIconContainer}>
|
||||
<View style={styles.tabIconSmall} />
|
||||
</View>
|
||||
<Text style={[styles.tabText, activeTab === 'recent' && styles.tabTextActive]}>
|
||||
{t('uploadReference.recentUsed')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<View style={styles.filterContainer}>
|
||||
<Pressable
|
||||
style={styles.categoryButton}
|
||||
onPress={() => {
|
||||
// 可以展开分类选择
|
||||
}}
|
||||
>
|
||||
<Text style={styles.categoryText}>{t('uploadReference.recentProject')}</Text>
|
||||
<DownArrowIcon />
|
||||
</Pressable>
|
||||
<View style={styles.filterButtons}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.filterButton,
|
||||
selectedFilter === 'all' && styles.filterButtonActive,
|
||||
]}
|
||||
onPress={() => setSelectedFilter('all')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterButtonText,
|
||||
selectedFilter === 'all' && styles.filterButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{t('uploadReference.all')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.filterButton,
|
||||
selectedFilter === 'face' && styles.filterButtonActive,
|
||||
]}
|
||||
onPress={() => setSelectedFilter('face')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.filterButtonText,
|
||||
selectedFilter === 'face' && styles.filterButtonTextActive,
|
||||
]}
|
||||
>
|
||||
{t('uploadReference.face')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 图片网格 */}
|
||||
<FlatList
|
||||
data={mockImages}
|
||||
renderItem={renderImageItem}
|
||||
keyExtractor={(item) => item.id.toString()}
|
||||
numColumns={3}
|
||||
showsVerticalScrollIndicator={false}
|
||||
removeClippedSubviews={Platform.OS === 'android'}
|
||||
maxToRenderPerBatch={Platform.OS === 'ios' ? 10 : 5}
|
||||
updateCellsBatchingPeriod={Platform.OS === 'ios' ? 50 : 100}
|
||||
initialNumToRender={Platform.OS === 'ios' ? 15 : 10}
|
||||
windowSize={Platform.OS === 'ios' ? 10 : 5}
|
||||
getItemLayout={(data, index) => {
|
||||
const gap = 2
|
||||
const itemWidth = (screenWidth - gap * 2) / 3
|
||||
const rowIndex = Math.floor(index / 3)
|
||||
return {
|
||||
length: itemWidth,
|
||||
offset: rowIndex * (itemWidth + gap),
|
||||
index,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</BottomSheetView>
|
||||
</BottomSheet>
|
||||
<AIGenerationRecordDrawer
|
||||
visible={aiRecordDrawerVisible}
|
||||
onClose={() => setAiRecordDrawerVisible(false)}
|
||||
onSelectImage={(imageUri) => {
|
||||
handleImageSelect(imageUri)
|
||||
}}
|
||||
type={activeTab}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bottomSheetBackground: {
|
||||
backgroundColor: '#16181B',
|
||||
},
|
||||
handleIndicator: {
|
||||
backgroundColor: '#666666',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#16181B',
|
||||
paddingTop: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
title: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
},
|
||||
closeButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
backgroundColor: '#272A30',
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
tabActive: {
|
||||
backgroundColor: '#262A31',
|
||||
},
|
||||
tabIconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabIcon: {
|
||||
width: 27,
|
||||
height: 27,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#4A4C4F',
|
||||
},
|
||||
tabIconSmall: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 6,
|
||||
backgroundColor: '#4A4C4F',
|
||||
},
|
||||
tabText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabTextActive: {
|
||||
color: '#F5F5F5',
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 9,
|
||||
},
|
||||
categoryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
categoryText: {
|
||||
color: '#F5F5F5',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
filterButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1C1E22',
|
||||
borderRadius: 100,
|
||||
height: 32,
|
||||
padding: 3,
|
||||
},
|
||||
filterButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
minWidth: 48,
|
||||
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterButtonActive: {
|
||||
backgroundColor: '#F5F5F5',
|
||||
height: 24,
|
||||
borderRadius: 100,
|
||||
},
|
||||
filterButtonText: {
|
||||
color: '#CCCCCC',
|
||||
fontSize: 12,
|
||||
},
|
||||
filterButtonTextActive: {
|
||||
color: '#000000',
|
||||
},
|
||||
// imageGrid: {
|
||||
// // paddingHorizontal: 16,
|
||||
// // paddingBottom: 20,
|
||||
// },
|
||||
imageItem: {
|
||||
// aspectRatio = width / height
|
||||
// 1 : 1.3 (width : height) => 1 / 1.3
|
||||
aspectRatio: 1 / 1.3,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#262A31',
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
14
components/icon/avatarUpload.tsx
Normal file
14
components/icon/avatarUpload.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const AvatarUploadIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||
<Path
|
||||
d="M11.9601 3.63998C11.6423 3.3222 11.2667 3.17776 10.8334 3.17776H9.44673L9.12895 2.33998C9.04228 2.13776 8.89784 1.96442 8.69561 1.81998C8.49339 1.67553 8.26228 1.58887 8.06006 1.58887H4.88228C4.65117 1.58887 4.44895 1.67553 4.24673 1.81998C4.0445 1.96442 3.90006 2.13776 3.81339 2.33998L3.46673 3.17776H2.05117C1.61784 3.17776 1.24228 3.3222 0.924503 3.63998C0.606725 3.95776 0.46228 4.33331 0.46228 4.76665V10.3422C0.46228 10.7755 0.606725 11.1511 0.924503 11.4689C1.24228 11.7866 1.61784 11.9311 2.05117 11.9311H10.8334C11.2667 11.9311 11.6423 11.7866 11.9601 11.4689C12.2778 11.1511 12.4223 10.7755 12.4223 10.3422V4.76665C12.4223 4.33331 12.2778 3.95776 11.9601 3.63998ZM6.44228 10.0822C4.99784 10.0822 3.81339 8.89776 3.81339 7.45331C3.81339 6.00887 4.99784 4.82442 6.44228 4.82442C7.88672 4.82442 9.07117 6.00887 9.07117 7.45331C9.07117 8.89776 7.88672 10.0822 6.44228 10.0822Z"
|
||||
fill="white"
|
||||
/>
|
||||
<Path
|
||||
d="M6.44215 5.71997C5.45993 5.71997 4.67993 6.49997 4.67993 7.48219C4.67993 8.46442 5.45993 9.24442 6.44215 9.24442C7.42438 9.24442 8.20438 8.46442 8.20438 7.48219C8.20438 6.49997 7.42438 5.71997 6.44215 5.71997Z"
|
||||
fill="white"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
10
components/icon/change.tsx
Normal file
10
components/icon/change.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const ChangeIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="18" height="18" viewBox="0 0 18 18" fill="none" className={className}>
|
||||
<Path
|
||||
d="M4.63613 7.83113L5.77238 9.25538C5.86174 9.36757 5.90287 9.51066 5.88673 9.65317C5.87059 9.79569 5.7985 9.92596 5.68631 10.0153C5.57413 10.1047 5.43104 10.1458 5.28852 10.1297C5.146 10.1135 5.01574 10.0414 4.92638 9.92926L4.51688 9.41513L4.51013 9.64913C4.51008 10.6911 4.90962 11.6934 5.62645 12.4495C6.34327 13.2057 7.3228 13.6582 8.36325 13.7138L8.58038 13.7194C9.48038 13.7194 10.3129 13.4269 10.9879 12.9296L11.1859 12.7755L11.196 12.7868L11.2478 12.7519C11.313 12.714 11.3854 12.6903 11.4604 12.6821C11.5354 12.674 11.6113 12.6817 11.6831 12.7046L11.7866 12.7519C11.8701 12.8004 11.939 12.8703 11.9862 12.9545C12.0335 13.0386 12.0573 13.1339 12.0552 13.2304C12.0531 13.3268 12.0253 13.421 11.9745 13.503C11.9237 13.5851 11.8518 13.652 11.7664 13.6969C11.0231 14.2821 10.133 14.6514 9.19372 14.764C8.25444 14.8767 7.30226 14.7285 6.44165 14.3357C5.58104 13.9429 4.84525 13.3206 4.31501 12.5372C3.78477 11.7537 3.48056 10.8393 3.43575 9.89438L3.43013 9.65138L3.4335 9.48263L3.12638 9.73126C3.0709 9.77543 3.00727 9.80824 2.93911 9.82783C2.87095 9.84741 2.79961 9.85337 2.72915 9.84538C2.65868 9.83739 2.59049 9.8156 2.52845 9.78125C2.4664 9.7469 2.41174 9.70067 2.36756 9.64519C2.32339 9.58972 2.29058 9.52609 2.27099 9.45793C2.25141 9.38977 2.24545 9.31843 2.25344 9.24796C2.26958 9.10566 2.34159 8.97559 2.45363 8.88638L3.87675 7.74563C3.93221 7.70134 3.99585 7.66842 4.06404 7.64874C4.13223 7.62906 4.20364 7.62302 4.27416 7.63096C4.34469 7.63891 4.41296 7.66067 4.47507 7.69502C4.53718 7.72936 4.59191 7.77562 4.63613 7.83113ZM8.58038 4.50001C9.89261 4.50022 11.1553 5.00131 12.1106 5.90098C13.0658 6.80064 13.6417 8.03102 13.7205 9.34088L14.0524 9.02813C14.1042 8.97975 14.1651 8.94205 14.2315 8.91719C14.298 8.89234 14.3686 8.88081 14.4395 8.88326C14.5104 8.88572 14.5801 8.90211 14.6446 8.9315C14.7092 8.9609 14.7673 9.00271 14.8157 9.05457C14.8641 9.10642 14.9018 9.1673 14.9266 9.23373C14.9515 9.30015 14.963 9.37082 14.9606 9.4417C14.9581 9.51258 14.9417 9.58228 14.9123 9.64683C14.8829 9.71137 14.8411 9.7695 14.7893 9.81788L13.4584 11.0599C13.3647 11.1478 13.2425 11.1993 13.1141 11.205H13.077C12.9976 11.2053 12.919 11.188 12.847 11.1544C12.7751 11.1207 12.7114 11.0716 12.6608 11.0104L11.4345 9.69301C11.3368 9.58828 11.2847 9.44902 11.2896 9.30587C11.2946 9.16273 11.3562 9.02741 11.4609 8.92969C11.5657 8.83198 11.7049 8.77987 11.8481 8.78483C11.9912 8.78978 12.1265 8.8514 12.2243 8.95613L12.6428 9.40388C12.5996 8.6852 12.3665 7.99083 11.9674 7.39162C11.5683 6.7924 11.0173 6.30977 10.3708 5.99297C9.72429 5.67616 9.00529 5.53652 8.28718 5.58829C7.56907 5.64005 6.87753 5.88137 6.28313 6.28763L6.10088 6.42376C6.02782 6.50479 5.93184 6.56168 5.82569 6.58688C5.71954 6.61208 5.60823 6.6044 5.50655 6.56485C5.40487 6.5253 5.31762 6.45575 5.25639 6.36545C5.19517 6.27515 5.16286 6.16836 5.16375 6.05926C5.16375 5.87588 5.256 5.71276 5.39775 5.61601L5.391 5.60588C6.29889 4.88794 7.42292 4.49819 8.58038 4.50001Z"
|
||||
fill="#F5F5F5"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
14
components/icon/check.tsx
Normal file
14
components/icon/check.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path, G, Defs, ClipPath,Rect } from 'react-native-svg'
|
||||
|
||||
export const CheckIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="12" height="12" className={className} viewBox="0 0 12 12" fill="none">
|
||||
<G clipPath="url(#clip0_3_4111)">
|
||||
<Path d="M6 0C2.6926 0 0 2.69115 0 6C0 9.30884 2.69115 12 6 12C9.30739 12 12 9.30884 12 6C12 2.69115 9.30884 0 6 0ZM9.14645 4.70082L5.50701 8.34026C5.42436 8.42291 5.31561 8.46496 5.20686 8.46496H5.16916C5.06042 8.46496 4.95167 8.42291 4.86902 8.34026L2.85355 6.32479C2.68826 6.1595 2.68826 5.8898 2.85355 5.7245C3.01885 5.55921 3.28855 5.55921 3.45384 5.7245L5.18801 7.45723L8.54616 4.09908C8.71145 3.93378 8.98115 3.93378 9.14645 4.09908C9.31319 4.26583 9.31319 4.53407 9.14645 4.70082Z" fill="#CCCCCC"/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_3_4111">
|
||||
<Rect width="12" height="12" fill="white"/>
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
8
components/icon/checkMark.tsx
Normal file
8
components/icon/checkMark.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
|
||||
export const CheckMarkIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="12" height="12" className={className} viewBox="0 0 12 12" fill="none">
|
||||
<Path d="M2.32549 5.56722C2.4512 5.56722 2.55806 5.61437 2.64606 5.70865L5.1232 8.36065L10.4049 2.68208C10.4929 2.58722 10.5998 2.53979 10.7255 2.53979C10.8512 2.53979 10.9581 2.58694 11.0461 2.68122C11.1323 2.77379 11.1755 2.88522 11.1755 3.01551C11.1755 3.14522 11.1326 3.25608 11.0469 3.34808L5.6572 9.14237C5.51035 9.30065 5.33263 9.37979 5.12406 9.3798C4.91549 9.3798 4.73777 9.30122 4.59092 9.14408L2.00577 6.37722C1.91892 6.28465 1.87549 6.17322 1.87549 6.04294C1.87549 5.91322 1.91892 5.8018 2.00577 5.70865C2.09377 5.61494 2.20035 5.56808 2.32549 5.56808V5.56722Z" fill="#CCCCCC"/>
|
||||
<Path d="M1.7041 6.04274C1.7041 6.21759 1.76267 6.36788 1.87982 6.49359L4.46582 9.26131C4.64639 9.45445 4.86582 9.55102 5.1241 9.55102C5.38239 9.55102 5.60182 9.45388 5.78239 9.25959L11.1721 3.46531C11.2887 3.33959 11.347 3.18959 11.347 3.01531C11.347 2.83988 11.2884 2.68931 11.1712 2.56359C11.0495 2.43331 10.901 2.36816 10.7255 2.36816C10.5501 2.36816 10.4015 2.43388 10.2798 2.56531L5.12239 8.10845L2.77124 5.59102C2.64953 5.46074 2.50096 5.39559 2.32553 5.39559C2.15067 5.39559 2.0021 5.46074 1.87982 5.59102C1.76267 5.71674 1.7041 5.86702 1.7041 6.04188V6.04274ZM5.12324 8.36045L5.23982 8.23445L10.405 2.68188C10.493 2.58702 10.5998 2.53959 10.7255 2.53959C10.8512 2.53959 10.9581 2.58674 11.0461 2.68102C11.1324 2.77359 11.1755 2.88502 11.1755 3.01531C11.1755 3.14502 11.1327 3.25588 11.047 3.34788L5.65724 9.14216C5.51039 9.30045 5.33267 9.37959 5.1241 9.37959C4.91553 9.37959 4.73782 9.30102 4.59096 9.14388L2.00582 6.37702C1.91896 6.28445 1.87553 6.17302 1.87553 6.04274C1.87553 5.91302 1.91896 5.80159 2.00582 5.70845C2.09382 5.61474 2.20039 5.56788 2.32553 5.56788C2.45124 5.56788 2.5581 5.61502 2.6461 5.70931L5.00582 8.23445L5.12324 8.36045Z" fill="#CCCCCC"/>
|
||||
</Svg>
|
||||
);
|
||||
18
components/icon/close.tsx
Normal file
18
components/icon/close.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Svg, { Path, Rect, G, Defs, ClipPath } from 'react-native-svg';
|
||||
|
||||
export const CloseIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<Rect width="24" height="24" rx="12" fill="#262626" />
|
||||
<G clipPath="url(#clip0_3512_209)">
|
||||
<Path
|
||||
d="M8.81829 7.96875L8.84579 7.994L12.2433 11.3918L15.5158 8.11925C15.6071 8.02702 15.7308 7.97393 15.8605 7.9712C15.9903 7.96848 16.1161 8.01633 16.2112 8.10465C16.3064 8.19297 16.3634 8.31483 16.3703 8.44445C16.3773 8.57408 16.3335 8.70132 16.2483 8.79926L16.2233 8.82676L12.9503 12.0988L16.223 15.3713C16.3154 15.4626 16.3686 15.5862 16.3714 15.716C16.3742 15.8458 16.3263 15.9716 16.238 16.0668C16.1497 16.162 16.0278 16.2191 15.8982 16.2261C15.7685 16.233 15.6412 16.1892 15.5433 16.104L15.5158 16.0785L12.2433 12.806L8.84579 16.204C8.75431 16.2955 8.63092 16.348 8.50155 16.3504C8.37219 16.3528 8.24692 16.305 8.15208 16.217C8.05724 16.129 8.0002 16.0076 7.99297 15.8785C7.98574 15.7493 8.02886 15.6223 8.11329 15.5243L8.13854 15.4968L11.5363 12.0988L8.13879 8.70126C8.04793 8.60966 7.99599 8.48649 7.99382 8.3575C7.99166 8.2285 8.03945 8.10366 8.12719 8.00908C8.21493 7.91449 8.33584 7.85749 8.46463 7.84997C8.59343 7.84246 8.72014 7.88502 8.81829 7.96875Z"
|
||||
fill="white"
|
||||
/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_3512_209">
|
||||
<Rect x="6" y="6" width="12" height="12" rx="6" fill="white" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
8
components/icon/close1.tsx
Normal file
8
components/icon/close1.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const Close1Icon = ({ className }: { className?: string }) => (
|
||||
<Svg width="14" height="14" viewBox="0 0 14 14" fill="none" className={className}>
|
||||
<Path d="M3.38311 2.66192C3.31119 2.58997 3.21443 2.54832 3.11274 2.54553C3.01105 2.54274 2.91215 2.57902 2.83639 2.64692C2.76063 2.71481 2.71377 2.80916 2.70544 2.91055C2.69711 3.01194 2.72795 3.11266 2.79161 3.19201L2.82057 3.22447L10.4569 10.8608C10.5289 10.9328 10.6256 10.9744 10.7273 10.9772C10.829 10.98 10.9279 10.9437 11.0037 10.8758C11.0794 10.8079 11.1263 10.7136 11.1346 10.6122C11.1429 10.5108 11.1121 10.4101 11.0484 10.3307L11.0195 10.2983L3.38311 2.66192Z" fill="#F5F5F5"/>
|
||||
<Path d="M10.4569 2.66192C10.5289 2.58997 10.6256 2.54832 10.7273 2.54553C10.829 2.54274 10.9279 2.57902 11.0037 2.64692C11.0794 2.71481 11.1263 2.80916 11.1346 2.91055C11.1429 3.01194 11.1121 3.11266 11.0484 3.19201L11.0195 3.22447L3.38311 10.8608C3.31119 10.9328 3.21443 10.9744 3.11274 10.9772C3.01105 10.98 2.91215 10.9437 2.83639 10.8758C2.76063 10.8079 2.71377 10.7136 2.70544 10.6122C2.69711 10.5108 2.72795 10.4101 2.79161 10.3307L2.82057 10.2983L10.4569 2.66192Z" fill="#F5F5F5"/>
|
||||
</Svg>
|
||||
);
|
||||
16
components/icon/delete.tsx
Normal file
16
components/icon/delete.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const DeleteIcon = () => (
|
||||
<Svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||
<G clipPath="url(#clip0_5062_1547)">
|
||||
<Path d="M1.06521 1.8252H11.775C11.9364 1.82522 12.0911 1.88934 12.2052 2.00346C12.3193 2.11758 12.3834 2.27234 12.3834 2.43371C12.3834 2.59508 12.3193 2.74985 12.2052 2.86396C12.0911 2.97808 11.9364 3.0422 11.775 3.04222H1.06521C0.903838 3.0422 0.749084 2.97808 0.634985 2.86396C0.520886 2.74985 0.456787 2.59508 0.456787 2.43371C0.456787 2.27234 0.520886 2.11758 0.634985 2.00346C0.749084 1.88934 0.903838 1.82522 1.06521 1.8252ZM5.36904 0.121094H7.47117C7.63255 0.121094 7.78733 0.185205 7.90145 0.299322C8.01557 0.41344 8.07968 0.568217 8.07968 0.729604C8.07968 0.890991 8.01557 1.04577 7.90145 1.15989C7.78733 1.274 7.63255 1.33811 7.47117 1.33811H5.36904C5.28913 1.33812 5.21 1.32238 5.13617 1.29179C5.06234 1.26121 4.99526 1.21639 4.93876 1.15989C4.88225 1.10338 4.83743 1.0363 4.80685 0.962471C4.77627 0.888643 4.76053 0.809515 4.76053 0.729604C4.76053 0.649694 4.77627 0.570565 4.80685 0.496737C4.83743 0.42291 4.88225 0.355828 4.93876 0.299322C4.99526 0.242817 5.06234 0.197994 5.13617 0.167414C5.21 0.136833 5.28913 0.121094 5.36904 0.121094ZM9.69832 4.25897C9.69832 4.07557 9.77117 3.89969 9.90085 3.77001C10.0305 3.64033 10.2064 3.56748 10.3898 3.56748C10.5732 3.56748 10.7491 3.64033 10.8788 3.77001C11.0084 3.89969 11.0813 4.07557 11.0813 4.25897V11.0743C11.0813 11.5805 10.8802 12.0659 10.5223 12.4238C10.1644 12.7817 9.67895 12.9828 9.17278 12.9828H3.57449C3.06832 12.9828 2.58288 12.7817 2.22497 12.4238C1.86705 12.0659 1.66598 11.5805 1.66598 11.0743V4.25924C1.66598 4.07585 1.73883 3.89997 1.86851 3.77029C1.99819 3.64061 2.17407 3.56775 2.35747 3.56775C2.54086 3.56775 2.71674 3.64061 2.84642 3.77029C2.9761 3.89997 3.04895 4.07585 3.04895 4.25924V11.0746C3.04895 11.365 3.28406 11.6001 3.57449 11.6001H9.17278C9.31216 11.6001 9.44583 11.5447 9.54439 11.4462C9.64295 11.3476 9.69832 11.2139 9.69832 11.0746V4.25924V4.25897Z" fill="#F5F5F5" />
|
||||
<Path d="M5 6V10" stroke="#F5F5F5" strokeLinecap="round" />
|
||||
<Path d="M8 6V10" stroke="#F5F5F5" strokeLinecap="round" />
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_5062_1547">
|
||||
<Rect width="13" height="13" fill="white" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
24
components/icon/downArrow.tsx
Normal file
24
components/icon/downArrow.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const DownArrowIcon = () => (
|
||||
<Svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<G clipPath="url(#clip0_2_1955)">
|
||||
<Path
|
||||
d="M4.50684 7.5293C4.64648 7.66895 4.83594 7.79785 5.01465 7.75879C5.19434 7.78906 5.36328 7.64941 5.50293 7.5293L9.81641 3.19629C10.0352 2.97754 10.0352 2.61816 9.81641 2.39941C9.59766 2.18066 9.23828 2.18066 9.01953 2.39941L5.00488 6.52344L0.980469 2.39941C0.761719 2.18066 0.402344 2.18066 0.183594 2.39941C-0.0351562 2.61816 -0.0351562 2.97754 0.183594 3.19629L4.50684 7.5293Z"
|
||||
fill="#F5F5F5"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="0.4"
|
||||
/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_2_1955">
|
||||
<Rect
|
||||
width="10"
|
||||
height="10"
|
||||
fill="white"
|
||||
transform="matrix(-1 0 0 1 10 0)"
|
||||
/>
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
9
components/icon/edit.tsx
Normal file
9
components/icon/edit.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const EditIcon = () => (
|
||||
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<Path d="M5.53035 15.8984C5.3966 15.8955 5.2687 15.843 5.17142 15.7512C5.02784 15.6039 4.9568 15.4566 5.02784 15.3093C5.53035 12.0693 5.81675 11.7748 5.88854 11.7015L12.4144 4.48442C12.8444 4.0426 13.5615 3.96933 13.9922 4.33715L15.6411 5.88388C15.8565 6.10442 16 6.3257 16 6.62024C16 6.91479 15.9282 7.2826 15.7129 7.50388L9.11596 14.7202C9.04417 14.7942 8.82881 15.0888 5.60139 15.8984H5.53035ZM13.2751 4.85297C13.1414 4.85593 13.0135 4.9084 12.9162 5.00024L6.31851 12.1433C6.24747 12.2906 5.96032 13.2475 5.67318 15.0888C7.46635 14.647 8.39883 14.2784 8.61345 14.2052L15.2111 7.06206C15.2829 6.98805 15.3547 6.84078 15.3547 6.76752C15.3547 6.69351 15.3547 6.6195 15.2111 6.54624L13.5615 4.9995C13.4897 4.85297 13.4179 4.85297 13.2751 4.85297Z" fill="#F5F5F5" stroke="#F5F5F5" strokeWidth="0.5" />
|
||||
<Path d="M14.5 12L15.1752 13.8248L17 14.5L15.1752 15.1752L14.5 17L13.8248 15.1752L12 14.5L13.8248 13.8248L14.5 12Z" fill="#F5F5F5" />
|
||||
<Path d="M6.50001 3L7.17524 4.82478L9.00001 5.5L7.17524 6.17523L6.50001 8.00001L5.82478 6.17523L4 5.5L5.82478 4.82478L6.50001 3Z" fill="#F5F5F5" />
|
||||
</Svg>
|
||||
);
|
||||
10
components/icon/home.tsx
Normal file
10
components/icon/home.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const HomeIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<Path
|
||||
d="M16.9717 7.85439L10.9446 1.94699C10.1097 1.12884 8.63951 1.12884 7.80462 1.94699L1.77793 7.85439C1.43791 8.18789 1.25 8.61816 1.25 9.06363V16.8441C1.25 17.851 2.18915 18.6667 3.34812 18.6667H6.69234C7.33388 18.6667 7.85364 18.2148 7.85364 17.6574V14.8591C7.85364 14.2285 8.3415 13.6522 9.0519 13.5231C10.028 13.3455 10.8964 13.9969 10.8964 14.8155V17.6574C10.8964 18.2148 11.4165 18.6667 12.0577 18.6667H15.4019C16.5605 18.6667 17.5 17.8506 17.5 16.8441V9.06363C17.4996 8.61816 17.3121 8.18789 16.9717 7.85439Z"
|
||||
fill="white"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
28
components/icon/index.tsx
Normal file
28
components/icon/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
export { CloseIcon } from "./close";
|
||||
export { PointsIcon } from "./points";
|
||||
export { OmitIcon } from "./omit";
|
||||
export { WhiteStarIcon } from "./whiteStar";
|
||||
export { LeftArrowIcon } from "./leftArrow";
|
||||
export { HomeIcon } from "./home";
|
||||
export { VideoIcon } from "./video";
|
||||
export { MessageIcon } from "./message";
|
||||
export { MyIcon } from "./my";
|
||||
export { SearchIcon } from "./search";
|
||||
export { DownArrowIcon } from "./downArrow";
|
||||
export { TopArrowIcon } from "./topArrow";
|
||||
export { SettingsIcon } from "./settings";
|
||||
export { SameStyleIcon } from "./sameStyle";
|
||||
export { NoNewsIcon } from "./noNews";
|
||||
export { WhitePointsIcon } from "./whitePoints";
|
||||
export { EditIcon } from "./edit";
|
||||
export { DeleteIcon } from "./delete";
|
||||
export { AvatarUploadIcon } from "./avatarUpload";
|
||||
export { ChangeIcon } from "./change";
|
||||
export { PlusIcon } from "./plus";
|
||||
export { UploadIcon } from "./upload";
|
||||
export { Close1Icon } from "./close1";
|
||||
export { CheckIcon } from "./check";
|
||||
export { UncheckedIcon } from "./unchecked";
|
||||
export { RightArrowIcon } from "./rightArrow";
|
||||
export { TermsIcon } from "./terms";
|
||||
export { PrivacyIcon } from "./privacy";
|
||||
7
components/icon/leftArrow.tsx
Normal file
7
components/icon/leftArrow.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const LeftArrowIcon = ({className}: {className?: string}) => (
|
||||
<Svg className={className} width="22" height="22" viewBox="0 0 22 22" fill="none">
|
||||
<Path d="M6.79546 11L13.0087 17.2133C13.3675 17.5721 13.3675 18.1521 13.0087 18.5088C12.65 18.8676 12.0699 18.8676 11.7132 18.5088L4.85112 11.6488C4.49233 11.29 4.49233 10.71 4.85112 10.3533L11.7132 3.49121C12.072 3.13242 12.6521 3.13242 13.0087 3.49121C13.3675 3.85 13.3675 4.43008 13.0087 4.78672L6.79546 11Z" fill="#F5F5F5" />
|
||||
</Svg>
|
||||
);
|
||||
7
components/icon/message.tsx
Normal file
7
components/icon/message.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const MessageIcon = () => (
|
||||
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<Path d="M9.33297 1.42321C9.33297 0.639286 8.73622 0 8 0C7.26541 0 6.66703 0.6375 6.66703 1.42321C6.66703 1.50357 6.67351 1.58214 6.68486 1.65893C4.84432 2.34286 3.33297 4.44643 3.33297 6.93393V8.89107C3.33297 8.89107 3.33297 11.7107 2.68432 11.7357C2.29676 11.7357 2 12.0536 2 12.4464C2 12.8411 2.29838 13.1571 2.66649 13.1571H13.3335C13.7032 13.1571 14 12.8393 14 12.4464C14 12.05 13.7016 11.7357 13.3335 11.7357C12.667 11.7357 12.667 8.91072 12.667 8.91072V6.93214C12.667 4.44286 11.2546 2.33929 9.31514 1.65536C9.32649 1.58036 9.33297 1.50179 9.33297 1.42321ZM9.99946 13.8661C9.99784 15.0464 9.11081 16 8 16C6.8973 16 6.00216 15.0482 6.00054 13.8661" fill="#F5F5F5" />
|
||||
</Svg>
|
||||
);
|
||||
15
components/icon/my.tsx
Normal file
15
components/icon/my.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const MyIcon = () => (
|
||||
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<G clipPath="url(#clip0_5062_503)">
|
||||
<Path d="M3.87988 5.11999C3.87988 5.66103 3.98645 6.19678 4.1935 6.69664C4.40055 7.1965 4.70402 7.65068 5.0866 8.03326C5.46917 8.41583 5.92336 8.71931 6.42322 8.92636C6.92308 9.13341 7.45883 9.23997 7.99987 9.23997C8.54091 9.23997 9.07666 9.13341 9.57652 8.92636C10.0764 8.71931 10.5306 8.41583 10.9131 8.03326C11.2957 7.65068 11.5992 7.1965 11.8062 6.69664C12.0133 6.19678 12.1199 5.66103 12.1199 5.11999C12.1199 4.0273 11.6858 2.97936 10.9131 2.20672C10.1405 1.43407 9.09256 1 7.99987 1C6.90718 1 5.85925 1.43407 5.0866 2.20672C4.31395 2.97936 3.87988 4.0273 3.87988 5.11999Z" fill="#F5F5F5" />
|
||||
<Path d="M1 12.9593C1 13.7657 1.74578 14.5392 3.07327 15.1094C4.40076 15.6797 6.20122 16.0001 8.07858 16.0001C9.95593 16.0001 11.7564 15.6797 13.0839 15.1094C14.4114 14.5392 15.1572 13.7657 15.1572 12.9593C15.1572 12.5599 14.9741 12.1645 14.6183 11.7956C14.2626 11.4267 13.7412 11.0914 13.0839 10.8091C12.4266 10.5267 11.6462 10.3027 10.7874 10.1499C9.92862 9.99711 9.00815 9.91846 8.07858 9.91846C7.149 9.91846 6.22853 9.99711 5.36972 10.1499C4.51091 10.3027 3.73057 10.5267 3.07327 10.8091C2.41596 11.0914 1.89456 11.4267 1.53882 11.7956C1.18309 12.1645 1 12.5599 1 12.9593Z" fill="#F5F5F5" />
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_5062_503">
|
||||
<Rect width="16" height="16" fill="white" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
7
components/icon/noNews.tsx
Normal file
7
components/icon/noNews.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const NoNewsIcon = () => (
|
||||
<Svg width="56" height="56" viewBox="0 0 56 56" fill="none">
|
||||
<Path d="M0 56.0001H56V7.77245e-05H0V56.0001Z" fill="white" />
|
||||
</Svg>
|
||||
);
|
||||
9
components/icon/omit.tsx
Normal file
9
components/icon/omit.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const OmitIcon = () => (
|
||||
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<Path d="M4.25 12C4.25 12.2298 4.29527 12.4574 4.38321 12.6697C4.47116 12.882 4.60006 13.0749 4.76256 13.2374C4.92507 13.3999 5.11798 13.5288 5.3303 13.6168C5.54262 13.7047 5.77019 13.75 6 13.75C6.22981 13.75 6.45738 13.7047 6.6697 13.6168C6.88202 13.5288 7.07493 13.3999 7.23744 13.2374C7.39994 13.0749 7.52884 12.882 7.61679 12.6697C7.70474 12.4574 7.75 12.2298 7.75 12C7.75 11.7702 7.70474 11.5426 7.61679 11.3303C7.52884 11.118 7.39994 10.9251 7.23744 10.7626C7.07493 10.6001 6.88202 10.4712 6.6697 10.3832C6.45738 10.2953 6.22981 10.25 6 10.25C5.77019 10.25 5.54262 10.2953 5.3303 10.3832C5.11798 10.4712 4.92507 10.6001 4.76256 10.7626C4.60006 10.9251 4.47116 11.118 4.38321 11.3303C4.29527 11.5426 4.25 11.7702 4.25 12Z" fill="#F5F5F5" />
|
||||
<Path d="M10.25 12C10.25 12.2298 10.2953 12.4574 10.3832 12.6697C10.4712 12.882 10.6001 13.0749 10.7626 13.2374C10.9251 13.3999 11.118 13.5288 11.3303 13.6168C11.5426 13.7047 11.7702 13.75 12 13.75C12.2298 13.75 12.4574 13.7047 12.6697 13.6168C12.882 13.5288 13.0749 13.3999 13.2374 13.2374C13.3999 13.0749 13.5288 12.882 13.6168 12.6697C13.7047 12.4574 13.75 12.2298 13.75 12C13.75 11.5359 13.5656 11.0908 13.2374 10.7626C12.9092 10.4344 12.4641 10.25 12 10.25C11.5359 10.25 11.0908 10.4344 10.7626 10.7626C10.4344 11.0908 10.25 11.5359 10.25 12Z" fill="#F5F5F5" />
|
||||
<Path d="M16.25 12C16.25 12.4641 16.4344 12.9092 16.7626 13.2374C17.0908 13.5656 17.5359 13.75 18 13.75C18.4641 13.75 18.9092 13.5656 19.2374 13.2374C19.5656 12.9092 19.75 12.4641 19.75 12C19.75 11.5359 19.5656 11.0908 19.2374 10.7626C18.9092 10.4344 18.4641 10.25 18 10.25C17.5359 10.25 17.0908 10.4344 16.7626 10.7626C16.4344 11.0908 16.25 11.5359 16.25 12Z" fill="#F5F5F5" />
|
||||
</Svg>
|
||||
);
|
||||
14
components/icon/plus.tsx
Normal file
14
components/icon/plus.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const PlusIcon = () => (
|
||||
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<Path
|
||||
d="M8 3V13M3 8H13"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
29
components/icon/points.tsx
Normal file
29
components/icon/points.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import Svg, { Path, Defs, LinearGradient, Stop } from 'react-native-svg';
|
||||
|
||||
interface PointsIconProps {
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export const PointsIcon = ({width = 14, height = 14, style}: PointsIconProps) => (
|
||||
<Svg width={width} height={height} viewBox="0 0 14 14" fill="none" style={style} preserveAspectRatio="xMidYMid meet">
|
||||
<Defs>
|
||||
<LinearGradient
|
||||
id="paint0_linear_2_1946"
|
||||
x1="7.00033"
|
||||
y1="1.73615"
|
||||
x2="7.00033"
|
||||
y2="12.2639"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<Stop offset="0" stopColor="#FFC738" />
|
||||
<Stop offset="1" stopColor="#FFA92D" />
|
||||
</LinearGradient>
|
||||
</Defs>
|
||||
<Path
|
||||
d="M10.297 6.5548H8.38602C8.21382 6.5548 8.08222 6.3994 8.11022 6.23L8.79062 2.0622C8.83542 1.7892 8.49802 1.624 8.30902 1.8256L3.49862 6.9734C3.33202 7.1526 3.45802 7.4452 3.70302 7.4452H5.61402C5.78622 7.4452 5.91782 7.6006 5.88982 7.77L5.20942 11.9378C5.16462 12.2108 5.50202 12.376 5.69102 12.1744L10.5014 7.0266C10.6694 6.8474 10.542 6.5548 10.297 6.5548Z"
|
||||
fill="url(#paint0_linear_2_1946)"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
27
components/icon/privacy.tsx
Normal file
27
components/icon/privacy.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const PrivacyIcon = () => (
|
||||
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<Path
|
||||
d="M4.16667 5.83333H15.8333C16.2754 5.83333 16.6993 6.00893 17.0119 6.32149C17.3244 6.63405 17.5 7.05797 17.5 7.5V15.8333C17.5 16.2754 17.3244 16.6993 17.0119 17.0119C16.6993 17.3244 16.2754 17.5 15.8333 17.5H4.16667C3.72464 17.5 3.30071 17.3244 2.98815 17.0119C2.67559 16.6993 2.5 16.2754 2.5 15.8333V7.5C2.5 7.05797 2.67559 6.63405 2.98815 6.32149C3.30071 6.00893 3.72464 5.83333 4.16667 5.83333Z"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M4.16667 5.83333L10 2.5L15.8333 5.83333"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M6.66667 10H13.3333"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
7
components/icon/rightArrow.tsx
Normal file
7
components/icon/rightArrow.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const RightArrowIcon = () => (
|
||||
<Svg width="6" height="10" viewBox="0 0 6 10" fill="none">
|
||||
<Path d="M4.47276 5L0.18567 8.9942C-0.0618901 9.22485 -0.0618901 9.59775 0.18567 9.82701C0.43323 10.0577 0.833477 10.0577 1.07956 9.82701L5.81433 5.4171C6.06189 5.18645 6.06189 4.81355 5.81433 4.58428L1.07956 0.172985C0.831995 -0.0576618 0.431748 -0.0576618 0.18567 0.172985C-0.0618901 0.403632 -0.0618901 0.776535 0.18567 1.0058L4.47276 5Z" fill="#F5F5F5"/>
|
||||
</Svg>
|
||||
);
|
||||
9
components/icon/sameStyle.tsx
Normal file
9
components/icon/sameStyle.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const SameStyleIcon = () => (
|
||||
<Svg width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<Path d="M27.7932 4.93529L23.4285 4L26.38 21.2176C26.6919 23.037 26.4942 24.9072 25.8086 26.6211L24.5713 29.7143L26.2706 28.2578C27.753 26.9871 28.7196 25.2182 28.9882 23.2843L30.917 9.39677C31.2053 7.32089 29.8424 5.37442 27.7932 4.93529Z" fill="#E6E6E6" />
|
||||
<Path d="M16.0806 2.99778L5.09443 4.99525C2.91464 5.39158 1.47222 7.48421 1.87745 9.66237L4.98561 26.3687C5.38851 28.5343 7.46644 29.9666 9.63366 29.5725L20.6198 27.5751C22.7996 27.1787 24.242 25.0861 23.8368 22.9079L20.7286 6.20163C20.3257 4.03603 18.2478 2.60374 16.0806 2.99778Z" fill="#E6E6E6" />
|
||||
<Path d="M10.5898 19.1379L9.94426 14.2966C9.8423 13.5319 10.608 12.9446 11.3201 13.2413L15.1931 14.8551C15.8502 15.1289 16.0189 15.982 15.5156 16.4853L12.2881 19.7128C11.7024 20.2985 10.6992 19.9589 10.5898 19.1379Z" fill="#070708" />
|
||||
</Svg>
|
||||
);
|
||||
14
components/icon/search.tsx
Normal file
14
components/icon/search.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<G clipPath="url(#clip0_5062_371)">
|
||||
<Path d="M15.9382 14.8633L18.7753 17.7009C18.8465 17.7712 18.903 17.855 18.9416 17.9473C18.9801 18.0396 19 18.1387 19 18.2388C19 18.3388 18.9801 18.4379 18.9416 18.5302C18.903 18.6226 18.8465 18.7063 18.7753 18.7766C18.6324 18.919 18.4391 18.9993 18.2374 19C18.0357 18.9993 17.8424 18.919 17.6996 18.7766L14.8634 15.94C13.3544 17.1819 11.4371 17.8174 9.4851 17.7228C7.53311 17.6281 5.68627 16.8101 4.30449 15.4281C3.20462 14.3281 2.4556 12.9266 2.15212 11.4009C1.84865 9.87519 2.00435 8.29377 2.59953 6.85655C3.19472 5.41932 4.20267 4.19084 5.49596 3.3264C6.78924 2.46195 8.3098 2.00037 9.86538 2C14.2089 2 17.7298 5.52136 17.7298 9.86581C17.7298 11.7287 17.0724 13.484 15.9377 14.8638L15.9382 14.8633ZM9.86538 3.52292C6.36433 3.52858 3.52816 6.36522 3.52249 9.86628C3.52249 13.3692 6.36244 16.2096 9.86538 16.2096C13.3683 16.2096 16.2078 13.3692 16.2078 9.86628C16.2078 6.36286 13.3683 3.52292 9.86538 3.52292Z" fill="#F5F5F5" />
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_5062_371">
|
||||
<Rect width="17" height="17" fill="white" x="2" y="2" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
14
components/icon/settings.tsx
Normal file
14
components/icon/settings.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const SettingsIcon = () => (
|
||||
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<G clipPath="url(#clip0_5062_1363)">
|
||||
<Path d="M17.7297 16.1303L11.7738 19.577C11.3032 19.8495 10.6945 19.9995 10.0601 19.9995C9.45766 19.9995 8.87644 19.862 8.42333 19.6132L2.40114 16.3015C1.43931 15.7728 0.705602 14.5604 0.694978 13.4811L0.625606 6.72452C0.614982 5.64582 1.32182 4.41901 2.27115 3.86967L8.22647 0.422967C8.69769 0.150481 9.30641 0.000488281 9.94076 0.000488281C10.5432 0.000488281 11.1238 0.137356 11.5769 0.386719L17.5991 3.6978C18.5609 4.22715 19.2947 5.43959 19.3053 6.51891L19.3747 13.2754C19.3859 14.3535 18.6784 15.5809 17.7297 16.1303ZM17.3398 6.53765C17.3366 6.1433 16.9873 5.56708 16.636 5.37396L10.6138 2.06226C10.4545 1.97476 10.2032 1.92289 9.94076 1.92289C9.66577 1.92289 9.39266 1.98164 9.22767 2.07726L3.27172 5.52396C2.92424 5.7252 2.58676 6.31017 2.59113 6.70515L2.6605 13.4617C2.66425 13.856 3.01361 14.4323 3.36484 14.626L9.38703 17.9371C9.54578 18.0246 9.79764 18.0771 10.0601 18.0771C10.3351 18.0771 10.6088 18.0177 10.7732 17.9227L16.7292 14.4754C17.076 14.2741 17.4135 13.6892 17.4098 13.2942L17.3398 6.53765ZM10.0001 14.141C7.66525 14.141 5.76597 12.2836 5.76597 9.99998C5.76597 7.71634 7.66525 5.85831 10.0001 5.85831C12.335 5.85831 14.2349 7.71634 14.2349 9.99998C14.2349 12.2836 12.335 14.141 10.0001 14.141ZM10.0001 7.78072C8.74957 7.78072 7.73149 8.77629 7.73149 9.99998C7.73149 11.223 8.74957 12.2186 10.0001 12.2186C11.2513 12.2186 12.2694 11.223 12.2694 9.99998C12.2694 8.77629 11.2513 7.78072 10.0001 7.78072Z" fill="#F5F5F5" />
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_5062_1363">
|
||||
<Rect width="20" height="20" fill="white" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
26
components/icon/terms.tsx
Normal file
26
components/icon/terms.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const TermsIcon = () => (
|
||||
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<Path
|
||||
d="M5.83333 2.5H14.1667C14.6087 2.5 15.0326 2.67559 15.3452 2.98815C15.6577 3.30071 15.8333 3.72464 15.8333 4.16667V15.8333C15.8333 16.2754 15.6577 16.6993 15.3452 17.0119C15.0326 17.3244 14.6087 17.5 14.1667 17.5H5.83333C5.39131 17.5 4.96738 17.3244 4.65482 17.0119C4.34226 16.6993 4.16667 16.2754 4.16667 15.8333V4.16667C4.16667 3.72464 4.34226 3.30071 4.65482 2.98815C4.96738 2.67559 5.39131 2.5 5.83333 2.5Z"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M6.66667 5.83333H13.3333"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<Path
|
||||
d="M10 5.83333V14.1667"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
24
components/icon/topArrow.tsx
Normal file
24
components/icon/topArrow.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const TopArrowIcon = () => (
|
||||
<Svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||
<G clipPath="url(#clip0_3_2634)">
|
||||
<Path
|
||||
d="M4.50684 2.4707C4.64648 2.33105 4.83594 2.20215 5.01465 2.24121C5.19434 2.21094 5.36328 2.35059 5.50293 2.4707L9.81641 6.80371C10.0352 7.02246 10.0352 7.38184 9.81641 7.60059C9.59766 7.81934 9.23828 7.81934 9.01953 7.60059L5.00488 3.47656L0.980469 7.60059C0.761719 7.81934 0.402344 7.81934 0.183594 7.60059C-0.0351562 7.38184 -0.0351562 7.02246 0.183594 6.80371L4.50684 2.4707Z"
|
||||
fill="#F5F5F5"
|
||||
stroke="#F5F5F5"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_3_2634">
|
||||
<Rect
|
||||
width="10"
|
||||
height="10"
|
||||
fill="white"
|
||||
transform="matrix(-1 0 0 -1 10 10)"
|
||||
/>
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
7
components/icon/unchecked.tsx
Normal file
7
components/icon/unchecked.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Circle } from 'react-native-svg'
|
||||
|
||||
export const UncheckedIcon = ({ className }: { className?: string }) => (
|
||||
<Svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<Circle cx="6" cy="6" r="5.5" stroke="#8A8A8A" strokeWidth="1" />
|
||||
</Svg>
|
||||
);
|
||||
7
components/icon/upload.tsx
Normal file
7
components/icon/upload.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const UploadIcon = () => (
|
||||
<Svg width="21" height="21" viewBox="0 0 21 21" fill="none">
|
||||
<Path d="M10.9373 10.9615L14.2787 14.302L13.1644 15.4164L11.7248 13.9768V18.375H10.1498V13.9752L8.71028 15.4164L7.59597 14.302L10.9373 10.9615ZM10.9373 2.625C12.2895 2.62506 13.5944 3.122 14.6041 4.02133C15.6138 4.92066 16.2578 6.15967 16.4136 7.50278C17.3934 7.76998 18.2483 8.37304 18.8286 9.20657C19.4089 10.0401 19.6779 11.051 19.5885 12.0627C19.4991 13.0744 19.057 14.0225 18.3395 14.7413C17.622 15.4601 16.6746 15.9039 15.6631 15.9952V14.4092C16.0255 14.3574 16.374 14.234 16.6881 14.0461C17.0023 13.8582 17.2759 13.6097 17.4929 13.3149C17.71 13.0201 17.8661 12.6851 17.9523 12.3293C18.0384 11.9734 18.0528 11.6041 17.9946 11.2427C17.9365 10.8812 17.807 10.5351 17.6136 10.2243C17.4201 9.91349 17.1667 9.64434 16.8682 9.4325C16.5696 9.22075 16.2318 9.07051 15.8746 8.99071C15.5173 8.91082 15.1477 8.90295 14.7874 8.96753C14.9108 8.39335 14.9041 7.79885 14.7679 7.22759C14.6318 6.65632 14.3695 6.12275 14.0004 5.66596C13.6313 5.20916 13.1647 4.84072 12.6347 4.58761C12.1048 4.33449 11.525 4.20312 10.9378 4.20312C10.3505 4.20312 9.7706 4.33449 9.2407 4.58761C8.71076 4.84072 8.24415 5.20916 7.87506 5.66596C7.50597 6.12275 7.24372 6.65632 7.10755 7.22759C6.97137 7.79885 6.9647 8.39335 7.08803 8.96753C6.36956 8.8326 5.62692 8.98861 5.02348 9.40126C4.42004 9.81391 4.00523 10.4493 3.87031 11.1678C3.73539 11.8863 3.8914 12.6289 4.30403 13.2324C4.71666 13.8358 5.35211 14.2506 6.07059 14.3855L6.21234 14.4092V15.9952C5.20075 15.9041 4.25335 15.4604 3.53573 14.7417C2.8181 14.0228 2.37589 13.0748 2.28638 12.063C2.19689 11.0513 2.46579 10.0404 3.04609 9.20675C3.62639 8.37317 4.48117 7.77004 5.46106 7.50278C5.61675 6.1596 6.26069 4.92049 7.2704 4.02113C8.28011 3.12177 9.58519 2.6249 10.9373 2.625Z" fill="#CCCCCC" />
|
||||
</Svg>
|
||||
);
|
||||
15
components/icon/video.tsx
Normal file
15
components/icon/video.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
|
||||
|
||||
export const VideoIcon = () => (
|
||||
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<G clipPath="url(#clip0_5062_492)">
|
||||
<Path d="M15.8854 4.81601C15.7094 4.32801 15.1574 3.76801 13.7254 3.60801C12.3254 1.92001 10.2294 0.984009 8.05338 0.984009C7.14938 0.984009 6.22938 1.14401 5.34138 1.48001C2.34938 2.59201 0.541379 5.40801 0.885379 8.39201C-0.122621 9.47201 -0.0906205 10.288 0.117379 10.784C0.357379 11.272 0.94938 11.896 2.51738 11.992C2.51738 11.992 5.74138 11.76 11.0134 9.74401C11.0134 9.74401 14.7334 7.96801 15.1494 7.14401C16.1254 6.09601 16.0934 5.31201 15.8854 4.81601ZM8.63738 5.03201C8.10938 5.03201 7.67738 4.60001 7.67738 4.05601C7.67738 3.51201 8.10938 3.08001 8.63738 3.08001C9.16538 3.08001 9.59738 3.51201 9.59738 4.05601C9.59738 4.60001 9.16538 5.03201 8.63738 5.03201Z" fill="#F5F5F5" />
|
||||
<Path d="M2.82129 12.664C4.19729 14.248 6.14929 15 8.23729 15.016C8.66929 15.016 9.11729 14.984 9.55729 14.904C9.99729 14.824 10.4293 14.704 10.8533 14.552C13.8213 13.464 15.3653 11.024 15.1493 8.104C11.0213 11.904 2.82129 12.664 2.82129 12.664Z" fill="#F5F5F5" />
|
||||
</G>
|
||||
<Defs>
|
||||
<ClipPath id="clip0_5062_492">
|
||||
<Rect width="16" height="16" fill="white" />
|
||||
</ClipPath>
|
||||
</Defs>
|
||||
</Svg>
|
||||
);
|
||||
7
components/icon/whitePoints.tsx
Normal file
7
components/icon/whitePoints.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const WhitePointsIcon = () => (
|
||||
<Svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<Path d="M10.297 6.55474H8.38602C8.21382 6.55474 8.08222 6.39934 8.11022 6.22994L8.79062 2.06214C8.83542 1.78914 8.49802 1.62394 8.30902 1.82554L3.49862 6.97334C3.33202 7.15254 3.45802 7.44514 3.70302 7.44514H5.61402C5.78622 7.44514 5.91782 7.60054 5.88982 7.76994L5.20942 11.9377C5.16462 12.2107 5.50202 12.3759 5.69102 12.1743L10.5014 7.02654C10.6694 6.84734 10.542 6.55474 10.297 6.55474Z" fill="white" />
|
||||
</Svg>
|
||||
);
|
||||
8
components/icon/whiteStar.tsx
Normal file
8
components/icon/whiteStar.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
export const WhiteStarIcon = () => (
|
||||
<Svg width="11" height="10" viewBox="0 0 11 10" fill="none">
|
||||
<Path d="M4.53107 1.26725C4.69215 0.831945 5.30785 0.831944 5.46893 1.26725L6.27067 3.43393C6.32131 3.57079 6.42921 3.67869 6.56607 3.72933L8.73275 4.53107C9.16806 4.69215 9.16806 5.30785 8.73275 5.46893L6.56607 6.27067C6.42921 6.32131 6.32131 6.42922 6.27067 6.56608L5.46893 8.73275C5.30785 9.16806 4.69215 9.16806 4.53107 8.73275L3.72933 6.56608C3.67869 6.42922 3.57079 6.32131 3.43393 6.27067L1.26725 5.46893C0.831944 5.30785 0.831944 4.69215 1.26725 4.53107L3.43393 3.72933C3.57078 3.67869 3.67869 3.57079 3.72933 3.43393L4.53107 1.26725Z" fill="#F5F5F5" />
|
||||
<Path d="M8.9791 7.17356C9.04353 6.99943 9.28981 6.99943 9.35424 7.17356L9.5849 7.79692C9.60516 7.85167 9.64832 7.89483 9.70307 7.91509L10.3264 8.14575C10.5006 8.21018 10.5006 8.45646 10.3264 8.52089L9.70307 8.75156C9.64832 8.77182 9.60516 8.81498 9.5849 8.86972L9.35424 9.49309C9.28981 9.66721 9.04353 9.66721 8.9791 9.49309L8.74843 8.86972C8.72817 8.81498 8.68501 8.77182 8.63027 8.75156L8.0069 8.52089C7.83278 8.45646 7.83278 8.21018 8.0069 8.14575L8.63027 7.91509C8.68501 7.89483 8.72817 7.85167 8.74843 7.79692L8.9791 7.17356Z" fill="#F5F5F5" />
|
||||
</Svg>
|
||||
);
|
||||
87
components/skeleton/AIGenerationRecordDrawerSkeleton.tsx
Normal file
87
components/skeleton/AIGenerationRecordDrawerSkeleton.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { View, StyleSheet, FlatList, Dimensions, Platform } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
const GAP = 2
|
||||
const NUM_COLUMNS = 3
|
||||
const ITEM_WIDTH = (screenWidth - GAP * 2) / NUM_COLUMNS
|
||||
|
||||
export function AIGenerationRecordDrawerSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 顶部标题栏骨架 */}
|
||||
<View style={styles.header}>
|
||||
<Skeleton width={120} height={18} borderRadius={4} />
|
||||
<View style={styles.closeButton}>
|
||||
<Skeleton width={24} height={24} borderRadius={12} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 图片网格骨架 */}
|
||||
<FlatList
|
||||
data={Array.from({ length: 30 }, (_, i) => i)}
|
||||
keyExtractor={(item) => item.toString()}
|
||||
numColumns={NUM_COLUMNS}
|
||||
contentContainerStyle={styles.imageGrid}
|
||||
renderItem={({ index }) => {
|
||||
const isLastInRow = (index + 1) % NUM_COLUMNS === 0
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.imageItem,
|
||||
{
|
||||
width: ITEM_WIDTH,
|
||||
marginRight: isLastInRow ? 0 : GAP,
|
||||
marginBottom: GAP,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Skeleton width="100%" height="100%" borderRadius={0} />
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#16181B',
|
||||
paddingTop: 12,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
position: 'relative',
|
||||
},
|
||||
closeButton: {
|
||||
position: 'absolute',
|
||||
right: 16,
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
imageGrid: {
|
||||
paddingHorizontal: 0,
|
||||
paddingBottom: Platform.OS === 'ios' ? 20 : 16,
|
||||
},
|
||||
imageItem: {
|
||||
// aspectRatio = width / height
|
||||
// 1 : 1.3 (width : height) => 1 / 1.3
|
||||
aspectRatio: 1 / 1.3,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#262A31',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
57
components/skeleton/ChannelsSkeleton.tsx
Normal file
57
components/skeleton/ChannelsSkeleton.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
export function ChannelsSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 标题栏骨架 */}
|
||||
<View style={styles.header}>
|
||||
<Skeleton width={60} height={16} borderRadius={4} />
|
||||
<Skeleton width={22} height={22} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 频道选择区域骨架 */}
|
||||
<View style={styles.channelsSection}>
|
||||
<View style={styles.channelsGrid}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13].map((item) => (
|
||||
<Skeleton
|
||||
key={item}
|
||||
width={80}
|
||||
height={32}
|
||||
borderRadius={100}
|
||||
style={styles.channelButton}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#0A0A0A',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingTop: 27,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
channelsSection: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 12,
|
||||
},
|
||||
channelsGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
channelButton: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
})
|
||||
|
||||
80
components/skeleton/GenerateVideoSkeleton.tsx
Normal file
80
components/skeleton/GenerateVideoSkeleton.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
export function GenerateVideoSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 顶部导航栏骨架 */}
|
||||
<View style={styles.header}>
|
||||
<Skeleton width={22} height={22} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
{/* 标题区域骨架 */}
|
||||
<View style={styles.titleSection}>
|
||||
<Skeleton width="70%" height={20} borderRadius={4} style={styles.title} />
|
||||
<Skeleton width="50%" height={14} borderRadius={4} style={styles.subtitle} />
|
||||
</View>
|
||||
|
||||
{/* 上传容器骨架 */}
|
||||
<Skeleton width="100%" height={140} borderRadius={12} style={styles.uploadContainer} />
|
||||
|
||||
{/* 上传参考图按钮骨架 */}
|
||||
<Skeleton width="100%" height={110} borderRadius={12} style={styles.uploadButton} />
|
||||
|
||||
{/* 描述输入区域骨架 */}
|
||||
<Skeleton width="100%" height={150} borderRadius={12} style={styles.descriptionInput} />
|
||||
|
||||
{/* 生成按钮骨架 */}
|
||||
<Skeleton width="100%" height={48} borderRadius={12} style={styles.generateButton} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingTop: 17,
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
content: {
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
backgroundColor: '#1C1E20',
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
gap: 8,
|
||||
},
|
||||
titleSection: {
|
||||
paddingHorizontal: 4,
|
||||
marginBottom: 11,
|
||||
gap: 5,
|
||||
},
|
||||
title: {
|
||||
marginBottom: 5,
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 0,
|
||||
},
|
||||
uploadContainer: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
uploadButton: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
descriptionInput: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
generateButton: {
|
||||
marginTop: 12,
|
||||
marginBottom: 10,
|
||||
},
|
||||
})
|
||||
|
||||
92
components/skeleton/GenerationRecordSkeleton.tsx
Normal file
92
components/skeleton/GenerationRecordSkeleton.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
export function GenerationRecordSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 顶部导航栏骨架 */}
|
||||
<View style={styles.header}>
|
||||
<Skeleton width={22} height={22} borderRadius={4} />
|
||||
<Skeleton width={80} height={16} borderRadius={4} />
|
||||
<View style={styles.headerSpacer} />
|
||||
</View>
|
||||
|
||||
{/* AI 视频标签骨架 */}
|
||||
<View style={styles.categorySection}>
|
||||
<Skeleton width={16} height={16} borderRadius={4} />
|
||||
<Skeleton width={60} height={16} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 原图标签骨架 */}
|
||||
<View style={styles.originalImageSection}>
|
||||
<Skeleton width={30} height={14} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 主图片骨架 */}
|
||||
<Skeleton
|
||||
width={screenWidth - 24}
|
||||
height={(screenWidth - 24) * 1.32}
|
||||
borderRadius={16}
|
||||
style={styles.imageContainer}
|
||||
/>
|
||||
|
||||
{/* 时长骨架 */}
|
||||
<Skeleton width={50} height={14} borderRadius={4} style={styles.durationText} />
|
||||
|
||||
{/* 底部操作按钮骨架 */}
|
||||
<View style={styles.actionButtons}>
|
||||
<Skeleton width={80} height={32} borderRadius={8} />
|
||||
<Skeleton width={80} height={32} borderRadius={8} />
|
||||
<Skeleton width={32} height={32} borderRadius={8} style={styles.deleteButton} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
headerSpacer: {
|
||||
width: 22,
|
||||
},
|
||||
categorySection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
gap: 4,
|
||||
},
|
||||
originalImageSection: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
imageContainer: {
|
||||
marginHorizontal: 12,
|
||||
marginBottom: 14,
|
||||
},
|
||||
durationText: {
|
||||
paddingLeft: 28,
|
||||
marginBottom: 22,
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
gap: 8,
|
||||
},
|
||||
deleteButton: {
|
||||
marginLeft: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
153
components/skeleton/MembershipSkeleton.tsx
Normal file
153
components/skeleton/MembershipSkeleton.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
export function MembershipSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 顶部导航栏骨架 */}
|
||||
<View style={styles.header}>
|
||||
<Skeleton width={32} height={32} borderRadius={100} />
|
||||
<View style={styles.headerSpacer} />
|
||||
<Skeleton width={100} height={32} borderRadius={100} />
|
||||
<Skeleton width={32} height={32} borderRadius={100} />
|
||||
</View>
|
||||
|
||||
{/* 订阅计划标题骨架 */}
|
||||
<Skeleton width={100} height={32} borderRadius={4} style={styles.sectionTitle} />
|
||||
|
||||
{/* 订阅计划卡片骨架 */}
|
||||
<View style={styles.plansContainer}>
|
||||
{[1, 2, 3].map((item) => (
|
||||
<View
|
||||
key={item}
|
||||
style={[
|
||||
styles.planCard,
|
||||
item > 1 && styles.planCardSpacing,
|
||||
]}
|
||||
>
|
||||
<Skeleton width={60} height={16} borderRadius={4} style={styles.planName} />
|
||||
<Skeleton width={80} height={28} borderRadius={4} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 积分每月卡片骨架 */}
|
||||
<View style={styles.pointsMonthlyContainer}>
|
||||
<View style={styles.pointsMonthlyCard}>
|
||||
<View style={styles.pointsMonthlyHeader}>
|
||||
<Skeleton width={14} height={14} borderRadius={4} />
|
||||
<Skeleton width={60} height={16} borderRadius={4} />
|
||||
<Skeleton width={50} height={14} borderRadius={4} />
|
||||
</View>
|
||||
<Skeleton width="100%" height={3} borderRadius={10} style={styles.progressBar} />
|
||||
<Skeleton width={120} height={12} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 功能列表骨架 */}
|
||||
{[1, 2, 3, 4, 5].map((item) => (
|
||||
<View key={item} style={styles.featureItem}>
|
||||
<Skeleton width={12} height={12} borderRadius={4} />
|
||||
<Skeleton width="80%" height={14} borderRadius={4} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 底部订阅按钮骨架 */}
|
||||
<View style={styles.subscribeContainer}>
|
||||
<Skeleton width="100%" height={48} borderRadius={12} style={styles.subscribeButton} />
|
||||
<View style={styles.agreementContainer}>
|
||||
<Skeleton width={12} height={12} borderRadius={4} />
|
||||
<Skeleton width={200} height={12} borderRadius={4} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 7,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
headerSpacer: {
|
||||
flex: 1,
|
||||
},
|
||||
sectionTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: 16,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
plansContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
planCard: {
|
||||
flex: 1,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 16,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
backgroundColor: '#16181B',
|
||||
gap: 14,
|
||||
},
|
||||
planCardSpacing: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
planName: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
pointsMonthlyContainer: {
|
||||
marginHorizontal: 16,
|
||||
backgroundColor: '#191B1F',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 24,
|
||||
gap: 12,
|
||||
},
|
||||
pointsMonthlyCard: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 10,
|
||||
backgroundColor: '#272A30',
|
||||
marginBottom: 24,
|
||||
gap: 10,
|
||||
},
|
||||
pointsMonthlyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
progressBar: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
subscribeContainer: {
|
||||
paddingTop: 16,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
gap: 8,
|
||||
},
|
||||
subscribeButton: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
agreementContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
})
|
||||
|
||||
65
components/skeleton/MessageSkeleton.tsx
Normal file
65
components/skeleton/MessageSkeleton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
export function MessageSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 标签选择器骨架 */}
|
||||
<View style={styles.segment}>
|
||||
<Skeleton width={40} height={20} borderRadius={4} />
|
||||
<Skeleton width={60} height={20} borderRadius={4} />
|
||||
<Skeleton width={40} height={20} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 消息卡片骨架 */}
|
||||
<View style={styles.cardsContainer}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<View key={item} style={styles.card}>
|
||||
<Skeleton width="60%" height={18} borderRadius={4} style={styles.cardTitle} />
|
||||
<Skeleton width="80%" height={14} borderRadius={4} style={styles.cardSubtitle} />
|
||||
<Skeleton width="100%" height={100} borderRadius={12} style={styles.cardBody} />
|
||||
<Skeleton width="40%" height={12} borderRadius={4} style={styles.cardTime} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
segment: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 19,
|
||||
paddingBottom: 12,
|
||||
gap: 16,
|
||||
},
|
||||
cardsContainer: {
|
||||
paddingHorizontal: 4,
|
||||
paddingTop: 12,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#16181B',
|
||||
borderRadius: 16,
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 4,
|
||||
},
|
||||
cardTitle: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
cardSubtitle: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
cardBody: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardTime: {
|
||||
marginTop: 0,
|
||||
},
|
||||
})
|
||||
|
||||
123
components/skeleton/MySkeleton.tsx
Normal file
123
components/skeleton/MySkeleton.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
const GALLERY_GAP = 2
|
||||
const GALLERY_HORIZONTAL_PADDING = 0
|
||||
const GALLERY_ITEM_SIZE = Math.floor(
|
||||
(screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3
|
||||
)
|
||||
|
||||
export function MySkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 顶部积分与设置骨架 */}
|
||||
<View style={styles.topBar}>
|
||||
<Skeleton width={80} height={32} borderRadius={100} />
|
||||
<Skeleton width={32} height={32} borderRadius={100} />
|
||||
</View>
|
||||
|
||||
{/* 个人信息区骨架 */}
|
||||
<View style={styles.profileSection}>
|
||||
<Skeleton width={64} height={64} borderRadius={32} />
|
||||
<View style={styles.profileInfo}>
|
||||
<Skeleton width={100} height={20} borderRadius={4} style={styles.profileName} />
|
||||
<Skeleton width={80} height={14} borderRadius={4} />
|
||||
</View>
|
||||
<Skeleton width={60} height={28} borderRadius={6} />
|
||||
</View>
|
||||
|
||||
{/* 标题行骨架 */}
|
||||
<View style={styles.sectionHeader}>
|
||||
<Skeleton width={80} height={18} borderRadius={4} />
|
||||
<Skeleton width={20} height={20} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 作品九宫格骨架 */}
|
||||
<View style={styles.galleryGrid}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((item) => (
|
||||
<View
|
||||
key={item}
|
||||
style={[
|
||||
styles.galleryItem,
|
||||
item % 3 !== 2 && styles.galleryItemMarginRight,
|
||||
styles.galleryItemMarginBottom,
|
||||
]}
|
||||
>
|
||||
<Skeleton
|
||||
width="100%"
|
||||
height={undefined}
|
||||
borderRadius={0}
|
||||
style={styles.gallerySkeleton}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
topBar: {
|
||||
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
|
||||
paddingTop: 19,
|
||||
paddingRight: 16,
|
||||
backgroundColor: '#090A0B',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 12,
|
||||
},
|
||||
profileSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
marginTop: 20,
|
||||
marginBottom: 32,
|
||||
backgroundColor: '#090A0B',
|
||||
gap: 16,
|
||||
},
|
||||
profileInfo: {
|
||||
flex: 1,
|
||||
gap: 4,
|
||||
},
|
||||
profileName: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
marginHorizontal: 12,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
galleryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 10,
|
||||
},
|
||||
galleryItem: {
|
||||
width: GALLERY_ITEM_SIZE,
|
||||
aspectRatio: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1C1E22',
|
||||
position: 'relative',
|
||||
},
|
||||
gallerySkeleton: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
galleryItemMarginRight: {
|
||||
marginRight: GALLERY_GAP,
|
||||
},
|
||||
galleryItemMarginBottom: {
|
||||
marginBottom: GALLERY_GAP,
|
||||
},
|
||||
})
|
||||
|
||||
55
components/skeleton/SearchResultsSkeleton.tsx
Normal file
55
components/skeleton/SearchResultsSkeleton.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
const horizontalPadding = 8 * 2
|
||||
const cardGap = 5
|
||||
const cardWidth = (screenWidth - horizontalPadding - cardGap) / 2
|
||||
|
||||
export function SearchResultsSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.gridContainer}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((item, index) => (
|
||||
<View
|
||||
key={item}
|
||||
style={[
|
||||
styles.card,
|
||||
{ width: cardWidth },
|
||||
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
|
||||
]}
|
||||
>
|
||||
<Skeleton
|
||||
width={cardWidth}
|
||||
height={cardWidth * 1.2}
|
||||
borderRadius={16}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 8,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardLeft: {
|
||||
marginRight: 0,
|
||||
},
|
||||
cardRight: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
})
|
||||
|
||||
89
components/skeleton/SearchTemplateSkeleton.tsx
Normal file
89
components/skeleton/SearchTemplateSkeleton.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
export function SearchTemplateSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 搜索历史骨架 */}
|
||||
<View style={styles.historySection}>
|
||||
<View style={styles.historyHeader}>
|
||||
<Skeleton width={80} height={18} borderRadius={4} />
|
||||
<Skeleton width={20} height={20} borderRadius={4} />
|
||||
</View>
|
||||
<View style={styles.historyTags}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Skeleton
|
||||
key={item}
|
||||
width={80}
|
||||
height={29}
|
||||
borderRadius={100}
|
||||
style={styles.historyTag}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 探索更多骨架 */}
|
||||
<View style={styles.exploreSection}>
|
||||
<View style={styles.exploreHeader}>
|
||||
<Skeleton width={80} height={18} borderRadius={4} />
|
||||
<Skeleton width={60} height={18} borderRadius={4} />
|
||||
</View>
|
||||
<View style={styles.exploreTags}>
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<Skeleton
|
||||
key={item}
|
||||
width="48%"
|
||||
height={20}
|
||||
borderRadius={4}
|
||||
style={styles.exploreTag}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
historySection: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
historyHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
historyTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginTop: 16,
|
||||
},
|
||||
historyTag: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
exploreSection: {
|
||||
marginTop: 0,
|
||||
},
|
||||
exploreHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
exploreTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
exploreTag: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
})
|
||||
|
||||
101
components/skeleton/SearchWorksResultsSkeleton.tsx
Normal file
101
components/skeleton/SearchWorksResultsSkeleton.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
const GALLERY_GAP = 1
|
||||
const GALLERY_ITEM_SIZE = Math.floor(
|
||||
(screenWidth - GALLERY_GAP * 2) / 3
|
||||
)
|
||||
|
||||
export function SearchWorksResultsSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 分类标签骨架 */}
|
||||
<View style={styles.categoryContainer}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Skeleton
|
||||
key={item}
|
||||
width={50}
|
||||
height={30}
|
||||
borderRadius={8}
|
||||
style={styles.categoryTag}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 作品列表骨架 */}
|
||||
<View style={styles.scrollContent}>
|
||||
{[1, 2].map((dateIndex) => (
|
||||
<View key={dateIndex}>
|
||||
<Skeleton width={100} height={14} borderRadius={4} style={styles.dateText} />
|
||||
<View style={styles.galleryGrid}>
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<View
|
||||
key={item}
|
||||
style={[
|
||||
styles.galleryItem,
|
||||
item % 3 !== 2 && styles.galleryItemMarginRight,
|
||||
styles.galleryItemMarginBottom,
|
||||
]}
|
||||
>
|
||||
<Skeleton
|
||||
width="100%"
|
||||
height={undefined}
|
||||
borderRadius={0}
|
||||
style={styles.gallerySkeleton}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
categoryContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 16,
|
||||
gap: 8,
|
||||
},
|
||||
categoryTag: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
dateText: {
|
||||
marginBottom: 8,
|
||||
paddingLeft: 14,
|
||||
},
|
||||
galleryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
galleryItem: {
|
||||
width: GALLERY_ITEM_SIZE,
|
||||
aspectRatio: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1C1E22',
|
||||
position: 'relative',
|
||||
},
|
||||
gallerySkeleton: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
galleryItemMarginRight: {
|
||||
marginRight: GALLERY_GAP,
|
||||
},
|
||||
galleryItemMarginBottom: {
|
||||
marginBottom: GALLERY_GAP,
|
||||
},
|
||||
})
|
||||
|
||||
53
components/skeleton/SearchWorksSkeleton.tsx
Normal file
53
components/skeleton/SearchWorksSkeleton.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
export function SearchWorksSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 搜索历史骨架 */}
|
||||
<View style={styles.historySection}>
|
||||
<View style={styles.historyHeader}>
|
||||
<Skeleton width={80} height={18} borderRadius={4} />
|
||||
<Skeleton width={20} height={20} borderRadius={4} />
|
||||
</View>
|
||||
<View style={styles.historyTags}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Skeleton
|
||||
key={item}
|
||||
width={80}
|
||||
height={29}
|
||||
borderRadius={100}
|
||||
style={styles.historyTag}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
paddingHorizontal: 12,
|
||||
},
|
||||
historySection: {
|
||||
paddingBottom: 32,
|
||||
},
|
||||
historyHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
historyTags: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginTop: 16,
|
||||
},
|
||||
historyTag: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
})
|
||||
|
||||
70
components/skeleton/TemplateDetailSkeleton.tsx
Normal file
70
components/skeleton/TemplateDetailSkeleton.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
const horizontalPadding = 8 * 2
|
||||
const cardGap = 5
|
||||
const cardWidth = (screenWidth - horizontalPadding - cardGap) / 2
|
||||
|
||||
export function TemplateDetailSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 标题区域骨架 */}
|
||||
<View style={styles.titleSection}>
|
||||
<Skeleton width="80%" height={24} borderRadius={4} style={styles.mainTitle} />
|
||||
<Skeleton width="60%" height={18} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 图片网格骨架 */}
|
||||
<View style={styles.gridContainer}>
|
||||
{[1, 2, 3, 4, 5, 6].map((item, index) => (
|
||||
<View
|
||||
key={item}
|
||||
style={[
|
||||
styles.card,
|
||||
{ width: cardWidth },
|
||||
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
|
||||
]}
|
||||
>
|
||||
<Skeleton
|
||||
width={cardWidth}
|
||||
height={cardWidth * 1.2}
|
||||
borderRadius={16}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
titleSection: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 24,
|
||||
gap: 4,
|
||||
},
|
||||
mainTitle: {
|
||||
marginBottom: 4,
|
||||
},
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 8,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
cardLeft: {
|
||||
marginRight: 0,
|
||||
},
|
||||
cardRight: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
})
|
||||
|
||||
159
components/skeleton/UploadReferenceImageDrawerSkeleton.tsx
Normal file
159
components/skeleton/UploadReferenceImageDrawerSkeleton.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { View, StyleSheet, FlatList, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
|
||||
const GAP = 2
|
||||
const NUM_COLUMNS = 3
|
||||
const ITEM_WIDTH = (screenWidth - GAP * 2) / NUM_COLUMNS
|
||||
|
||||
export function UploadReferenceImageDrawerSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 顶部标题栏骨架 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerTitles}>
|
||||
<Skeleton width={160} height={18} borderRadius={4} />
|
||||
<Skeleton width={140} height={16} borderRadius={4} style={styles.headerSubtitle} />
|
||||
</View>
|
||||
<Skeleton width={24} height={24} borderRadius={12} />
|
||||
</View>
|
||||
|
||||
{/* 标签切换骨架 */}
|
||||
<View style={styles.tabContainer}>
|
||||
<View style={styles.tab}>
|
||||
<View style={styles.tabIconContainer}>
|
||||
<Skeleton width={27} height={27} borderRadius={6} />
|
||||
</View>
|
||||
<Skeleton width={60} height={14} borderRadius={4} />
|
||||
</View>
|
||||
<View style={styles.tab}>
|
||||
<View style={styles.tabIconContainer}>
|
||||
<Skeleton width={26} height={26} borderRadius={6} />
|
||||
</View>
|
||||
<Skeleton width={80} height={14} borderRadius={4} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 筛选区域骨架 */}
|
||||
<View style={styles.filterContainer}>
|
||||
<View style={styles.categoryButton}>
|
||||
<Skeleton width={120} height={18} borderRadius={4} />
|
||||
<Skeleton width={16} height={16} borderRadius={8} />
|
||||
</View>
|
||||
<View style={styles.filterButtons}>
|
||||
<View style={styles.filterButton}>
|
||||
<Skeleton width={36} height={16} borderRadius={8} />
|
||||
</View>
|
||||
<View style={styles.filterButton}>
|
||||
<Skeleton width={36} height={16} borderRadius={8} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 图片网格骨架 */}
|
||||
<FlatList
|
||||
data={Array.from({ length: 30 }, (_, i) => i)}
|
||||
keyExtractor={(item) => item.toString()}
|
||||
numColumns={NUM_COLUMNS}
|
||||
renderItem={({ index }) => {
|
||||
const isLastInRow = (index + 1) % NUM_COLUMNS === 0
|
||||
const isLastRow = index >= Math.floor(30 / NUM_COLUMNS) * NUM_COLUMNS
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.imageItem,
|
||||
{
|
||||
width: ITEM_WIDTH,
|
||||
marginRight: isLastInRow ? 0 : GAP,
|
||||
marginBottom: isLastRow ? 0 : GAP,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Skeleton width="100%" height="100%" borderRadius={0} />
|
||||
</View>
|
||||
)
|
||||
}}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#16181B',
|
||||
paddingTop: 24,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
headerTitles: {
|
||||
gap: 4,
|
||||
},
|
||||
headerSubtitle: {
|
||||
marginTop: 2,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
gap: 8,
|
||||
marginBottom: 24,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
height: 52,
|
||||
backgroundColor: '#272A30',
|
||||
borderRadius: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
tabIconContainer: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: 9,
|
||||
},
|
||||
categoryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
filterButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#1C1E22',
|
||||
borderRadius: 100,
|
||||
height: 32,
|
||||
padding: 3,
|
||||
},
|
||||
filterButton: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
minWidth: 48,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
imageItem: {
|
||||
aspectRatio: 1 / 1.3,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#262A31',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
60
components/skeleton/VideoSkeleton.tsx
Normal file
60
components/skeleton/VideoSkeleton.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
|
||||
const TAB_BAR_HEIGHT = 83
|
||||
const videoHeight = screenHeight - TAB_BAR_HEIGHT
|
||||
|
||||
export function VideoSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.videoContainer, { height: videoHeight }]}>
|
||||
{/* 主视频区域骨架 */}
|
||||
<Skeleton width={screenWidth} height={videoHeight} borderRadius={0} />
|
||||
|
||||
{/* 左下角按钮骨架 */}
|
||||
<View style={styles.actionButtonLeft}>
|
||||
<Skeleton width={16} height={16} borderRadius={4} />
|
||||
<Skeleton width={80} height={14} borderRadius={4} />
|
||||
</View>
|
||||
|
||||
{/* 右下角按钮骨架 */}
|
||||
<View style={styles.actionButtonRight}>
|
||||
<Skeleton width={20} height={20} borderRadius={4} />
|
||||
<Skeleton width={50} height={12} borderRadius={4} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
videoContainer: {
|
||||
width: screenWidth,
|
||||
backgroundColor: '#090A0B',
|
||||
position: 'relative',
|
||||
},
|
||||
actionButtonLeft: {
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
bottom: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
actionButtonRight: {
|
||||
position: 'absolute',
|
||||
right: 13,
|
||||
bottom: 13,
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
},
|
||||
})
|
||||
|
||||
101
components/skeleton/WorksListSkeleton.tsx
Normal file
101
components/skeleton/WorksListSkeleton.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { View, StyleSheet, Dimensions } from 'react-native'
|
||||
import { Skeleton } from './skeleton'
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window')
|
||||
const GALLERY_GAP = 1
|
||||
const GALLERY_ITEM_SIZE = Math.floor(
|
||||
(screenWidth - GALLERY_GAP * 2) / 3
|
||||
)
|
||||
|
||||
export function WorksListSkeleton() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 分类标签骨架 */}
|
||||
<View style={styles.categoryContainer}>
|
||||
{[1, 2, 3, 4].map((item) => (
|
||||
<Skeleton
|
||||
key={item}
|
||||
width={50}
|
||||
height={30}
|
||||
borderRadius={8}
|
||||
style={styles.categoryTag}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 作品列表骨架 */}
|
||||
<View style={styles.scrollContent}>
|
||||
{[1, 2].map((dateIndex) => (
|
||||
<View key={dateIndex}>
|
||||
<Skeleton width={100} height={14} borderRadius={4} style={styles.dateText} />
|
||||
<View style={styles.galleryGrid}>
|
||||
{[1, 2, 3, 4, 5, 6].map((item) => (
|
||||
<View
|
||||
key={item}
|
||||
style={[
|
||||
styles.galleryItem,
|
||||
item % 3 !== 2 && styles.galleryItemMarginRight,
|
||||
styles.galleryItemMarginBottom,
|
||||
]}
|
||||
>
|
||||
<Skeleton
|
||||
width="100%"
|
||||
height={undefined}
|
||||
borderRadius={0}
|
||||
style={styles.gallerySkeleton}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#090A0B',
|
||||
},
|
||||
categoryContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 16,
|
||||
gap: 8,
|
||||
},
|
||||
categoryTag: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
dateText: {
|
||||
marginBottom: 8,
|
||||
paddingLeft: 14,
|
||||
},
|
||||
galleryGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
galleryItem: {
|
||||
width: GALLERY_ITEM_SIZE,
|
||||
aspectRatio: 1,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#1C1E22',
|
||||
position: 'relative',
|
||||
},
|
||||
gallerySkeleton: {
|
||||
width: '100%',
|
||||
height: undefined,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
galleryItemMarginRight: {
|
||||
marginRight: GALLERY_GAP,
|
||||
},
|
||||
galleryItemMarginBottom: {
|
||||
marginBottom: GALLERY_GAP,
|
||||
},
|
||||
})
|
||||
|
||||
17
components/skeleton/index.ts
Normal file
17
components/skeleton/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export { Skeleton } from './skeleton'
|
||||
export { MessageSkeleton } from './MessageSkeleton'
|
||||
export { MySkeleton } from './MySkeleton'
|
||||
export { VideoSkeleton } from './VideoSkeleton'
|
||||
export { ChannelsSkeleton } from './ChannelsSkeleton'
|
||||
export { GenerateVideoSkeleton } from './GenerateVideoSkeleton'
|
||||
export { GenerationRecordSkeleton } from './GenerationRecordSkeleton'
|
||||
export { MembershipSkeleton } from './MembershipSkeleton'
|
||||
export { SearchTemplateSkeleton } from './SearchTemplateSkeleton'
|
||||
export { SearchResultsSkeleton } from './SearchResultsSkeleton'
|
||||
export { SearchWorksSkeleton } from './SearchWorksSkeleton'
|
||||
export { SearchWorksResultsSkeleton } from './SearchWorksResultsSkeleton'
|
||||
export { TemplateDetailSkeleton } from './TemplateDetailSkeleton'
|
||||
export { WorksListSkeleton } from './WorksListSkeleton'
|
||||
export { UploadReferenceImageDrawerSkeleton } from './UploadReferenceImageDrawerSkeleton'
|
||||
export { AIGenerationRecordDrawerSkeleton } from './AIGenerationRecordDrawerSkeleton'
|
||||
|
||||
60
components/skeleton/skeleton.tsx
Normal file
60
components/skeleton/skeleton.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { View, StyleSheet, Animated, Easing } from 'react-native'
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
borderRadius?: number
|
||||
style?: any
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
width = '100%',
|
||||
height = 20,
|
||||
borderRadius = 4,
|
||||
style,
|
||||
}: SkeletonProps) {
|
||||
const animatedValue = useRef(new Animated.Value(0)).current
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 1,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(animatedValue, {
|
||||
toValue: 0,
|
||||
duration: 1000,
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
])
|
||||
)
|
||||
animation.start()
|
||||
return () => animation.stop()
|
||||
}, [animatedValue])
|
||||
|
||||
const opacity = animatedValue.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.3, 0.7],
|
||||
})
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
height,
|
||||
borderRadius,
|
||||
backgroundColor: '#1C1E22',
|
||||
opacity,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
57
components/themed-text.tsx
Normal file
57
components/themed-text.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { StyleSheet, Text, type TextProps } from 'react-native';
|
||||
|
||||
|
||||
export type ThemedTextProps = TextProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
|
||||
};
|
||||
|
||||
export function ThemedText({
|
||||
style,
|
||||
lightColor,
|
||||
darkColor,
|
||||
type = 'default',
|
||||
...rest
|
||||
}: ThemedTextProps) {
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
type === 'default' ? styles.default : undefined,
|
||||
type === 'title' ? styles.title : undefined,
|
||||
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
|
||||
type === 'subtitle' ? styles.subtitle : undefined,
|
||||
type === 'link' ? styles.link : undefined,
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
default: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
},
|
||||
defaultSemiBold: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: '600',
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 32,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
link: {
|
||||
lineHeight: 30,
|
||||
fontSize: 16,
|
||||
color: '#0a7ea4',
|
||||
},
|
||||
});
|
||||
14
components/themed-view.tsx
Normal file
14
components/themed-view.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { View, type ViewProps } from 'react-native';
|
||||
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
|
||||
export type ThemedViewProps = ViewProps & {
|
||||
lightColor?: string;
|
||||
darkColor?: string;
|
||||
};
|
||||
|
||||
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
|
||||
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
|
||||
|
||||
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
|
||||
}
|
||||
71
components/ui/Block.tsx
Normal file
71
components/ui/Block.tsx
Normal 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
80
components/ui/Img.tsx
Normal 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
|
||||
300
components/ui/ModalPortal.jsx
Normal file
300
components/ui/ModalPortal.jsx
Normal 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
9
components/ui/Overlay.js
Normal 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
48
components/ui/Text.tsx
Normal 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
253
components/ui/Toast.tsx
Normal 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
|
||||
428
components/ui/alert-dialog.tsx
Normal file
428
components/ui/alert-dialog.tsx
Normal 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
116
components/ui/button.tsx
Normal 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
151
components/ui/carousel.tsx
Normal 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 以支持 className(NativeWind)
|
||||
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 };
|
||||
137
components/ui/delete-confirm-dialog.tsx
Normal file
137
components/ui/delete-confirm-dialog.tsx
Normal 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
443
components/ui/drawer.tsx
Normal 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
402
components/ui/dropdown.tsx
Normal 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
8
components/ui/index.ts
Normal 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
66
components/ui/media.tsx
Normal 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 };
|
||||
142
components/ui/notifications/StartGeneratingNotification.tsx
Normal file
142
components/ui/notifications/StartGeneratingNotification.tsx
Normal 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
239
components/ui/tooltip.tsx
Normal 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
136
components/ui/video.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user