expo-duooomi-app/app/(tabs)/sync.tsx

794 lines
27 KiB
TypeScript

import { AntDesign, FontAwesome, Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'
import { Block, ConfirmModal, Text, Toast, VideoBox } from '@share/components'
import Img from '@share/components/Img'
import { FlashList } from '@shopify/flash-list'
import * as ImagePicker from 'expo-image-picker'
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Platform, ScrollView, View } from 'react-native'
import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { imgPicker } from '@/@share/apis'
import { useBleExplorer } from '@/ble'
import BannerSection from '@/components/BannerSection'
import { useFileUpload } from '@/hooks/actions/use-file-upload'
import { useTemplateActions } from '@/hooks/actions/use-template-actions'
import { useAuth } from '@/hooks/core/use-auth'
import { useTemplateGenerations } from '@/hooks/data/use-template-generations'
import { screenWidth } from '@/utils'
import { aniStorage } from '@/utils/aniStorage'
import { cn } from '@/utils/cn'
// ============ 小组件 ============
const SpinningLoader = memo(() => {
const rotation = useSharedValue(0)
useEffect(() => {
rotation.value = withRepeat(withTiming(360, { duration: 1000, easing: Easing.linear }), -1, false)
}, [])
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value}deg` }],
}))
return (
<Animated.View style={animatedStyle}>
<FontAwesome color="#FFE500" name="circle-o-notch" size={20} />
</Animated.View>
)
})
const DeviceItem = memo(({ device, onConnectToggle }: { device: any; onConnectToggle: (device: any) => void }) => {
const { name = 'Unknown Device', id, connected: isConnected } = device
return (
<Block className="relative mb-[16px] flex-row items-center overflow-hidden border-[3px] border-black bg-white p-[16px] shadow-large-black">
{isConnected && <Block className="absolute inset-y-0 left-0 w-[16px] border-r-[3px] border-black bg-accent" />}
<Block className="size-[48px] items-center justify-center border-[3px] border-black bg-gray-100 shadow-soft-black-10">
<Ionicons color="black" name="phone-portrait-outline" size={24} />
</Block>
<Block className="relative z-10 ml-[16px] flex-1 flex-row items-center justify-between gap-[16px]">
<Block className="flex-1">
<Block className="flex-1 flex-row gap-[8px]">
<Text className="break-words text-[18px] font-[900] italic">{name}</Text>
</Block>
<Block className="mt-[4px] flex-row gap-[8px]">
<Text
className={`border-2 border-black px-[6px] text-[10px] font-[900] italic ${isConnected ? 'bg-black text-accent' : 'bg-gray-200 text-gray-500'}`}
>
{isConnected ? '已连接' : '未连接'}
</Text>
</Block>
</Block>
<Block className="relative z-10 items-center">
<Block
className={`h-[40px] items-center justify-center border-[3px] border-black px-[16px] text-[12px] font-[900] italic ${isConnected ? 'bg-accent text-black shadow-medium-black' : 'bg-white text-black shadow-medium-gray'}`}
onClick={() => onConnectToggle(device)}
>
<Text className="text-[12px] font-[900] italic">{isConnected ? '已连接' : '连接'}</Text>
</Block>
</Block>
</Block>
</Block>
)
})
const GridItem = memo(
({
post,
isSelected,
isSelectionMode,
itemWidth,
isVisible,
onSelect,
}: {
post: any
isSelected: boolean
isSelectionMode: boolean
itemWidth: number
isVisible: boolean
onSelect: (post: any) => void
}) => {
// 渲染状态标记
const renderStatusBadge = () => {
const status = post.status
// 成功状态不显示标记
if (status === 'completed' || status === 'success') {
return null
}
// 失败状态
if (status === 'failed') {
return (
<Block
className="absolute left-[8px] top-[8px] z-20 flex-row items-center gap-[4px] border-2 border-black bg-[#e61e25] px-[8px] py-[4px] shadow-hard-black"
style={{ transform: [{ skewX: '6deg' }] }}
>
<Ionicons color="white" name="close-circle" size={14} />
<Text className="text-[10px] font-[900] italic text-white"></Text>
</Block>
)
}
// 其他状态显示加载中
return (
<Block
className="absolute left-[8px] top-[8px] z-20 flex-row items-center gap-[4px] border-2 border-black bg-white px-[8px] py-[4px] shadow-hard-black"
style={{ transform: [{ skewX: '6deg' }] }}
>
<SpinningLoader />
<Text className="text-[10px] font-[900] italic text-black"></Text>
</Block>
)
}
return (
<Block className="relative" onClick={() => onSelect(post)}>
<Block
className={`relative overflow-hidden border-2 ${isSelected ? 'shadow-[0px_0px_0px_4px_#FFE500]' : 'shadow-hard-black'} ${isSelected ? 'border-accent' : 'border-black'}`}
style={{ transform: [{ skewX: '-6deg' }], height: itemWidth, width: itemWidth }}
>
<Block style={{ height: itemWidth, width: itemWidth }}>
<Img className="size-full" src={post.imageUrl} />
</Block>
{isSelected && <Block className="absolute inset-0 border-[3px] border-accent" />}
{/* 状态标记 */}
{renderStatusBadge()}
{isSelectionMode && (
<Block
className="absolute inset-0 z-30 items-center justify-center"
style={{ transform: [{ skewX: '6deg' }] }}
>
<Block
className={`size-[32px] items-center justify-center rounded-full border-[3px] border-black ${isSelected ? 'bg-accent' : 'bg-white/50'}`}
>
<Ionicons color="black" name="checkmark" size={20} />
</Block>
</Block>
)}
</Block>
</Block>
)
},
)
const FilterButtons = memo(
({ isSelectionMode, onToggleSelectionMode }: { isSelectionMode: boolean; onToggleSelectionMode: () => void }) => {
return (
<Block className="flex flex-row items-center justify-between" style={{ marginVertical: 16 }}>
<Block className="flex-row">
{['我的生成'].map((label) => {
return (
<Block
key={label}
className={`-skew-x-3 border-2 border-black bg-black px-[16px] py-[4px] shadow-small-accent`}
>
<Text className={`text-[10px] font-[900] italic text-accent`}>{label}</Text>
</Block>
)
})}
</Block>
<Block
className={`border-2 border-black px-[16px] py-[6px] shadow-medium-black ${isSelectionMode ? 'bg-[#e61e25] text-white' : 'bg-accent text-black'}`}
onClick={onToggleSelectionMode}
>
<Text className="text-[12px] font-[900] italic">{isSelectionMode ? '取消' : '管理'}</Text>
</Block>
</Block>
)
},
)
const SelectionBar = memo(
({
isSelectionMode,
selectedCount,
onSelectAll,
onDelete,
}: {
isSelectionMode: boolean
selectedCount: number
onSelectAll: () => void
onDelete: () => void
}) => {
const insets = useSafeAreaInsets()
if (!isSelectionMode) return null
return (
<Block className="absolute inset-x-[16px] bottom-[96px] z-50" style={{ paddingBottom: insets.bottom }}>
<Block className="-skew-x-3 flex-row items-center justify-between border-[3px] border-black bg-white p-[12px] shadow-large-black">
<Block className="skew-x-3 flex-row items-center gap-[12px] pl-[8px]">
<Text className="text-[14px] font-[900] italic">: {selectedCount}</Text>
<Block className="text-[12px] underline" onClick={onSelectAll}>
<Text className="text-[12px] font-[700]"></Text>
</Block>
</Block>
<Block
className={`skew-x-3 flex-row items-center space-x-[8px] border-2 border-black px-[16px] py-[8px] font-[[90]0] text-[14px] shadow-medium-black${selectedCount > 0 ? 'bg-[#e61e25] text-white' : 'bg-gray-200 text-gray-400'}`}
onClick={onDelete}
>
<Ionicons color="black" name="trash-outline" size={16} />
<Text className="text-[14px] font-[900]">{selectedCount > 0 ? '删除' : '删除'}</Text>
</Block>
</Block>
</Block>
)
},
)
const FABButtons = memo(
({ onGenAgain, onSync, canSync }: { onGenAgain: () => void; onSync: () => void; canSync: boolean }) => {
const insets = useSafeAreaInsets()
return (
<Block className="absolute inset-x-[16px] bottom-[96px] z-40" style={{ paddingBottom: insets.bottom }}>
<Block className="w-full flex-row gap-[12px]">
<Block className="flex-1 -skew-x-6 shadow-large-black">
<Block
className={cn`relative h-[56px] items-center justify-center overflow-hidden border-[2px] border-black bg-[#e61e25] shadow-large-accent`}
onClick={onGenAgain}
>
<Block className="z-10 skew-x-6 flex-row items-center gap-[8px]">
<MaterialCommunityIcons color="white" name="star-four-points" size={20} />
<Text className="font-[bol]d text-[16px] italic text-white"></Text>
</Block>
</Block>
</Block>
<Block className="flex-1 -skew-x-6 shadow-large-accent">
<Block
className={cn`relative h-[56px] items-center justify-center overflow-hidden border-[2px] border-black bg-black`}
onClick={onSync}
>
<Block className="z-10 flex-row items-center gap-[8px]">
<Ionicons color={'white'} name="refresh" size={20} />
<Text className={cn`font-[bol]d bg-black text-[16px] italic text-white`}></Text>
</Block>
</Block>
</Block>
</Block>
</Block>
)
},
)
// ============ 主要组件部分 ============
const HeaderBanner = memo(({ connectedDevice, onPick }: { connectedDevice: any; onPick: () => void }) => {
return (
<Block className="relative z-40 flex-row items-center justify-between py-[12px]">
<Block className="flex-row items-center gap-[8px] border-2 border-white bg-black py-[4px] shadow-hard-accent">
<Block className={`size-[8px] ${connectedDevice ? 'bg-accent' : 'bg-red-500'}`} />
<Text className="text-[12px] font-[900] tracking-[1px] text-white">
{connectedDevice ? '设备已连接' : '设备离线'}
</Text>
</Block>
<Block
className="h-[40px] flex-row items-center justify-center gap-[8px] border-[3px] border-black bg-white px-[12px] shadow-hard-black"
onClick={onPick}
>
<Ionicons color="black" name="cloud-upload-outline" size={18} />
<Text className="text-[12px] font-[900] italic text-black"></Text>
</Block>
</Block>
)
})
const TopCircleSection = memo(
({
connectedDevice,
onStartConnect,
selectedItem,
}: {
connectedDevice: any
onStartConnect: () => void
selectedItem: any
}) => {
const outerRotate = useSharedValue(0)
useEffect(() => {
outerRotate.value = withRepeat(withTiming(360, { duration: 12000, easing: Easing.linear }), -1, false)
}, [])
const outerRingStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${outerRotate.value}deg` }],
}))
return (
<Block className="relative z-10 items-center">
<Block className="relative my-[24px] flex w-full flex-row items-center justify-between">
<Block
className={`w-[48px] items-center justify-center gap-[4px] border-[3px] border-black py-[12px] shadow-hard-black-50 ${connectedDevice ? 'bg-accent' : 'bg-white'}`}
onClick={onStartConnect}
>
<Ionicons
color={connectedDevice ? 'black' : '#6B7280'}
name={connectedDevice ? 'wifi' : 'wifi-outline'}
size={20}
/>
<Text className="text-[10px] font-[900] italic">{connectedDevice ? '已连接' : '选择设备'}</Text>
</Block>
<Block className="relative size-[256px] items-center justify-center">
<Block
animated
className="absolute inset-[-12px] rounded-full border-[3px] border-dashed border-black opacity-30"
style={outerRingStyle}
/>
<Block>
<GalleryRenderer selectedItem={selectedItem} />
{connectedDevice && (
<Block className="absolute inset-0 items-center justify-center">
{/* <Ionicons name="desktop-outline" size={48} color="white" /> */}
<Text className="border border-white bg-black px-[12px] py-[2px] text-[12px] font-[900] italic text-white">
线
</Text>
</Block>
)}
</Block>
</Block>
<Block className="h-[10px] w-[48px]" />
</Block>
</Block>
)
},
)
const GalleryRenderer = memo(({ selectedItem }: { selectedItem: any }) => {
const url = selectedItem?.url || selectedItem.imageUrl
console.log('GalleryRenderer------------', url)
if (!url) return null
const Width = 256
return (
<View
className="relative z-10 border-4 border-black"
style={{ width: Width, height: Width, borderRadius: Width, overflow: 'hidden' }}
>
<VideoBox style={{ width: Width, height: Width }} url={url} width={Width} />
</View>
)
})
const ManagerView = memo(
({
viewState,
onBack,
onConnectToggle,
}: {
viewState: string
onBack: () => void
onConnectToggle: (device: any) => void
}) => {
const { discoveredDevices } = useBleExplorer()
if (viewState !== 'manager') return null
return (
<Block className="absolute inset-0" style={{ zIndex: 100 }}>
<Block className="relative flex-1 bg-paper">
<Block className="flex-row items-center justify-between px-[16px] py-[12px]">
<Block
className="size-[40px] items-center justify-center border-[3px] border-black bg-white shadow-medium-black"
onClick={onBack}
>
<Ionicons color="black" name="chevron-back" size={24} />
</Block>
<Text className="text-[20px] font-[900] italic tracking-[-0.5px]"></Text>
<Block className="size-[40px] items-center justify-center bg-black/0">{/* 空占位符 */}</Block>
</Block>
<ScrollView className="relative flex-1 bg-paper">
<Block className="p-[16px]">
{discoveredDevices
.filter((i) => i.name)
.map((device) => (
<DeviceItem key={device.id} device={device} onConnectToggle={onConnectToggle} />
))}
</Block>
<Block className="h-[200px] w-full" />
</ScrollView>
</Block>
</Block>
)
},
)
// ============ 主组件 ============
const Sync = () => {
const { user } = useAuth()
const { data: generationsData, loading: generationsLoading, load: loadGenerations } = useTemplateGenerations()
const { uploadFile, loading: uploadLoading } = useFileUpload()
const { runTemplate, batchDeleteGenerations, loading: actionLoading } = useTemplateActions()
const [viewState, setViewState] = useState<'home' | 'manager' | 'scanner'>('home')
const [selectedItem, setSelectedItem] = useState({} as any)
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const {
connectedDevice,
startScan,
stopScan,
connectToDevice,
disconnectDevice,
transferMediaSingle,
} = useBleExplorer()
const itemWidth = Math.floor((screenWidth - 12 * 2 - 12 * 2) / 3)
// 加载生成记录
useEffect(() => {
if (user?.id) {
loadGenerations()
}
}, [user?.id, loadGenerations])
// 将生成记录转换为 posts 格式
const posts = useMemo(() => {
const generations = generationsData?.data || []
return generations.map((gen: any) => ({
id: gen.id,
imageUrl: Array.isArray(gen.resultUrl) ? gen.resultUrl[0] : gen.resultUrl,
originalUrl: gen.originalUrl,
templateId: gen.templateId,
type: gen.type,
status: gen.status,
createdAt: gen.createdAt,
title: `生成-${gen.id.slice(0, 6)}`,
rank: 'S',
author: user?.name || 'User',
avatarUrl:
user?.image || 'https://image.pollinations.ai/prompt/cool%20anime%20boy%20avatar%20hoodie?seed=123&nologo=true',
}))
}, [generationsData, user])
useEffect(() => {
if (!selectedItem?.id && posts.length > 0) {
const firstItem = posts[0]
const newItem = {
id: firstItem.id,
imageUrl: firstItem.imageUrl,
url: firstItem.imageUrl,
originalUrl: firstItem.originalUrl,
}
setSelectedItem(newItem)
}
}, [posts])
const viewableIds = useRef<Set<string>>(new Set(posts.map((p: any) => p.id)))
// 事件处理函数
const handleConnectToggle = useCallback(
async (item: any) => {
if (item.connected) {
disconnectDevice()
} else {
await disconnectDevice()
Toast.showLoading({
title: '连接中...',
duration: 30e3,
})
connectToDevice(item)
.then(() => {
console.log('设备连接成功')
})
.catch(() => {
Toast.show({
title: '设备连接失败',
})
})
.finally(() => {
Toast.hideLoading()
})
}
},
[connectToDevice, disconnectDevice],
)
const canSync = useMemo(() => {
return !!connectedDevice?.id && !!selectedItem?.imageUrl
}, [connectedDevice, selectedItem])
const handleSync = useCallback(async () => {
// await aniStorage.set('test_url', { test: 'data' })
console.log(
'aniStorage.has----------------',
await aniStorage.has('test_url'),
// await aniStorage.delete('test_url'),
// await aniStorage.get('test_url'),
)
if (!canSync) {
Toast.show({ title: '请先连接设备' })
return
}
Toast.show({
renderContent: () => (
<Block
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
>
<SpinningLoader />
<Block style={{ marginLeft: 8 }}>
<Text className="font-[bla]ck text-[14px] italic text-white">...</Text>
</Block>
</Block>
),
duration: 0,
})
transferMediaSingle(selectedItem?.imageUrl)
.then(() => {
Toast.show({ title: '同步成功' })
})
.catch(() => {
Toast.hideLoading()
Toast.show({ title: '同步失败' })
})
}, [canSync, selectedItem, transferMediaSingle])
const handlePick = useCallback(async () => {
const assetList = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' })
if (!assetList?.length) return
const result = assetList[0] as any
Toast.showLoading({ title: '上传中...', duration: 30e3 })
const file = {
name: result.fileName,
type: result.mimeType || 'image/jpeg',
uri: Platform.OS === 'android' ? result.uri : result.uri.replace('file://', ''),
}
const formData = new FormData()
formData.append('file', file as any)
const { url, error } = await uploadFile(file as any)
Toast.hideLoading()
if (error || !url) {
Toast.show({ title: '上传失败' })
return
}
const newItem = {
id: `local-${Date.now()}`,
// type: isVideo ? 'video' : 'image',
imageUrl: url,
url,
originalUrl: url,
// ...(typeof asset === 'object' ? asset : {}),
}
setSelectedItem(newItem)
// // 如果设备已连接,询问是否同步
// 先预览不直接同步上传
// if (connectedDevice?.id) {
// Toast.showModal(
// <ConfirmModal
// content={<Text className="text-[14px] leading-relaxed text-gray-800">文件已上传,是否立即同步到设备?</Text>}
// onConfirm={() => {
// Toast.hideModal()
// handleSync()
// }}
// onCancel={() => Toast.hideModal()}
// />,
// )
// } else {
// Toast.show({ title: '上传成功' })
// }
}, [uploadFile, connectedDevice, handleSync])
const startConnect = useCallback(() => {
setViewState('manager')
startScan()
}, [startScan])
const handleGenAgain = useCallback(() => {
if (!selectedItem?.templateId) {
Toast.show({ title: '请先选择一个生成记录' })
return
}
Toast.showModal(
<ConfirmModal
content={
<Text className="font-[bol]d text-[14px] leading-relaxed text-gray-800">
<Text className="font-[bla]ck mx-[4px] text-[20px] italic text-[#e61e25]">2 Goo</Text>{' '}
</Text>
}
onCancel={() => Toast.hideModal()}
onConfirm={handleGenAgainConfirm}
/>,
)
}, [selectedItem])
const handleGenAgainConfirm = useCallback(async () => {
if (!selectedItem?.templateId || !selectedItem?.originalUrl) return
Toast.hideModal()
Toast.show({
renderContent: () => (
<Block
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
>
<SpinningLoader />
<Block style={{ marginLeft: 8 }}>
<Text className="font-[bla]ck text-[14px] italic text-white">...</Text>
</Block>
</Block>
),
duration: 0,
})
const { generationId, error } = await runTemplate({
templateId: selectedItem.templateId,
data: {},
originalUrl: selectedItem.originalUrl,
})
Toast.hideLoading()
if (error || !generationId) {
Toast.show({ title: error?.message || '生成失败' })
return
}
Toast.show({
renderContent: () => (
<Block
className="flex w-full flex-row items-center justify-center border-[3px] border-white bg-black"
style={{ paddingVertical: 14, transform: [{ skewX: '-6deg' }] }}
>
<AntDesign color="white" name="check-circle" size={20} />
<Block style={{ marginLeft: 8 }}>
<Text className="font-[bla]ck text-[14px] italic text-white"></Text>
</Block>
</Block>
),
})
// 刷新列表
if (user?.id) {
loadGenerations()
}
}, [selectedItem, runTemplate, user?.id, loadGenerations])
const toggleSelectionMode = useCallback(() => {
setIsSelectionMode((v) => !v)
setSelectedIds(new Set())
}, [])
const handleItemSelect = useCallback(
(post: any) => {
if (isSelectionMode) {
const next = new Set(selectedIds)
if (next.has(post.id)) next.delete(post.id)
else next.add(post.id)
setSelectedIds(next)
} else {
setSelectedItem(post)
}
},
[isSelectionMode, selectedIds],
)
const handleDelete = useCallback(async () => {
if (selectedIds.size === 0) return
const { success, error } = await batchDeleteGenerations(Array.from(selectedIds))
if (error || !success) {
Toast.show({ title: error?.message || '删除失败' })
return
}
Toast.show({ title: `成功删除 ${selectedIds.size} 个生成记录` })
// 刷新列表
if (user?.id) {
await loadGenerations()
}
setSelectedIds(new Set())
setIsSelectionMode(false)
}, [selectedIds, batchDeleteGenerations, user?.id, loadGenerations])
const selectAll = useCallback(() => {
if (selectedIds.size === posts.length) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(posts.map((p: any) => p.id)))
}
}, [posts, selectedIds.size])
const onViewableItemsChanged = useCallback((params: any) => {
const { viewableItems } = params
viewableIds.current = new Set(viewableItems.map((v: any) => v.item.id))
}, [])
const renderHeader = useMemo(
() => (
<Block className="z-10">
<HeaderBanner connectedDevice={connectedDevice} onPick={handlePick} />
<TopCircleSection connectedDevice={connectedDevice} selectedItem={selectedItem} onStartConnect={startConnect} />
<FilterButtons isSelectionMode={isSelectionMode} onToggleSelectionMode={toggleSelectionMode} />
</Block>
),
[connectedDevice, handlePick, startConnect, isSelectionMode, toggleSelectionMode, selectedItem],
)
const renderGridItem = useCallback(
({ item: post }: { item: any }) => {
const isSelected = isSelectionMode ? selectedIds.has(post.id) : selectedItem?.id === post.id
const isVisible = viewableIds.current.has(post.id)
return (
<GridItem
isSelected={isSelected}
isSelectionMode={isSelectionMode}
isVisible={isVisible}
itemWidth={itemWidth}
post={post}
onSelect={handleItemSelect}
/>
)
},
[isSelectionMode, selectedIds, selectedItem, itemWidth, handleItemSelect],
)
return (
<Block className="relative flex-1">
<BannerSection />
<Block className="z-10 flex-1">
<FlashList
removeClippedSubviews
contentContainerStyle={{ paddingHorizontal: 12, paddingBottom: 200 }}
data={posts}
getItemType={() => 'row'}
ItemSeparatorComponent={() => <Block style={{ height: 6 }} />}
keyExtractor={(item: any) => item?.id}
ListHeaderComponent={renderHeader}
numColumns={3}
renderItem={renderGridItem}
viewabilityConfig={{ itemVisiblePercentThreshold: 60 }}
onViewableItemsChanged={onViewableItemsChanged}
/>
</Block>
<FABButtons canSync={canSync} onGenAgain={handleGenAgain} onSync={handleSync} />
<SelectionBar
isSelectionMode={isSelectionMode}
selectedCount={selectedIds.size}
onDelete={handleDelete}
onSelectAll={selectAll}
/>
<ManagerView
viewState={viewState}
onConnectToggle={handleConnectToggle}
onBack={() => {
setViewState('home')
stopScan()
}}
/>
</Block>
)
}
export default memo(Sync)