diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bd7c548 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "chatgpt.openOnStartup": true, +} \ No newline at end of file diff --git a/app/(tabs)/explore.tsx b/app/(tabs)/explore.tsx index 1921cc8..ae26571 100644 --- a/app/(tabs)/explore.tsx +++ b/app/(tabs)/explore.tsx @@ -18,6 +18,7 @@ import { Image } from 'expo-image' import { APP_VERSION } from '@/app.constants' import * as Sentry from '@sentry/react-native' +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' export default function TabTwoScreen() { const { @@ -197,10 +198,7 @@ export default function TabTwoScreen() { } return ( - } - > + BLE Explorer 插件当前版本号: {APP_VERSION} @@ -308,7 +306,7 @@ export default function TabTwoScreen() { onChangeText={setUserId} placeholder="输入用户ID" placeholderTextColor="#999" - editable={isConnected} + // editable={isConnected} /> @@ -325,7 +323,7 @@ export default function TabTwoScreen() { Write UUID: {BLE_UUIDS.WRITE_CHARACTERISTIC} Read UUID: {BLE_UUIDS.READ_CHARACTERISTIC} - + ) } diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index a259dff..54d9b0f 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -6,7 +6,7 @@ import { Dimensions, FlatList, TextInput, RefreshControl, ActivityIndicator } fr import { LinearGradient } from 'expo-linear-gradient' import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated' -import { router } from 'expo-router' +import { router, useRouter } from 'expo-router' import { IOS_UNIVERSAL_LINK } from '@/app.constants' import { FlashList } from '@shopify/flash-list' import { screenHeight, screenWidth } from '@/utils' @@ -51,11 +51,36 @@ const Banner = memo(function Banner({ bgVideo }) { type SearchOverlayProps = { isOpen: boolean - searchText: string - onChange: (v: string) => void + searchText?: string + onChange?: (v: string) => void onClose: () => void + onSearch: (v: string) => void } -const SearchOverlay = memo(function SearchOverlay({ isOpen, searchText, onChange, onClose }) { +const SearchOverlay = memo(function SearchOverlay({ isOpen, onChange, onClose, onSearch }) { + const timerRef = useRef | null>(null) + const [searchText, setSearchText] = useState('') + const handleTextChange = (text: string) => { + setSearchText(text) + onChange && onChange(text) + + if (timerRef.current) { + clearTimeout(timerRef.current) + } + + timerRef.current = setTimeout(() => { + onSearch(text) + }, 2000) + } + + useEffect(() => { + // 删除定时器 + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current) + } + } + }, []) + if (!isOpen) return null return ( @@ -65,9 +90,9 @@ const SearchOverlay = memo(function SearchOverlay({ isOpen, > (function SearchOverlay({ isOpen, > + + + 登出 + ) }) @@ -633,6 +665,11 @@ export default function Sync() { ) }, [activeTab, isAuthenticated]) + const handleSearch = useCallback((text: string) => { + console.log('Search for:', text) + // Implement your search logic here + }, []) + return ( @@ -659,7 +696,7 @@ export default function Sync() { ListEmptyComponent={renderListEmpty} /> - + setIsSearchOpen(false)} onSearch={handleSearch} /> ) } diff --git a/app/(tabs)/sync.tsx b/app/(tabs)/sync.tsx index 3eddc38..1ad7187 100644 --- a/app/(tabs)/sync.tsx +++ b/app/(tabs)/sync.tsx @@ -16,6 +16,8 @@ import { useTemplateGenerations } from '@/hooks/data/use-template-generations' import { useFileUpload } from '@/hooks/actions/use-file-upload' import { useTemplateActions } from '@/hooks/actions/use-template-actions' import { useAuth } from '@/hooks/core/use-auth' +import { aniStorage } from '@/utils/aniStorage' +import { get } from 'react-native/Libraries/TurboModule/TurboModuleRegistry' // ============ 常量定义 ============ const BACKGROUND_VIDEOS = [ @@ -462,7 +464,15 @@ const Sync = () => { return !!connectedDevice?.id && !!selectedItem?.imageUrl }, [connectedDevice, selectedItem]) - const handleSync = useCallback(() => { + 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 @@ -504,8 +514,12 @@ const Sync = () => { Toast.showLoading({ title: '上传中...', duration: 30e3 }) // 上传到云端 - const file = await fetch(uri).then((r) => r.blob()) - const { url, error } = await uploadFile(file, 'generations') + const fileBlob = await fetch(uri).then((r) => r.blob()) + const mimeType = fileBlob.type || (isVideo ? 'video/mp4' : 'image/jpeg') + + const file = new File([fileBlob], fileName, { type: mimeType }) + + const { url, error } = await uploadFile(file) Toast.hideLoading() diff --git a/app/_layout.tsx b/app/_layout.tsx index 2772271..5b053f0 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -78,10 +78,10 @@ function RootLayout() { return ( - - {/* */} + + ) diff --git a/app/auth.tsx b/app/auth.tsx index 4aa1130..1581e17 100644 --- a/app/auth.tsx +++ b/app/auth.tsx @@ -1,11 +1,11 @@ -import React, { useState, useMemo, useCallback } from 'react' +import React, { useState, useCallback } from 'react' import { Block, Text, Toast, VideoBox } from '@share/components' import { Ionicons } from '@expo/vector-icons' -import { ScrollView, Dimensions, TextInput, Platform } from 'react-native' +import { Dimensions, TextInput, Platform } from 'react-native' import { useAuth } from '@/hooks/core/use-auth' -import { KeyboardAvoidingView } from 'react-native-keyboard-controller' import { setAuthToken } from '@/lib/auth' +import { KeyboardAwareScrollView } from 'react-native-keyboard-controller' const BACKGROUND_VIDEOS = [ 'https://cdn.roasmax.cn/material/b46f380532e14cf58dd350dbacc7c34a.mp4', @@ -13,8 +13,6 @@ const BACKGROUND_VIDEOS = [ 'https://cdn.roasmax.cn/material/e4947477843f4067be7c37569a33d17b.mp4', ] - - type AuthMode = 'login' | 'register' export default function Auth() { @@ -28,8 +26,6 @@ export default function Auth() { const [confirmPassword, setConfirmPassword] = useState('') const [loading, setLoading] = useState(false) - const { height: screenHeight } = Dimensions.get('window') - const handleLogin = useCallback(async () => { if (!username || !password) { Toast.show({ title: '请填写账号和密码' }) @@ -38,20 +34,23 @@ export default function Auth() { setLoading(true) try { - const result = await signIn.username({ - username, - password, - }, { - onSuccess: async (ctx) => { - const authToken = ctx.response.headers.get("set-auth-token"); - if (authToken) { - setAuthToken(authToken) - } + const result = await signIn.username( + { + username, + password, }, - onError: (ctx) => { - console.error(`[LOGIN] login error`, ctx) - } - }) + { + onSuccess: async (ctx) => { + const authToken = ctx.response.headers.get('set-auth-token') + if (authToken) { + setAuthToken(authToken) + } + }, + onError: (ctx) => { + console.error(`[LOGIN] login error`, ctx) + }, + }, + ) if (result.error) { Toast.show({ title: result.error.message || '登录失败' }) @@ -90,7 +89,7 @@ export default function Auth() { name: username, }) - console.log('result------------', result); + // console.log('result------------', result) if (result.error) { Toast.show({ title: result.error.message || '注册失败' }) @@ -113,57 +112,106 @@ export default function Auth() { }, [mode, handleLogin, handleRegister]) return ( - - - - - - - - - - - - - - - LOOMART - + + + + + + + + + + + + LOOMART + + - - {(['login', 'register'] as const).map((tabMode) => { - const isActive = mode === tabMode - return ( - setMode(tabMode)} - className={`flex-1 items-center justify-center border-[3px] border-black py-[12px] shadow-hard-black ${isActive ? 'bg-accent' : 'bg-white'}`} - style={{ transform: [{ skewX: '-6deg' }] }} + + {(['login', 'register'] as const).map((tabMode) => { + const isActive = mode === tabMode + return ( + setMode(tabMode)} + className={`flex-1 items-center justify-center border-[3px] border-black py-[12px] shadow-hard-black ${isActive ? 'bg-accent' : 'bg-white'}`} + style={{ transform: [{ skewX: '-6deg' }] }} + > + - - {tabMode === 'login' ? '登录' : '注册'} - - - ) - })} - + {tabMode === 'login' ? '登录' : '注册'} + + + ) + })} + - - - - {mode === 'login' ? ( + + + + {mode === 'login' ? ( + + 账号 + + + + + + ) : ( + <> - 账号 - + 邮箱 + + + + + + + + 用户名 + - ) : ( - <> - - 邮箱 - - - - - + + )} - - 用户名 - - - - - - - )} + + 密码 + + + + + + {mode === 'register' && ( - 密码 - + 确认密码 + + )} - {mode === 'register' && ( - - 确认密码 - - - - - - )} - - - {loading ? ( - - ) : ( - - )} - {loading ? '处理中...' : mode === 'login' ? '登录' : '注册'} - - - {mode === 'login' && ( - - 忘记密码?点击找回 - - )} + + {loading ? : } + {loading ? '处理中...' : mode === 'login' ? '登录' : '注册'} + + {mode === 'login' && ( + + 忘记密码?点击找回 + + )} + - - © 2025 LOOMART. All rights reserved. - + + © 2025 LOOMART. All rights reserved. - - - + + + + ) } diff --git a/app/pointList.tsx b/app/pointList.tsx index bc41aba..0ca6ae6 100644 --- a/app/pointList.tsx +++ b/app/pointList.tsx @@ -134,7 +134,11 @@ export default function ChargePage() { className="absolute bottom-0 left-0 right-0 border-t-[1px] border-white/5 bg-[#1C1E22]/95 px-[16px]" style={{ paddingBottom: insets.bottom + 12, paddingTop: 16 }} > - + 确认充值 diff --git a/utils/aniStorage.ts b/utils/aniStorage.ts new file mode 100644 index 0000000..13b42ac --- /dev/null +++ b/utils/aniStorage.ts @@ -0,0 +1,139 @@ +import * as FileSystem from 'expo-file-system/legacy' + +/** + * 基于 Expo FileSystem 的持久化缓存系统,用于存储 .ani 文件数据 + * 键是 URL,值是 ani 文件内容 + */ + +// 定义 ani 文件内容的类型 +type AniData = object | string + +const CACHE_FOLDER = `${FileSystem.cacheDirectory}ani_cache/` + +/** + * 将 URL 转换为合法的文件名 + * + * 为什么需要这样做? + * 1. URL 中包含文件系统不允许的特殊字符(如 /, :, ?, & 等)。 + * 2. URL 的长度可能超过操作系统对文件名的长度限制(通常为 255 字符)。 + * 3. 简单的哈希算法可以将任意长度的 URL 转换为简短且唯一的标识符。 + */ +const hashUrl = (url: string): string => { + let hash = 0 + for (let i = 0; i < url.length; i++) { + const char = url.charCodeAt(i) + hash = (hash << 5) - hash + char + hash |= 0 // Convert to 32bit integer + } + // 使用 hex 字符串和长度作为后缀来减少碰撞概率 + return `${hash.toString(16)}_${url.length}` +} + +class AniStorage { + constructor() { + this.ensureDir() + } + + /** + * 确保存储目录存在 + */ + private async ensureDir() { + try { + // 直接尝试创建目录,intermediates: true 会处理父目录不存在的情况 + // 如果目录已存在,通常不会报错,或者我们在 catch 中忽略 + await FileSystem.makeDirectoryAsync(CACHE_FOLDER, { intermediates: true }) + } catch (error) { + // 忽略目录已存在的错误 + } + } + + /** + * 获取缓存文件路径 + */ + private getFilePath(url: string): string { + const filename = hashUrl(url) + return `${CACHE_FOLDER}${filename}.json` + } + + /** + * 获取缓存中的 ani 数据 + * @param url 资源的 URL + * @returns 缓存的数据,如果不存在则返回 undefined + */ + async get(url: string): Promise { + try { + const filePath = this.getFilePath(url) + // 移除 getInfoAsync 检查,直接尝试读取 + // 如果文件不存在,readAsStringAsync 会抛出错误,我们在 catch 中处理 + const content = await FileSystem.readAsStringAsync(filePath) + try { + return JSON.parse(content) + } catch { + return content + } + } catch (error) { + // 文件不存在或读取失败时返回 undefined + return undefined + } + } + + /** + * 设置 ani 数据到缓存中 + * @param url 资源的 URL + * @param data ani 文件数据 + */ + async set(url: string, data: AniData): Promise { + try { + await this.ensureDir() + const filePath = this.getFilePath(url) + const content = typeof data === 'string' ? data : JSON.stringify(data) + await FileSystem.writeAsStringAsync(filePath, content) + } catch (error) { + console.warn('AniCache set error:', error) + } + } + + /** + * 检查缓存中是否存在该 URL (异步) + * @param url 资源的 URL + */ + async has(url: string): Promise { + try { + const filePath = this.getFilePath(url) + // 由于 getInfoAsync 被弃用,这里尝试读取文件来检查是否存在 + await FileSystem.readAsStringAsync(filePath) + return true + } catch { + return false + } + } + + /** + * 清除所有缓存 + */ + async clear(): Promise { + try { + // idempotent: true 使得如果文件/目录不存在也不会报错 + await FileSystem.deleteAsync(CACHE_FOLDER, { idempotent: true }) + await this.ensureDir() + } catch (error) { + console.warn('AniCache clear error:', error) + } + } + + /** + * 删除指定 URL 的缓存 + * @param url 资源的 URL + */ + async delete(url: string): Promise { + try { + const filePath = this.getFilePath(url) + await FileSystem.deleteAsync(filePath, { idempotent: true }) + } catch (error) { + console.warn('AniCache delete error:', error) + } + } +} + +// 导出单例实例 +export const aniStorage = new AniStorage()