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()