Initial commit: expo-popcore-app
This commit is contained in:
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%',
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user