Initial commit: expo-popcore-app

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

View 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%',
},
})

View 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',
},
})

View 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',
},
})

View 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',
},
})

View 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%',
},
})