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

31
.easignore Normal file
View File

@@ -0,0 +1,31 @@
# Exclude other apps directories
../apps/*
!../apps/expo-duooomi-app
# Exclude unused packages from the monorepo
../packages/eslint-config/*
../packages/flowgraph/*
../packages/flowgraph-core/*
# Exclude root level files that aren't needed for this app
../.agent/
../.gitea/
../.vscode/
../BETTER_AUTH_体系交接文档.md
../CLAUDE.md
../FLOWGRAPH_交接文档.md
../FLOWGRAPH_思维导图.md
../STRIPE_CREDIT_BURNDOWN_交接文档.md
../ai-codegen-guideline.md
../debug-credit-burndown.js
../README.md
# Exclude development and configuration files
../.gitignore
../.prettierignore
../.prettierrc
# Only include necessary package management files
!../pnpm-workspace.yaml
!../package.json
!../pnpm-lock.yaml

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

40
.prettierignore Normal file
View File

@@ -0,0 +1,40 @@
# Dependencies
node_modules/
# Build outputs
dist/
build/
.expo/
# Generated files
*.generated.*
*.d.ts
# Config files that shouldn't be formatted
.env*
*.log
# Native files
ios/
android/
# Assets
assets/
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.ico
# Documentation
*.md
# Git
.git/
.gitignore
# Other
coverage/
.nyc_output/

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"singleQuote": true,
"endOfLine": "auto",
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"semi": false,
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"jsxSingleQuote": false,
"useTabs": false,
"quoteProps": "as-needed",
"plugins": ["prettier-plugin-tailwindcss"]
}

1
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

102
CLAUDE.md Normal file
View File

@@ -0,0 +1,102 @@
这是一个expo项目技术栈 tailwindcss + react native + zustand 状态管理
设计稿宽度是375px如果用户发送了样式代码需要将它转换成tailwindcss
常用库:
```tsx
import { mediaDevices, RTCView } from "react-native-webrtc";
import { Canvas, type CanvasRef } from "react-native-wgpu";
import { Animated, StyleSheet, Text, View, PixelRatio } from "react-native";
export { ErrorBoundary, Stack } from "expo-router";
import { NativeStackNavigationOptions } from "@react-navigation/native-stack";
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';
import Constants from 'expo-constants';
import { Video } from "expo-av";
import { useFonts } from "expo-font";
import { VictoryBar, VictoryChart } from "victory-native";
import "@expo/metro-runtime";
import * as SQLite from "expo-sqlite";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { createStore } from "tinybase";
import { createLocalPersister } from "tinybase/persisters/persister-browser";
import { createExpoSqlitePersister } from "tinybase/persisters/persister-expo-sqlite";
import {
Provider,
useAddRowCallback,
useCreatePersister,
useCreateStore,
useDelTableCallback,
useHasTable,
useRow,
useSetCellCallback,
useSortedRowIds,
} from "tinybase/ui-react";
import { GLView } from "expo-gl";
import { Renderer, TextureLoader } from "expo-three";
import { Camera } from "expo-camera";
import { cameraWithTensors } from '@tensorflow/tfjs-react-native';
import * as tf from "@tensorflow/tfjs";
import {
useCssElement,
useNativeVariable as useFunctionalVariable,
} from "react-native-css";
import Animated from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import styled from "styled-components/native";
import { handleURLCallback, StripeProvider } from "@stripe/stripe-react-native";
import type { Stripe } from "stripe";
import {
SQLiteProvider,
useSQLiteContext,
type SQLiteDatabase,
} from 'expo-sqlite';
import * as SplashScreen from "expo-splash-screen";
import { Asset } from "expo-asset";
import * as Updates from "expo-updates";
import io from "socket.io-client";
```
- 状态管理
```tsx
import create from "zustand";
const initialState = {
items: [],
};
export const useStore = create((set, get) => {
return Object.assign(initialState, {
items: [],
addItem(text) {
const items = get().items;
set({ items: [...items, { text, id: Math.random() }] });
},
});
});
export function useReset() {
useStore.setState(initialState);
}
```
```tsx
import shallow from "zustand/shallow";
import { useReset, useStore } from "./store";
const { items, addItem } = useStore(
({ addItem, items }) => ({
items,
addItem,
}),
shallow
);
<Button onPress={() => useReset()} title="reset" />
```

289
README.md Normal file
View File

@@ -0,0 +1,289 @@
# 协议三端通信说明
## 1. 数据格式
### 1.1 基本帧结构
所有数据包遵循以下格式(通常为大端序):
| 字段 (Field) | 长度 (Bytes) | 值 / 说明 |
| :--- | :--- | :--- |
| **HEADER** | 3 | `FEDCBA` |
| **CMD** | 2 | 命令字 (如 `E100`) |
| **DATA** | n | 数据内容 (0 ~ n 字节) |
| **CHECKSUM** | 1 | 校验和 |
| **TAIL** | 2 | `00EF` |
### 1.2 错误反馈 (系统级)
当接收到的数据包格式错误时,设备会返回以下错误码:
| 错误类型 | CMD (上报) | 参数 (DATA) | 完整 HEX 示例 |
| :--- | :--- | :--- | :--- |
| HEADER 错误 | `E0E0` | `C0` | `FEDCBA E0E0 C0 00EF` |
| TAIL 错误 | `E0E0` | `C1` | `FEDCBA E0E0 C1 00EF` |
| CHECKSUM 错误 | `E0E2` | `C0` | `FEDCBA E0E2 C0 00EF` |
| CMD 不支持 | `E0E3` | `[CMD]` | `FEDCBA E0E3 [CMD] XX 00EF` |
---
## 2. 蓝牙通道说明
* **OTA 通道**:
* Write: `ae01`
* Notify: `ai02` (或 `ae02`)
* **APP 通信通道**:
* Write: `ae10` (MTU 建议设置为 517)
* Notify: `ae02`
---
## 3. 命令详解
### 3.1 绑定 (0xE100)
**方向**: APP -> 设备
* **发送**: `FEDCBA E100 E1 00EF`
* **返回**:
| 状态 | CMD | 参数 | 完整 HEX 示例 |
| :--- | :--- | :--- | :--- |
| 成功 | `E1A0` | `81` | `FEDCBA E1A0 81 00EF` |
| 失败 | `E1A1` | `82` | `FEDCBA E1A1 82 00EF` |
| 异常 | `E1A2` | `83` | `FEDCBA E1A2 83 00EF` |
### 3.2 绑定失败上报 (0xE200)
**方向**: APP -> 设备 (通常用于APP端绑定失败后通知设备)
* **发送**: `FEDCBA E200 XX 00EF`
* **返回**:
| 状态 | CMD | 参数 | 完整 HEX 示例 |
| :--- | :--- | :--- | :--- |
| 成功 | `E2A0` | `XX` | `FEDCBA E2A0 XX 00EF` |
| 失败 | `E2A1` | `XX` | `FEDCBA E2A1 XX 00EF` |
| 异常 | `E2A2` | `XX` | `FEDCBA E2A2 XX 00EF` |
### 3.3 写用户 ID (0xE300)
**方向**: APP -> 设备
* **发送**: `FEDCBA E300 [UserID] XX 00EF`
* `UserID`: 用户ID数据
* **返回**:
| 状态 | CMD | 参数 | 完整 HEX 示例 |
| :--- | :--- | :--- | :--- |
| 成功 | `E3A0` | `83` | `FEDCBA E3A0 83 00EF` |
| 失败 | `E3A1` | `84` | `FEDCBA E3A1 84 00EF` |
| 异常(空) | `E3A3` | `85` | `FEDCBA E3A3 85 00EF` |
### 3.4 查询密锁 (0xE400)
**方向**: APP -> 设备
* **发送**: `FEDCBA E400 E4 00EF`
* **返回**: `FEDCBA E4A0 [TOKEN] XX 00EF`
* `TOKEN`: 32位 (4字节) 密锁数据。
### 3.5 设置时间 (0xE500)
**方向**: APP -> 设备
* **发送**: `FEDCBA E500 [Time] XX 00EF`
* `Time`: 格式 `yyyymmddhhmmss` (14字节 ASCII)
* **返回**:
| 状态 | CMD | 参数 | 完整 HEX 示例 |
| :--- | :--- | :--- | :--- |
| 成功 | `E5A0` | `85` | `FEDCBA E5A0 85 00EF` |
| 失败 | `E5A1` | `86` | `FEDCBA E5A1 86 00EF` |
| 异常 | `E5A2` | `87` | `FEDCBA E5A2 87 00EF` |
### 3.6 查询固件版本号 (0xE600)
**方向**: APP -> 设备
* **发送**: `FEDCBA E600 E6 00EF`
* **返回**: `FEDCBA E6A0 [Version] XX 00EF`
* `Version`: 格式 `yyyymmddhh` (10字节 ASCII)
### 3.7 查询资源版本号 (0xE700 / 0xD700)
**方向**: APP -> 设备
* **版本 < V4.0**:
* 发送: `FEDCBA E700 E7 00EF`
* 返回: `FEDCBA E7A0 [ResVersion] XX 00EF`
* **版本 >= V4.0**:
* 发送: `FEDCBA D700 E7 00EF`
* 返回: `FEDCBA D7A0 [ResVersion] XX 00EF`
### 3.8 设置资源版本号 (0xE800)
**方向**: APP -> 设备
* **发送**: `FEDCBA E800 [TT] [ResVersion] XX 00EF`
* **返回**:
| 状态 | CMD | 参数 | 完整 HEX 示例 |
| :--- | :--- | :--- | :--- |
| 成功 | `E8A0` | `88` | `FEDCBA E8A0 88 00EF` |
| 失败 | `E8A1` | `89` | `FEDCBA E8A1 89 00EF` |
| 异常 | `E8A2` | `8A` | `FEDCBA E8A2 8A 00EF` |
### 3.9 解绑 (0xE900)
**方向**: APP -> 设备
* **发送**: `FEDCBA E900 E0 00EF`
* **返回**: `FEDCBA E9A0 F3 00EF` (解绑成功)
### 3.10 下载文件 / OTA (0xEA00)
**方向**: APP -> 设备
此命令流程包含请求传输、数据传输、传输结束、取消传输等状态。
#### A. 启动传输请求
* **发送**: `FEDCBA EA00 [Type] [NameLen] [Name] [MD5] [Size] XX 00EF`
* `Type`: 文件类型 (1字节)
* `NameLen`: 文件名长度 (1字节)
* `Name`: 文件名 (N字节)
* `MD5`: 文件校验 (8字节)
* `Size`: 文件大小 (4字节, 16进制)
* **返回**:
| 状态 | CMD | 说明 | 附加数据 |
| :--- | :--- | :--- | :--- |
| 同意传输 | `EAA0` | 准备接收 | `8A` |
| 失败 | `EAA1` | 拒绝/错误 | `8B` |
| 异常 | `EAA2` | 参数错误 | `8C` |
| 文件相同 | `EAA3` | 无需传输 | `8D` |
| 断点续传 | `EAA4` | 从Offset开始 | `[Offset(4B)]` |
| OTA回复 | `EAA5` | OTA 错误码 | `[ErrCode]` |
#### B. 文件数据传输 (Packet)
* **发送**: `FEDCBA EA01 [Seq] [Len] [Data] XX 00EF`
* `Seq`: 序列号 (2字节)
* `Len`: 数据长度 (2字节)
* `Data`: 数据内容
* **返回 (每包/批量确认)**: `FEDCBA EAA6 [ErrCode] [Seq] [TotalLen] XX 00EF`
#### C. 传输完成
* **发送**: `FEDCBA EA02 EC 00EF`
* **返回**:
* 成功: `FEDCBA EAA7 91 00EF`
* 失败: `FEDCBA EAA8 [ErrCode] XX 00EF`
#### D. 取消传输
* **发送**: `FEDCBA EA03 ED 00EF`
* **返回**: `FEDCBA EAA9 [ErrCode] 8D 00EF`
### 3.11 查询设备 OTA 信息 (0xEB00)
**方向**: APP -> 设备
* **查询 OTA 信息**:
* 发送: `FEDCBA EB00 E0 00EF`
* 返回: `FEDCBA EBA0 [Err] [Ver] XX 00EF`
* **清除 OTA 状态**:
* 发送: `FEDCBA EB01 EC 00EF`
* 返回: `FEDCBA EBA7 92 00EF`
### 3.12 设备信息查询 (0xED00)
**方向**: APP -> 设备
* **发送**: `FEDCBA ED00 [TypeMask] XX 00EF`
* `TypeMask`: 2字节位掩码每一位代表请求的信息类型。
* **返回**:
* 成功: `FEDCBA EDA0 [Data] XX 00EF`
* 失败: `FEDCBA EDA1 ...`
* 异常: `FEDCBA EDA2 ...`
**支持的信息类型 (Bit定义)**:
| Bit | 信息内容 | 长度 (Bytes) | 说明 |
| :--- | :--- | :--- | :--- |
| 0 | 当前电量 | 1 | 0~100 |
| 1 | 当前音量 | 1 | 0~3 |
| 2 | SD卡挂载状态 | 1 | 0:未挂载, 1:挂载 |
| 3 | SD卡总容量 | 4 | 单位 KB |
| 4 | SD卡剩余容量 | 4 | 单位 KB |
| 5 | BLE MAC 地址 | 6 | Hex format |
---
## 4. JSON 协议 (V2)
新版协议在原有基础上增加了 JSON 数据传输模式,支持更丰富的信息交互。
### 4.1 帧结构 (Frame Structure)
V2 协议使用特定的帧头 `0xC7` (App发) / `0xB0` (设备发) 包裹 JSON 数据。
| 字段 | 长度 (Bytes) | 说明 |
| :--- | :--- | :--- |
| **HEAD** | 1 | `0xC7` (App->Dev) / `0xB0` (Dev->App) |
| **TYPE** | 1 | 命令字 (如 `0x0D`) |
| **Subpage Total** | 2 | 分包总数 (大端) |
| **Current Page** | 2 | 当前包序号 (大端) |
| **Data Len** | 2 | 数据长度 (大端) |
| **DATA** | N | JSON 字符串 (UTF-8) |
| **CHECKSUM** | 1 | 校验和 (0 - Sum(0...End-1)) |
### 4.2 命令定义 (JSON Payload)
以下为 Data 字段中的 JSON 内容示例。
#### 4.2.1 激活状态 (0x01)
* **APP -> 设备**: (查询)
* **设备 -> APP**:
```json
{
"type": 0x01,
"state": 0x00 // 0:未激活, 1:已激活
}
```
#### 4.2.2 协议版本 (0x07)
* **设备 -> APP**:
```json
{
"type": 0x07,
"version": "2024.01"
}
```
#### 4.2.3 时间同步 (0x08)
* **APP -> 设备**:
```json
{
"type": 0x08,
"year": 2024,
"mon": 1,
"day": 1,
"hour": 12,
"min": 0,
"mes": 0
}
```
#### 4.2.4 设备信息上报 (0x0D)
* **设备 -> APP**:
```json
{
"type": 0x0d,
"allspace": 1000,
"freespace": 500,
"devname": "Loom",
"size": 0, // 0:圆屏, 1:方屏
"brand": 5
}
```
#### 4.2.5 身份核对 (0x0E)
* **APP -> 设备** (发送 12位码):
```json
{
"type": 0x0e,
"IdCheck": "xxxxxxxxxxxx"
}
```
* **设备 -> APP** (返回结果):
```json
{
"type": 0x0e,
"Ret": 1 // 0:错误, 1:正确
}
```

90
app.json Normal file
View File

@@ -0,0 +1,90 @@
{
"expo": {
"name": "popcore",
"slug": "popcore",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "popcore",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSBluetoothPeripheralUsageDescription": "This app uses Bluetooth to act as a peripheral device for testing and development purposes.",
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "com.bowong.popcore"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"permissions": [
"android.permission.BLUETOOTH",
"android.permission.BLUETOOTH_ADMIN",
"android.permission.BLUETOOTH_SCAN",
"android.permission.BLUETOOTH_CONNECT",
"android.permission.BLUETOOTH_ADVERTISE",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.READ_EXTERNAL_STORAGE",
"android.permission.WRITE_EXTERNAL_STORAGE",
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
"android.permission.ACCESS_MEDIA_LOCATION",
"android.permission.READ_MEDIA_IMAGES",
"android.permission.READ_MEDIA_VIDEO",
"android.permission.READ_MEDIA_AUDIO",
"android.permission.RECORD_AUDIO"
],
"package": "com.bowong.popcore"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png",
"bundler": "metro"
},
"plugins": [
"expo-router",
[
"react-native-ble-plx",
{
"isBackgroundEnabled": true,
"modes": [
"peripheral",
"central"
],
"bluetoothAlwaysPermission": "Allow $(PRODUCT_NAME) to connect to bluetooth devices"
}
],
[
"expo-media-library",
{
"photosPermission": "Allow $(PRODUCT_NAME) to access your photos.",
"savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.",
"isAccessMediaLocationEnabled": true
}
],
[
"expo-image-picker",
{
"photosPermission": "The app accesses your photos to let you upload your avatar or post images."
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "97a8e5b1-e6e8-45aa-ad95-f273b6c307ef"
}
}
}
}

137
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,137 @@
import { Tabs } from 'expo-router'
import React from 'react'
import { StyleSheet, View, Text } from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { useTranslation } from 'react-i18next'
import { HomeIcon, MessageIcon, MyIcon, VideoIcon } from '@/components/icon'
export default function TabLayout() {
const { t } = useTranslation()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#FFFFFF',
tabBarInactiveTintColor: '#FFFFFF',
headerShown: false,
tabBarHideOnKeyboard: true,
tabBarShowLabel: true,
tabBarStyle: {
backgroundColor: '#1C1E22CC',
height: 83,
paddingTop: 4,
paddingBottom: 4,
paddingLeft: 4,
paddingRight: 4,
borderTopWidth: 0,
},
tabBarLabelStyle: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '400',
},
}}
>
<Tabs.Screen
name="index"
options={{
title: t('tabs.home'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.home')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<HomeIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: 0.6 }}>
<HomeIcon />
</View>
)
},
}}
/>
<Tabs.Screen
name="video"
options={{
title: t('tabs.video'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.video')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<VideoIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: 0.6 }}>
<VideoIcon />
</View>
)
},
}}
/>
<Tabs.Screen
name="message"
options={{
title: t('tabs.message'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.message')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<MessageIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: focused ? 1 : 0.6 }}>
<MessageIcon />
</View>
)
},
}}
/>
<Tabs.Screen
name="my"
options={{
title: t('tabs.my'),
tabBarLabel: ({ focused }: { focused: boolean }) => (focused ? null : <Text style={styles.tabLabel}>{t('tabs.my')}</Text>),
tabBarIcon: ({ focused }: { focused: boolean }) => {
if (focused) {
return (
<LinearGradient colors={['#9966FF', '#FF6699', '#FF9966']} start={{ x: 0, y: 0 }} end={{ x: 1, y: 0 }} style={styles.iconContainer}>
<MyIcon />
</LinearGradient>
)
}
return (
<View style={{ opacity: focused ? 1 : 0.6 }}>
<MyIcon />
</View>
)
},
}}
/>
</Tabs>
)
}
const styles = StyleSheet.create({
iconContainer: {
width: 87,
height: 41,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 100,
},
tabLabel: {
fontSize: 10,
color: '#FFFFFF',
textAlign: 'center',
},
})

624
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,624 @@
import { useState, useRef, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
Dimensions,
ScrollView,
Pressable,
StatusBar as RNStatusBar,
Animated,
Platform,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PointsIcon, SearchIcon, DownArrowIcon, WhiteStarIcon } from '@/components/icon'
import { AuthForm } from '@/components/blocks/AuthForm'
import { useActivates } from '@/hooks/use-activates'
const { width: screenWidth } = Dimensions.get('window')
// 卡片数据 - 根据 Figma 设计更新
const cardData = [
{
id: 1,
title: '宠物写真',
image: require('@/assets/images/android-icon-background.png'),
isHot: true,
users: 6349,
height: 214,
},
{
id: 2,
title: '我和小猫的人生合照',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 236,
},
{
id: 3,
title: '猫:晚安~人',
image: require('@/assets/images/favicon.png'),
users: 6349,
height: 214,
},
{
id: 4,
title: '穿越时空的相聚',
image: require('@/assets/images/icon.png'),
users: 6349,
height: 100,
},
{
id: 5,
title: '睡衣版猫咪',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 120,
},
{
id: 6,
title: '猫咪写真',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 214,
},
{
id: 7,
title: '站姐视角写真',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 214,
},
{
id: 8,
title: '猫咪写真',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 200,
},
]
export default function HomeScreen() {
const { t } = useTranslation()
const insets = useSafeAreaInsets()
const router = useRouter()
const [activeTab, setActiveTab] = useState(0)
// 标签数据 - 根据 Figma 设计更新
const tabs = [
t('home.tabs.featured'),
t('home.tabs.christmas'),
t('home.tabs.pets'),
t('home.tabs.avatar'),
t('home.tabs.theater1'),
t('home.tabs.theater2'),
]
const [gridWidth, setGridWidth] = useState(screenWidth)
const [showTabArrow, setShowTabArrow] = useState(false)
const [tabsSticky, setTabsSticky] = useState(false)
const [tabsHeight, setTabsHeight] = useState(0)
const tabsPositionRef = useRef(0)
const titleBarHeightRef = useRef(0)
const scrollY = useRef(new Animated.Value(0)).current
const { load, data: activatesData, error } = useActivates()
useEffect(() => {
load()
}, [])
useEffect(() => {
console.log({ activatesData, error })
}, [activatesData, error])
const horizontalPadding = 8 * 2 // gridContainer 的左右 padding
const cardGap = 5 // 两个卡片之间的间距
const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2
// 渲染标签导航的函数
const renderTabs = (wrapperStyle?: any) => (
<View style={[styles.tabsWrapper, wrapperStyle]}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabsContainer}
contentContainerStyle={[
styles.tabsContent,
showTabArrow && styles.tabsContentWithArrow,
]}
onContentSizeChange={(contentWidth) => {
const tabsContainerPadding = 16 * 2 // tabsContent 的左右 padding
const availableWidth = screenWidth - tabsContainerPadding
setShowTabArrow(contentWidth > availableWidth)
}}
>
{tabs.map((tab, index) => (
<Pressable
key={index}
onPress={() => setActiveTab(index)}
style={styles.tab}
>
<View style={styles.tabLabelWrapper}>
{activeTab === index && (
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.tabUnderline}
/>
)}
<Text
style={[
styles.tabText,
activeTab === index && styles.tabTextActive,
]}
>
{tab}
</Text>
</View>
</Pressable>
))}
</ScrollView>
{showTabArrow && (
<View style={styles.tabArrowContainer}>
<LinearGradient
colors={['#090A0B', 'rgba(9, 10, 11, 0)']}
locations={[0.38, 1.0]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={styles.tabArrowGradient}
>
<Pressable
style={styles.tabArrow}
onPress={() => router.push('/channels')}
>
<DownArrowIcon />
</Pressable>
</LinearGradient>
</View>
)}
</View>
)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 标题栏 */}
<View
style={styles.titleBar}
onLayout={(event) => {
const { height } = event.nativeEvent.layout
titleBarHeightRef.current = height
}}
>
<Text style={styles.appTitle}>Popcore</Text>
<View style={styles.headerRight}>
<Pressable
style={styles.pointsContainer}
onPress={() => router.push('/membership' as any)}
>
<PointsIcon />
<Text style={styles.pointsText}>60</Text>
</Pressable>
<Pressable
style={styles.searchButton}
onPress={() => router.push('/searchTemplate')}
>
<SearchIcon />
</Pressable>
</View>
</View>
{/* 吸顶的标签导航 - 适配 iOS 和 Android 的安全区域 */}
{tabsSticky && (
<View
style={[
styles.stickyTabsWrapper,
// 加上 insets.top 以适配不同设备的状态栏高度iOS 刘海屏、Android 状态栏等)
{ top: titleBarHeightRef.current + insets.top },
]}
>
{renderTabs(styles.stickyTabs)}
</View>
)}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
onScroll={(event) => {
const scrollY = event.nativeEvent.contentOffset.y
if (scrollY >= tabsPositionRef.current) {
setTabsSticky(true)
} else {
setTabsSticky(false)
}
}}
// iOS 使用 16ms (60fps)Android 使用 50ms 以获得更好的性能
scrollEventThrottle={Platform.OS === 'ios' ? 16 : 50}
>
{/* 图片区域 */}
<View style={styles.heroSection}>
<ScrollView
horizontal
pagingEnabled
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.heroSliderContent}
>
{activatesData?.activities.map((activity) => (
<Pressable key={activity.id} style={styles.heroMainSlide} onPress={() => router.push(activity.link as any)}>
<Image
source={{ uri: activity.coverUrl }}
style={styles.heroMainImage}
contentFit="cover"
/>
<View style={styles.heroTextContainer}>
<Text style={styles.heroText}>{activity.title}</Text>
<Text style={styles.heroSubtext}>{activity.desc}</Text>
</View>
</Pressable>
))}
</ScrollView>
</View>
{/* 标签导航 */}
<View
onLayout={(event) => {
const { y, height } = event.nativeEvent.layout
tabsPositionRef.current = y
setTabsHeight(height)
}}
style={tabsSticky ? { opacity: 0, height: tabsHeight } : undefined}
>
{renderTabs()}
</View>
<View className='py-6'>
<AuthForm mode='register' />
</View>
{/* 内容网格 */}
<View
style={styles.gridContainer}
onLayout={(event) => {
const { width } = event.nativeEvent.layout
setGridWidth(width)
}}
>
{cardData.map((card, index) => (
<Pressable
key={card.id}
style={[
styles.card,
{ width: cardWidth },
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
]}
onPress={() => {
router.push({
pathname: '/templateDetail' as any,
params: { id: card.id.toString() },
})
}}
>
<View
style={[
styles.cardImageContainer,
{ height: card.height || cardWidth * 1.2 },
]}
>
<Image
source={card.image}
style={styles.cardImage}
contentFit="cover"
/>
<LinearGradient
colors={['rgba(17, 17, 17, 0)', 'rgba(17, 17, 17, 0.9)']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.cardImageGradient}
/>
{card.isHot ? (
<View style={styles.hotBadge}>
<Text style={styles.hotEmoji}>🔥</Text>
<Text style={styles.hotText}>{t('home.hotTemplate')}</Text>
</View>
) : (
<View style={styles.hotBadge}>
<WhiteStarIcon />
<Text style={styles.hotText}>
{card.users}{t('home.peopleUsed')}
</Text>
</View>
)}
<Text style={styles.cardTitle} numberOfLines={1}>
{card.title}
</Text>
</View>
</Pressable>
))}
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
backgroundColor: '#090A0B',
paddingTop: 8,
paddingBottom: 12,
},
statusBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
},
time: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
statusIcons: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
signalBars: {
width: 18,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
wifiIcon: {
width: 16,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
batteryIcon: {
width: 24,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
titleBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 7,
paddingTop: 19,
},
appTitle: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '500',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
pointsContainer: {
backgroundColor: '#1C1E22',
borderRadius: 12,
paddingLeft: 8,
paddingRight: 10,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
},
pointsText: {
color: '#FFCF00',
fontSize: 12,
fontWeight: '600',
},
searchButton: {
padding: 4,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
},
heroSection: {
flexDirection: 'row',
paddingLeft: 12,
paddingTop: 12,
marginBottom: 40,
overflow: 'hidden',
},
heroSliderContent: {
gap: 12,
},
heroMainSlide: {
width: '100%',
},
heroMainImage: {
width: 265,
height: 150,
borderRadius: 12,
},
heroTextContainer: {
paddingTop: 12,
paddingHorizontal: 8,
},
heroText: {
color: '#ABABAB',
fontSize: 12,
marginBottom: 4,
},
heroSubtext: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '500',
},
heroSide: {
width: 120,
borderRadius: 12,
overflow: 'hidden',
},
heroSideImage: {
width: '100%',
height: 140,
},
heroSideTextContainer: {
padding: 8,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
},
heroSideText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '500',
marginBottom: 2,
},
heroSideSubtext: {
color: '#FFFFFF',
fontSize: 11,
opacity: 0.9,
},
tabsWrapper: {
position: 'relative',
marginBottom: 18,
},
stickyTabsWrapper: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 100,
backgroundColor: '#090A0B',
},
stickyTabs: {
marginBottom: 0,
},
tabsContainer: {
marginBottom: 0,
},
tabsContent: {
paddingHorizontal: 16,
gap: 20,
alignItems: 'center',
},
tabsContentWithArrow: {
paddingRight: 60,
},
tab: {
paddingBottom: 4,
position: 'relative',
},
tabLabelWrapper: {
position: 'relative',
paddingBottom: 2,
alignSelf: 'flex-start',
justifyContent: 'flex-end',
},
tabText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
tabTextActive: {
opacity: 1,
fontWeight: '600',
},
tabUnderline: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 12,
},
tabArrowContainer: {
position: 'absolute',
right: 0,
top: 0,
zIndex: 10,
},
tabArrowGradient: {
paddingLeft: 30,
paddingRight: 16,
paddingVertical: 4,
},
tabArrow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 8,
justifyContent: 'space-between',
},
card: {
marginBottom: 12,
},
cardLeft: {
marginRight: 0,
},
cardRight: {
marginLeft: 0,
},
cardImageContainer: {
width: '100%',
borderRadius: 16,
overflow: 'hidden',
position: 'relative',
},
cardImage: {
width: '100%',
height: '100%',
},
cardImageGradient: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '33.33%',
},
hotBadge: {
position: 'absolute',
top: 8,
left: 8,
backgroundColor: '#191A1F80',
paddingHorizontal: 7,
paddingVertical: 4,
borderRadius: 100,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 1,
},
hotEmoji: {
fontSize: 10,
},
hotText: {
color: '#F5F5F5',
fontSize: 11,
fontWeight: '500',
},
cardTitle: {
position: 'absolute',
bottom: 12,
left: 12,
fontSize: 14,
fontWeight: '500',
color: '#F5F5F5',
lineHeight: 20,
},
})

326
app/(tabs)/message.tsx Normal file
View File

@@ -0,0 +1,326 @@
import { useState, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
StatusBar as RNStatusBar,
Pressable,
} from 'react-native'
import { LinearGradient } from 'expo-linear-gradient'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useTranslation } from 'react-i18next'
// 消息卡片数据
interface MessageCard {
id: number
title: string
subtitle: string
body: string
time: string
type: 'notice' | 'other'
isNew?: boolean // 是否为新消息
}
const messageCards: MessageCard[] = [
{
id: 1,
title: '恭喜你获得双12新用户专享福利',
subtitle: '打开推送的内容,限时优惠,多种玩法,快来体验~',
body: '图片占位。',
time: '2023-12-29 18:32:21',
type: 'notice',
isNew: true, // 新消息
},
{
id: 2,
title: '新功能上线AI 智能生成',
subtitle: '全新 AI 功能已上线,快来体验吧!',
body: '图片占位。',
time: '2023-12-28 15:20:10',
type: 'notice',
isNew: true, // 新消息
},
{
id: 3,
title: '系统维护通知',
subtitle: '系统将于今晚进行维护升级',
body: '图片占位。',
time: '2023-12-27 10:15:30',
type: 'other',
isNew: false, // 非新消息
},
{
id: 4,
title: '限时活动:分享有礼',
subtitle: '分享你的作品,赢取丰厚奖励',
body: '图片占位。',
time: '2023-12-26 14:05:22',
type: 'notice',
isNew: false, // 非新消息
},
{
id: 5,
title: '版本更新提醒',
subtitle: '新版本已发布,建议及时更新',
body: '图片占位。',
time: '2023-12-25 09:30:45',
type: 'other',
isNew: false, // 非新消息
},
]
export default function MessageScreen() {
const { t } = useTranslation()
const [activeTab, setActiveTab] = useState<'all' | 'notice' | 'other'>('all')
// 根据选中的标签过滤卡片
const filteredCards = messageCards.filter((card) => {
if (activeTab === 'all') return true
return card.type === activeTab
})
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 固定在顶部的标签选择器 */}
<View style={styles.segment}>
<Pressable onPress={() => setActiveTab('all')}>
{activeTab === 'all' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text
style={[
styles.segmentText,
styles.segmentTextActiveText,
]}
>
{t('message.all')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.all')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('notice')}>
{activeTab === 'notice' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text
style={[
styles.segmentText,
styles.segmentTextActiveText,
]}
>
{t('message.notice')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.notice')}</Text>
)}
</Pressable>
<Pressable onPress={() => setActiveTab('other')}>
{activeTab === 'other' ? (
<View style={styles.segmentTextActiveWrapper}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.segmentTextActiveBg}
/>
<Text
style={[
styles.segmentText,
styles.segmentTextActiveText,
]}
>
{t('message.other')}
</Text>
</View>
) : (
<Text style={styles.segmentText}>{t('message.other')}</Text>
)}
</Pressable>
</View>
{/* 可滚动的消息卡片列表 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 消息卡片列表 */}
{filteredCards.length > 0 ? (
filteredCards.map((card) => (
<View key={card.id} style={styles.cardContainer}>
{/* 新消息绿色指示点 */}
{card.isNew && (
<View style={styles.newMessageDotContainer}>
<View style={styles.newMessageDot} />
</View>
)}
<Text style={styles.cardTitle}>
{card.title}
</Text>
<Text style={styles.cardSubtitle} numberOfLines={2}>
{card.subtitle}
</Text>
<View style={styles.cardBody}>
<Text style={styles.cardBodyText}>
{/* TODO:这里是图片 */}
{card.body}
</Text>
</View>
<Text style={styles.cardTime}>
{card.time}
</Text>
</View>
))
) : (
<View style={styles.emptyContainer}>
{/* <NoNewsIcon /> */}
💭
<Text style={styles.emptyText}>{t('message.noMessages')}</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
paddingHorizontal: 4,
paddingTop: 12,
},
appMiniIcon: {
width: 28,
height: 18,
borderRadius: 4,
backgroundColor: '#FF66AA',
},
segment: {
flexDirection: 'row',
paddingHorizontal: 8,
paddingTop: 19,
paddingBottom: 12,
backgroundColor: '#090A0B',
gap: 16,
},
segmentText: {
fontSize: 14,
color: '#FFFFFF',
},
segmentTextActiveWrapper: {
position: 'relative',
paddingBottom: 2,
justifyContent: 'flex-end',
alignSelf: 'flex-start',
},
segmentTextActiveBg: {
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
height: 10,
backgroundColor: '#FF9966',
},
segmentTextActiveText: {
zIndex: 1,
},
statusDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: '#00FF66',
marginRight: 4,
},
cardContainer: {
backgroundColor: '#16181B',
borderRadius: 16,
padding: 16,
marginBottom: 12,
marginHorizontal: 4,
position: 'relative', // 用于定位新消息指示点
},
newMessageDotContainer: {
position: 'absolute',
top: -4,
right: -2,
width: 16,
height: 16,
borderWidth: 4,
borderColor: '#090A0B',
borderRadius: 8,
justifyContent: 'center',
alignItems: 'center',
},
newMessageDot: {
width: 6,
height: 6,
borderRadius: 4,
backgroundColor: '#00FF66', // 绿色
zIndex: 1,
},
cardTitle: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '600',
marginBottom: 8,
},
cardSubtitle: {
color: '#ABABAB',
fontSize: 11,
marginBottom: 16,
},
cardBody: {
backgroundColor: '#26292E',
borderRadius: 12,
height:100,
marginBottom: 12,
},
cardBodyText: {
color: '#FFFFFF',
fontSize: 12,
opacity: 0.8,
},
cardTime: {
color: '#8A8A8A',
fontSize: 10,
},
emptyContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingTop: 200,
},
emptyText: {
color: '#8A8A8A',
fontSize: 12,
marginTop: 16,
},
})

440
app/(tabs)/my.tsx Normal file
View File

@@ -0,0 +1,440 @@
import { useState, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { PointsIcon, SearchIcon, SettingsIcon } from '@/components/icon'
import EditProfileDrawer from '@/components/drawer/EditProfileDrawer'
import Dropdown from '@/components/ui/dropdown'
const { width: screenWidth } = Dimensions.get('window')
const GALLERY_GAP = 2
const GALLERY_HORIZONTAL_PADDING = 0
// 计算每个卡片的宽度:屏幕宽度 - 左右padding - 2个间距然后除以3使用 Math.floor 确保整数像素
const GALLERY_ITEM_SIZE = Math.floor(
(screenWidth - GALLERY_HORIZONTAL_PADDING * 2 - GALLERY_GAP * 2) / 3
)
// status状态有running pending completed
const works = [
{ id: 1, status: 'running' as const, count: 1 },
{ id: 2, status: 'completed' as const, count: 1 },
{ id: 3, status: 'completed' as const, count: 1 },
{ id: 4, status: 'completed' as const, count: 2 },
{ id: 5, status: 'pending' as const, count: 1 },
{ id: 6, status: 'pending' as const, count: 1 },
{ id: 7, status: 'pending' as const, count: 1 },
{ id: 8, status: 'pending' as const, count: 1 },
{ id: 9, status: 'completed' as const, count: 1 },
{ id: 10, status: 'completed' as const, count: 1 },
{ id: 11, status: 'completed' as const, count: 1 },
{ id: 12, status: 'completed' as const, count: 1 },
{ id: 13, status: 'completed' as const, count: 1 },
{ id: 14, status: 'completed' as const, count: 1 },
{ id: 15, status: 'completed' as const, count: 1 },
{ id: 16, status: 'completed' as const, count: 1 },
{ id: 17, status: 'completed' as const, count: 1 },
]
export default function My() {
const router = useRouter()
const { t, i18n } = useTranslation()
const [editDrawerVisible, setEditDrawerVisible] = useState(false)
const [profileName, setProfileName] = useState('乔乔乔乔')
// 处理设置菜单选择
const handleSettingsSelect = (value: string) => {
if (value === 'changePassword') {
router.push('/changePassword' as any)
} else if (value === 'language') {
// 切换语言
const newLang = i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN'
i18n.changeLanguage(newLang)
}
}
// 设置菜单选项
const getLanguageLabel = () => {
if (i18n.language === 'zh-CN') {
return t('my.languageSwitch')
} else {
return t('my.languageSwitchEn')
}
}
const settingsOptions = [
{ label: t('my.changePassword'), value: 'changePassword' },
{ label: getLanguageLabel(), value: 'language' },
]
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部积分与设置 */}
<View style={styles.topBar}>
<Pressable
style={styles.pointsPill}
onPress={() => router.push('/membership' as any)}
>
<PointsIcon />
<Text style={styles.pointsPillText}>60</Text>
</Pressable>
<Dropdown
options={settingsOptions}
onSelect={(value) => handleSettingsSelect(value)}
renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable onPress={toggle}>
<SettingsIcon />
</Pressable>
)}
dropdownStyle={{
minWidth: 160,
right: 10,
backgroundColor: '#2A2A2A80',
}}
/>
</View>
{/* 个人信息区 */}
<View style={styles.profileSection}>
<Image
source={require('@/assets/images/icon.png')}
style={styles.avatar}
contentFit="cover"
/>
<View style={styles.profileInfo}>
<Text style={styles.profileName}>{profileName}</Text>
<Text style={styles.profileSubTitle}>ID 12345678</Text>
</View>
<Pressable
style={styles.editButton}
onPress={() => setEditDrawerVisible(true)}
>
<Text style={styles.editButtonText}>{t('my.editProfile')}</Text>
</Pressable>
</View>
{/* "生成作品" 标题行 */}
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{t('my.generatedWorks')}</Text>
<Pressable
style={styles.sectionMoreButton}
onPress={() => router.push('/worksList' as any)}
>
<SearchIcon />
</Pressable>
</View>
{/* 作品九宫格 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.galleryGrid}>
{works.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
// 每行的前两个item有右边距第三个没有
index % 3 !== 2 && styles.galleryItemMarginRight,
// 所有item都有下边距最后一行也会有但影响不大
styles.galleryItemMarginBottom,
]}
onPress={() => {
// 只有已完成的作品才能点击进入详情页
if (item.status === 'completed') {
router.push({
pathname: '/generationRecord' as any,
params: { id: item.id.toString() },
})
}
}}
disabled={item.status !== 'completed'}
>
<Image
source={require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
{/* 生成中遮罩 */}
{item.status != 'completed' && (
<View style={styles.generatingOverlay} />
)}
{/* 右上角作品数量角标 */}
{item.status === 'completed'&&<View style={styles.counterBadge}>
<Text style={styles.counterText}>
{item.count}
</Text>
</View>}
{/* "生成中"角标:覆盖在图片左下角 */}
{item.status === 'running' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>
{t('my.generating')}
</Text>
</View>
)}
{item.status === 'pending' && (
<View style={styles.generatingBadge}>
<Text style={styles.generatingBadgeText}>
{t('my.queuing')}
</Text>
</View>
)}
</Pressable>
))}
</View>
</ScrollView>
{/* 编辑资料抽屉 */}
<EditProfileDrawer
visible={editDrawerVisible}
onClose={() => setEditDrawerVisible(false)}
initialName={profileName}
onSave={(name) => setProfileName(name)}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
topBar: {
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
paddingTop: 19,
paddingRight: 16,
flexDirection: 'row',
justifyContent: 'flex-end',
backgroundColor: '#090A0B',
gap: 12,
},
pointsPill: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 100,
backgroundColor: '#1C1E22',
},
pointsPillText: {
color: '#FFCF00',
fontSize: 12,
fontWeight: '600',
},
header: {
backgroundColor: '#000000',
paddingTop: 8,
paddingBottom: 12,
},
statusBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingBottom: 8,
},
time: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
statusIcons: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
signalBars: {
width: 18,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
wifiIcon: {
width: 16,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
batteryIcon: {
width: 24,
height: 12,
backgroundColor: '#FFFFFF',
borderRadius: 2,
},
titleBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
},
appTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
},
headerRight: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
pointsContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
pointsText: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
searchButton: {
padding: 4,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
},
profileSection: {
flexDirection: 'row',
alignItems: 'center',
paddingLeft: 16,
paddingRight: 16,
marginTop: 20,
marginBottom: 32,
backgroundColor: '#090A0B',
},
avatar: {
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
marginRight: 16,
},
profileInfo: {
flex: 1,
},
profileName: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
profileSubTitle: {
color: '#FFFFFF',
fontSize: 12,
opacity: 0.7,
},
editButton: {
paddingHorizontal: 8, //左右各留 8 像素的内边距
paddingVertical: 6, //上下各留 6 像素的内边距
borderRadius: 6,
backgroundColor: '#1C1E22',
},
editButtonText: {
color: '#FFFFFF',
fontSize: 12,
},
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
marginHorizontal: 12,
backgroundColor: '#090A0B',
},
sectionTitle: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
},
sectionMoreButton: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
sectionMoreIcon: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FFFFFF33',
},
galleryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 10,
},
galleryItem: {
width: GALLERY_ITEM_SIZE,
// 使用等比例 1:1保证容器永远是正方形
aspectRatio: 1,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
galleryItemMarginRight: {
marginRight: GALLERY_GAP,
},
galleryItemMarginBottom: {
marginBottom: GALLERY_GAP,
},
galleryImage: {
width: '100%',
// 高度由 aspectRatio 决定,避免拉伸
height: undefined,
aspectRatio: 1,
},
generatingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#00000080',
},
counterBadge: {
position: 'absolute',
right: 8,
bottom: 8,
paddingHorizontal: 10,
paddingVertical: 1,
borderRadius: 6,
backgroundColor: '#16181B1A',
},
counterText: {
color: '#FFFFFF',
fontSize: 10,
fontWeight: '600',
},
generatingBadge: {
position: 'absolute',
left: 8,
bottom: 8,
},
generatingBadgeText: {
color: '#F5F5F5',
fontSize: 9,
fontWeight: '500',
},
})

275
app/(tabs)/video.tsx Normal file
View File

@@ -0,0 +1,275 @@
import { useState, useRef } from 'react'
import {
View,
Text,
StyleSheet,
Dimensions,
FlatList,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { Image, ImageLoadEventData } from 'expo-image'
import { useTranslation } from 'react-i18next'
import { SameStyleIcon, VideoIcon, WhiteStarIcon } from '@/components/icon'
import { useRouter } from 'expo-router'
const { width: screenWidth, height: screenHeight } = Dimensions.get('window')
const TAB_BAR_HEIGHT = 83 // 底部导航栏高度
// 视频数据
const videoData = [
{
id: 1,
videoUrl: require('@/assets/images/android-icon-background.png'),
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
title: '雅琳圣诞写真',
duration: '00:03',
},
{
id: 2,
videoUrl: require('@/assets/images/android-icon-background.png'),
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
title: '宠物写真',
duration: '00:05',
},
{
id: 3,
videoUrl: require('@/assets/images/android-icon-background.png'),
thumbnailUrl: require('@/assets/images/android-icon-background.png'),
title: '我和小猫的人生合照',
duration: '00:04',
},
]
interface VideoItemProps {
item: typeof videoData[0]
index: number
videoHeight: number
}
function VideoItem({ item, videoHeight }: VideoItemProps) {
const { t } = useTranslation()
const router = useRouter()
const [isPlaying, setIsPlaying] = useState(false)
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
const handleImageLoad = (event: ImageLoadEventData) => {
const { source } = event
if (source?.width && source?.height) {
setImageSize({ width: source.width, height: source.height })
}
}
// 根据图片比例计算显示尺寸
const getImageStyle = () => {
if (!imageSize) {
return {
width: screenWidth,
height: videoHeight,
}
}
const imageAspectRatio = imageSize.width / imageSize.height
const screenAspectRatio = screenWidth / videoHeight
let displayWidth = screenWidth
let displayHeight = videoHeight
if (imageAspectRatio > screenAspectRatio) {
// 图片更宽,以宽度为准
displayHeight = screenWidth / imageAspectRatio
} else {
// 图片更高,以高度为准
displayWidth = videoHeight * imageAspectRatio
}
return {
width: displayWidth,
height: displayHeight,
}
}
return (
<View style={[styles.videoContainer, { height: videoHeight }]}>
{/* 主视频区域 */}
<View style={styles.videoWrapper}>
<Image
source={item.videoUrl}
style={[getImageStyle()]}
contentFit="contain"
onLoad={handleImageLoad}
/>
{/* 播放按钮 */}
{!isPlaying && (
<Pressable
style={styles.playButton}
onPress={() => setIsPlaying(true)}
>
<View style={styles.playIcon}>
<View style={styles.playTriangle} />
</View>
</Pressable>
)}
{/* 左下角原图缩略图 */}
<View style={styles.thumbnailContainer}>
<Image
source={item.thumbnailUrl}
style={styles.thumbnail}
contentFit="cover"
/>
</View>
</View>
{/* 左下角按钮 */}
<Pressable style={styles.actionButtonLeft}>
<WhiteStarIcon />
<Text style={styles.actionButtonTextTitle}>{item.title}</Text>
</Pressable>
{/* 右下角按钮 */}
<Pressable
style={styles.actionButtonRight}
onPress={() => {
router.push({
pathname: '/generateVideo' as any,
params: {
template: JSON.stringify(item),
},
})
}}
>
<SameStyleIcon />
<Text style={styles.actionButtonText}>{t('video.makeSame')}</Text>
</Pressable>
</View>
)
}
export default function VideoScreen() {
const flatListRef = useRef<FlatList>(null)
const insets = useSafeAreaInsets()
const videoHeight = screenHeight - TAB_BAR_HEIGHT
return (
<SafeAreaView style={styles.container} edges={[]}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<FlatList
ref={flatListRef}
data={videoData}
renderItem={({ item, index }) => (
<VideoItem item={item} index={index} videoHeight={videoHeight} />
)}
keyExtractor={(item) => item.id.toString()}
pagingEnabled
showsVerticalScrollIndicator={false}
snapToInterval={videoHeight}
snapToAlignment="start"
decelerationRate="fast"
getItemLayout={(_, index) => ({
length: videoHeight,
offset: videoHeight * index,
index,
})}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
videoContainer: {
width: screenWidth,
backgroundColor: '#090A0B',
position: 'relative',
},
videoWrapper: {
flex: 1,
position: 'relative',
backgroundColor: '#090A0B',
alignItems: 'center',
justifyContent: 'center',
},
playButton: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
playIcon: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.85)',
alignItems: 'center',
justifyContent: 'center',
},
playTriangle: {
width: 0,
height: 0,
borderLeftWidth: 18,
borderTopWidth: 11,
borderBottomWidth: 11,
borderLeftColor: '#000000',
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
marginLeft: 3,
},
thumbnailContainer: {
position: 'absolute',
left: 12,
bottom: 80,
width: 56,
height: 56,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#FFFFFF',
backgroundColor: '#090A0B',
},
thumbnail: {
width: '100%',
height: '100%',
},
actionButtonLeft: {
position: 'absolute',
left: 12,
bottom: 16,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 6,
paddingVertical: 8,
backgroundColor: '#191B1F',
borderRadius: 8,
borderWidth: 1,
borderColor: '#2F3134',
},
actionButtonRight: {
position: 'absolute',
right: 13,
bottom: 13,
flexDirection: 'column',
alignItems: 'center',
},
actionButtonTextTitle: {
color: '#F5F5F5',
fontSize: 11,
},
actionButtonText: {
color: '#CCCCCC',
fontSize: 10,
fontWeight: '500',
},
})

31
app/+not-found.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { Link, Stack } from "expo-router";
import { StyleSheet, View } from "react-native";
import { useTranslation } from 'react-i18next';
export default function NotFoundScreen() {
const { t } = useTranslation();
return (
<>
<Stack.Screen options={{ title: t('notFound.title') }} />
<View style={styles.container}>
<Link href="/" style={styles.button}>
{t('notFound.goBack')}
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#25292e",
justifyContent: "center",
alignItems: "center",
},
button: {
fontSize: 20,
textDecorationLine: "underline",
color: "#fff",
},
});

65
app/_layout.tsx Normal file
View File

@@ -0,0 +1,65 @@
import '../global.css'
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native'
import { Stack } from 'expo-router'
import { StatusBar } from 'expo-status-bar'
import 'react-native-reanimated'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { useColorScheme } from '@/hooks/use-color-scheme'
import { KeyboardProvider } from 'react-native-keyboard-controller'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { ModalPortal } from '@/components/ui'
import '@/lib/i18n'
export const unstable_settings = {
anchor: '(tabs)',
}
// 路由层
export default function RootLayout() {
return (
<Providers>
<Stack>
<Stack.Screen name="modal" options={{ presentation: 'modal', title: 'Modal' }} />
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="channels" options={{ headerShown: false }} />
<Stack.Screen name="searchTemplate" options={{ headerShown: false }} />
<Stack.Screen name="searchResults" options={{ headerShown: false }} />
<Stack.Screen name="generationRecord" options={{ headerShown: false }} />
<Stack.Screen name="generateVideo" options={{ headerShown: false }} />
<Stack.Screen name="templateDetail" options={{ headerShown: false }} />
<Stack.Screen name="membership" options={{ headerShown: false }} />
<Stack.Screen name="terms" options={{ headerShown: false }} />
<Stack.Screen name="privacy" options={{ headerShown: false }} />
<Stack.Screen name="worksList" options={{ headerShown: false }} />
<Stack.Screen name="searchWorks" options={{ headerShown: false }} />
<Stack.Screen name="searchWorksResults" options={{ headerShown: false }} />
<Stack.Screen name="changePassword" options={{ headerShown: false }} />
</Stack>
<StatusBar style="auto" />
</Providers>
)
}
function Providers({ children }: { children: React.ReactNode }) {
const colorScheme = useColorScheme()
return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
{children}
{/* modals */}
{/* 挂载全局方法 */}
<ModalPortal ref={(ref) => ((global as any).actionSheet = ref)} />
<ModalPortal ref={(ref) => ((global as any).modal = ref)} />
<ModalPortal ref={(ref) => ((global as any).loading = ref)} />
<ModalPortal ref={(ref) => ((global as any).toast = ref)} />
</ThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</SafeAreaProvider>
)
}

316
app/changePassword.tsx Normal file
View File

@@ -0,0 +1,316 @@
import { useState } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
Platform,
TextInput,
Keyboard,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { StatusBar as RNStatusBar } from 'react-native'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon } from '@/components/icon'
import { LinearGradient } from 'expo-linear-gradient'
export default function ChangePasswordScreen() {
const router = useRouter()
const { t } = useTranslation()
const [currentPassword, setCurrentPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [currentPasswordError, setCurrentPasswordError] = useState('')
const [newPasswordError, setNewPasswordError] = useState('')
const [confirmPasswordError, setConfirmPasswordError] = useState('')
const validateCurrentPassword = (value: string) => {
if (!value) {
setCurrentPasswordError(t('changePassword.currentPasswordRequired'))
return false
}
setCurrentPasswordError('')
return true
}
const validateNewPassword = (value: string) => {
if (!value) {
setNewPasswordError(t('changePassword.newPasswordRequired'))
return false
}
if (value.length < 6) {
setNewPasswordError(t('changePassword.newPasswordTooShort'))
return false
}
if (currentPassword && value === currentPassword) {
setNewPasswordError(t('changePassword.newPasswordSame'))
return false
}
setNewPasswordError('')
return true
}
const validateConfirmPassword = (value: string) => {
if (!value) {
setConfirmPasswordError(t('changePassword.confirmPasswordRequired'))
return false
}
if (newPassword && value !== newPassword) {
setConfirmPasswordError(t('changePassword.confirmPasswordMismatch'))
return false
}
setConfirmPasswordError('')
return true
}
const handleSubmit = () => {
Keyboard.dismiss()
// 按顺序验证每个字段
if (!validateCurrentPassword(currentPassword)) {
return
}
if (!validateNewPassword(newPassword)) {
return
}
if (!validateConfirmPassword(confirmPassword)) {
return
}
// TODO: 调用修改密码的API
// 这里应该调用实际的API来修改密码
console.log('修改密码:', { currentPassword, newPassword })
// 成功后返回
router.back()
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar
barStyle="light-content"
backgroundColor="#090A0B"
translucent={Platform.OS === 'android'}
/>
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => {
router.back()
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<LeftArrowIcon className="w-4 h-4" />
</Pressable>
<Text style={styles.headerTitle}>{t('changePassword.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.form}>
<View style={styles.inputGroup}>
<Text style={styles.label}>{t('changePassword.currentPassword')}</Text>
<TextInput
style={[
styles.input,
currentPasswordError ? styles.inputError : null,
]}
value={currentPassword}
onChangeText={(text) => {
setCurrentPassword(text)
if (currentPasswordError) {
validateCurrentPassword(text)
}
}}
onBlur={() => {
validateCurrentPassword(currentPassword)
}}
placeholder={t('changePassword.currentPasswordPlaceholder')}
placeholderTextColor="#666666"
secureTextEntry
autoCapitalize="none"
/>
{currentPasswordError ? (
<Text style={styles.errorText}>{currentPasswordError}</Text>
) : null}
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>{t('changePassword.newPassword')}</Text>
<TextInput
style={[
styles.input,
newPasswordError ? styles.inputError : null,
]}
value={newPassword}
onChangeText={(text) => {
setNewPassword(text)
if (newPasswordError) {
validateNewPassword(text)
}
// 如果确认密码已填写,重新验证确认密码
if (confirmPassword) {
validateConfirmPassword(confirmPassword)
}
}}
onBlur={() => {
validateNewPassword(newPassword)
}}
placeholder={t('changePassword.newPasswordPlaceholder')}
placeholderTextColor="#666666"
secureTextEntry
autoCapitalize="none"
/>
{newPasswordError ? (
<Text style={styles.errorText}>{newPasswordError}</Text>
) : null}
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>{t('changePassword.confirmPassword')}</Text>
<TextInput
style={[
styles.input,
confirmPasswordError ? styles.inputError : null,
]}
value={confirmPassword}
onChangeText={(text) => {
setConfirmPassword(text)
if (confirmPasswordError) {
validateConfirmPassword(text)
}
}}
onBlur={() => {
validateConfirmPassword(confirmPassword)
}}
placeholder={t('changePassword.confirmPasswordPlaceholder')}
placeholderTextColor="#666666"
secureTextEntry
autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={handleSubmit}
/>
{confirmPasswordError ? (
<Text style={styles.errorText}>{confirmPasswordError}</Text>
) : null}
</View>
<Pressable
style={styles.submitButtonContainer}
onPress={handleSubmit}
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.submitButton}
>
<Text style={styles.submitButtonText}>{t('changePassword.submit')}</Text>
</LinearGradient>
</Pressable>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'ios' ? 8 : 16,
paddingBottom: 16,
backgroundColor: '#090A0B',
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
flex: 1,
textAlign: 'center',
},
headerSpacer: {
width: 32,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 24,
paddingBottom: 32,
},
form: {
flex: 1,
},
inputGroup: {
marginBottom: 12,
},
label: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
marginBottom: 8,
},
input: {
backgroundColor: '#1C1E22',
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
color: '#F5F5F5',
fontSize: 14,
height: 48,
},
inputError: {
borderWidth: 1,
borderColor: '#FF6B6B',
},
errorText: {
color: '#FF6B6B',
fontSize: 12,
marginTop: 8,
},
submitButtonContainer: {
width: '100%',
borderRadius: 12,
overflow: 'hidden',
height: 48,
marginTop: 32,
},
submitButton: {
width: '100%',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
height: 48,
},
submitButtonText: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '600',
},
})

173
app/channels.tsx Normal file
View File

@@ -0,0 +1,173 @@
import { useState } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { TopArrowIcon } from '@/components/icon'
import GradientText from '@/components/GradientText'
export default function ChannelsScreen() {
const { t } = useTranslation()
const router = useRouter()
const [isExpanded, setIsExpanded] = useState(true)
const [selectedChannel, setSelectedChannel] = useState(2) // 默认选中第二个频道
// 频道数据
const channels = Array.from({ length: 13 }, (_, i) => ({
id: i + 1,
name: t('channels.channelName'),
}))
return (
<View style={styles.screen}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部容器 - 包含 header 和频道选择区域,带底部圆角 */}
<View style={styles.topContainer}>
<SafeAreaView style={styles.safeArea} edges={['top']}>
{/* 标题栏 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>{t('channels.title')}</Text>
<Pressable
onPress={() => {
if (isExpanded) {
router.push('/')
} else {
setIsExpanded(true)
}
}}
>
<TopArrowIcon />
</Pressable>
</View>
{/* 频道选择区域 */}
{isExpanded && (
<View style={styles.channelsSection}>
<View style={styles.channelsGrid}>
{channels.map((channel) => {
return (
<Pressable
key={channel.id}
onPress={() => setSelectedChannel(channel.id)}
style={[styles.channelButtonWrapper]}
>
{selectedChannel === channel.id ? (
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.channelButtonGradient}
>
<View style={styles.channelButtonDefault}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.channelButtonText}
>
{channel.name}
</GradientText>
</View>
</LinearGradient>
) : (
<View style={styles.channelButtonWrapperUnselected}>
<View style={styles.channelButtonDefault}>
<Text style={styles.channelButtonText}>
{channel.name}
</Text>
</View>
</View>
)}
</Pressable>
)
})}
</View>
</View>
)}
</SafeAreaView>
</View>
</View>
)
}
const styles = StyleSheet.create({
screen: {
flex: 1,
backgroundColor: '#0A0A0A',
},
topContainer: {
backgroundColor: '#16181B',
borderBottomLeftRadius: 16,
borderBottomRightRadius: 16,
overflow: 'hidden',
},
safeArea: {
backgroundColor: '#16181B',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 27,
paddingBottom: 12,
},
headerTitle: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
},
searchText: {
color: '#ABABAB',
fontSize: 14,
fontWeight: '400',
},
channelsSection: {
paddingHorizontal: 12,
paddingBottom: 12,
},
channelsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
channelButtonWrapper: {
// 按钮宽度根据内容自适应
},
channelButtonGradient: {
borderRadius: 100,
padding: 1,
},
channelButtonWrapperUnselected: {
borderRadius: 100,
padding: 1,
backgroundColor: 'transparent',
},
channelButtonDefault: {
backgroundColor: '#262A31',
borderRadius: 100,
paddingVertical: 8,
paddingHorizontal: 17,
borderWidth: 1,
borderColor: '#262A31',
},
channelButtonText: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
textAlign: 'center',
},
})

377
app/generateVideo.tsx Normal file
View File

@@ -0,0 +1,377 @@
import { useState, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
TextInput,
StatusBar as RNStatusBar,
Platform,
KeyboardAvoidingView,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { LinearGradient } from 'expo-linear-gradient'
import { LeftArrowIcon, UploadIcon, WhitePointsIcon } from '@/components/icon'
import UploadReferenceImageDrawer from '@/components/drawer/UploadReferenceImageDrawer'
import { StartGeneratingNotification } from '@/components/ui'
const { height: screenHeight } = Dimensions.get('window')
interface TemplateData {
id: number
videoUrl: any
thumbnailUrl: any
title: string
duration: string
}
export default function GenerateVideoScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const [description, setDescription] = useState('')
const [uploadedImage, setUploadedImage] = useState<any>(null)
const [templateData, setTemplateData] = useState<TemplateData | null>(null)
const [drawerVisible, setDrawerVisible] = useState(false)
const [showNotification, setShowNotification] = useState(false)
useEffect(() => {
if (params.template && typeof params.template === 'string') {
try {
const parsed = JSON.parse(params.template) as TemplateData
setTemplateData(parsed)
// 使用缩略图作为上传的图片
if (parsed.thumbnailUrl) {
setUploadedImage(parsed.thumbnailUrl)
}
} catch (error) {
console.error('Failed to parse template data:', error)
}
}
}, [params.template])
return (
<SafeAreaView
style={styles.container}
edges={Platform.OS === 'ios' ? ['top'] : ['top', 'bottom']}
>
<StatusBar style="light" />
{Platform.OS === 'android' && (
<RNStatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
)}
<KeyboardAvoidingView
style={styles.keyboardAvoidingView}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* 顶部导航栏 */}
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
>
<LeftArrowIcon />
</Pressable>
</View>
<View style={styles.content}>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.title}> {templateData?.title}</Text>
<Text style={styles.subtitle}>
{templateData?.title }
</Text>
</View>
<View style={styles.uploadContainer}>
{uploadedImage ? (
<Image
source={uploadedImage}
style={styles.uploadedImage}
contentFit="cover"
/>
) : (
<View style={styles.avatarPlaceholder}>
<View style={styles.avatarCircle} />
</View>
)}
<View style={styles.thumbnailContainer}>
<Image
source={templateData?.thumbnailUrl}
style={styles.thumbnail}
contentFit="cover"
/>
</View>
</View>
{/* 上传区域 */}
<Pressable
style={styles.uploadReferenceButton}
onPress={() => {
setDrawerVisible(true)
}}
>
<UploadIcon />
<Text style={styles.uploadReferenceText}>{t('generateVideo.uploadReference')}</Text>
</Pressable>
{/* 描述输入区域 */}
<TextInput
style={styles.descriptionInput}
value={description}
onChangeText={setDescription}
placeholder={t('generateVideo.descriptionPlaceholder')}
placeholderTextColor="#8A8A8A"
multiline
numberOfLines={4}
textAlignVertical="top"
/>
{/* 底部生成按钮 */}
<View style={styles.generateButtonContainer}>
<Pressable
onPress={() => {
setShowNotification(true)
// 3秒后自动隐藏通知
setTimeout(() => {
setShowNotification(false)
}, 3000)
}}
>
<LinearGradient
colors={['#9966FF', '#FF6699', '#FF9966']}
locations={[0.0015, 0.4985, 0.9956]}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={styles.generateButton}
>
<Text style={styles.generateButtonText}>{t('generateVideo.generate')}</Text>
<View style={styles.pointsBadge}>
<WhitePointsIcon />
<Text style={styles.pointsText}>10</Text>
</View>
</LinearGradient>
</Pressable>
</View>
</View>
</ScrollView>
</KeyboardAvoidingView>
<UploadReferenceImageDrawer
visible={drawerVisible}
onClose={() => setDrawerVisible(false)}
onSelectImage={(imageUri) => {
setUploadedImage(imageUri)
}}
/>
{/* 通知组件 */}
{showNotification && (
<View style={styles.notificationContainer}>
<StartGeneratingNotification
count={1}
visible={showNotification}
title={t('generateVideo.startGenerating')}
message={t('generateVideo.generatingMessage')}
onPress={() => setShowNotification(false)}
style={styles.notification}
/>
</View>
)}
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
keyboardAvoidingView: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
flexGrow: 1,
backgroundColor: '#090A0B',
},
generateButtonContainer: {
marginTop: 'auto',
paddingTop: 12,
paddingBottom: Platform.select({
ios: 10,
android: 10,
default: 10,
}),
},
content: {
paddingHorizontal: 12,
paddingTop: 16,
backgroundColor: '#1C1E20',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
gap: 8,
minHeight: Platform.select({
ios: screenHeight - 100,
android: screenHeight - 100,
default: screenHeight - 60,
}),
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: Platform.select({
ios: 17,
android: 12,
default: 17,
}),
paddingHorizontal: 12,
paddingBottom: 20,
},
backButton: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
},
titleSection: {
paddingHorizontal:4,
marginBottom: 11,
},
title: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '600',
marginBottom: 5,
marginLeft: -4,
},
subtitle: {
color: '#CCCCCC',
fontSize: 12,
},
uploadContainer: {
height: 140,
borderRadius: 12,
backgroundColor: '#262A31',
overflow: 'hidden',
position: 'relative',
alignItems: 'center',
justifyContent: 'center',
},
thumbnailContainer: {
position: 'absolute',
left: 8,
bottom: 5,
width: 56,
height: 56,
borderRadius: 8,
overflow: 'hidden',
borderWidth: 2,
borderColor: '#FFFFFF',
backgroundColor: '#090A0B',
},
thumbnail: {
width: '100%',
height: '100%',
},
avatarPlaceholder: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#2F3134',
alignItems: 'center',
justifyContent: 'center',
},
avatarCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: '#4A4C4F',
},
uploadedImage: {
width: '100%',
height: '100%',
},
uploadReferenceButton: {
height: 110,
backgroundColor: '#262A31',
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
gap: 6,
},
uploadReferenceText: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
},
descriptionInput: {
minHeight: 150,
backgroundColor: '#262A31',
borderRadius: 12,
padding: 12,
color: '#F5F5F5',
fontSize: 14,
lineHeight: 20,
...Platform.select({
android: {
textAlignVertical: 'top',
},
}),
},
generateButton: {
width: '100%',
height: 48,
backgroundColor: 'red',
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
generateButtonText: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '500',
},
pointsBadge: {
flexDirection: 'row',
alignItems: 'center',
},
pointsText: {
color: '#f5f5f5',
fontSize: 14,
fontWeight: '500',
},
notificationContainer: {
position: 'absolute',
top: Platform.select({
ios: 60,
android: 50,
default: 60,
}),
left: 0,
right: 0,
paddingHorizontal: 8,
zIndex: 1000,
},
notification: {
width: '100%',
},
})

244
app/generationRecord.tsx Normal file
View File

@@ -0,0 +1,244 @@
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Image } from 'expo-image'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon, DeleteIcon, EditIcon, ChangeIcon, WhiteStarIcon } from '@/components/icon'
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog'
const { width: screenWidth } = Dimensions.get('window')
export default function GenerationRecordScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const workId = params.id as string
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const handleDelete = () => {
// TODO: 实现删除逻辑
console.log('删除记录:', workId)
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 顶部导航栏 */}
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
>
<LeftArrowIcon />
</Pressable>
<Text style={styles.headerTitle}>{t('generationRecord.title')}</Text>
<View style={styles.headerSpacer} />
</View>
{/* AI 视频标签 */}
<View style={styles.categorySection}>
<View style={styles.categoryIcon}>
<WhiteStarIcon />
</View>
<Text style={styles.categoryText}>{t('generationRecord.aiVideo')}</Text>
</View>
{/* 原图标签 */}
<View style={styles.originalImageSection}>
<Text style={styles.originalImageLabel}>{t('generationRecord.originalImage')}</Text>
<View style={styles.originalImageDivider} />
</View>
{/* 主图片/视频预览区域 */}
<View style={styles.imageContainer}>
<Image
source={require('@/assets/images/android-icon-background.png')}
style={styles.mainImage}
contentFit="cover"
/>
{/* 视频时长显示 */}
</View>
<Text style={styles.durationText}>00:03</Text>
{/* 底部操作按钮 */}
<View style={styles.actionButtons}>
<Pressable style={styles.actionButton} onPress={() => {
router.push({
pathname: '/generateVideo' as any,
params: {
template: JSON.stringify({
id: 1,
title: 'AI 视频',
image: require('@/assets/images/android-icon-background.png'),
users: 6349,
height: 214,
}),
},
})
}}>
<EditIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.reEdit')}</Text>
</Pressable>
<Pressable style={styles.actionButton}>
<ChangeIcon />
<Text style={styles.actionButtonText}>{t('generationRecord.regenerate')}</Text>
</Pressable>
<Pressable
style={styles.deleteButton}
onPress={() => setDeleteDialogOpen(true)}
>
<DeleteIcon />
</Pressable>
</View>
</ScrollView>
{/* 删除确认弹窗 */}
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDelete}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 20,
},
backButton: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '600',
flex: 1,
textAlign: 'center',
},
headerSpacer: {
width: 22,
},
categorySection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
gap:4,
},
categoryIcon: {
width: 16,
height: 16,
alignItems: 'center',
justifyContent: 'center',
},
categoryText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '600',
},
originalImageSection: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
},
originalImageLabel: {
color: '#8A8A8A',
fontSize: 13,
marginRight: 6,
},
originalImageDivider: {
width: 1,
height: 12,
backgroundColor: '#FFFFFF33',
},
imageContainer: {
width: screenWidth - 24,
height: (screenWidth - 24) * 1.32, // 根据设计稿比例计算
marginHorizontal: 12,
marginBottom: 14,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
mainImage: {
width: '100%',
height: '100%',
},
durationText: {
paddingLeft: 28,
color: '#F5F5F5',
fontSize: 13,
marginBottom: 22,
fontWeight: '500',
},
actionButtons: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
gap: 8,
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 8,
paddingVertical: 6,
borderRadius: 8,
backgroundColor: '#1C1E22',
height: 32,
},
actionButtonText: {
color: '#F5F5F5',
fontSize: 11,
fontWeight: '500',
},
deleteButton: {
width: 32,
height: 32,
borderRadius: 8,
backgroundColor: '#1C1E22',
alignItems: 'center',
justifyContent: 'center',
marginLeft: 'auto',
},
})

770
app/membership.tsx Normal file
View File

@@ -0,0 +1,770 @@
import { useState, useEffect, useRef } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
StatusBar as RNStatusBar,
Platform,
useWindowDimensions,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { Image } from 'expo-image'
import Carousel from 'react-native-reanimated-carousel'
import Svg, { Path, Defs, LinearGradient as SvgLinearGradient, Stop } from 'react-native-svg'
import { CheckIcon, LeftArrowIcon, OmitIcon, UncheckedIcon, TermsIcon, PrivacyIcon } from '@/components/icon'
import { CheckMarkIcon } from '@/components/icon/checkMark'
import PointsDrawer from '@/components/drawer/PointsDrawer'
import Dropdown from '@/components/ui/dropdown'
import GradientText from '@/components/GradientText'
// 使用唯一 id 的 PointsIcon避免与其他页面的图标 id 冲突
const MembershipPointsIcon = () => {
const gradientId = 'paint0_linear_membership'
return (
<Svg width="14" height="14" viewBox="0 0 14 14" fill="none" preserveAspectRatio="xMidYMid meet">
<Defs>
<SvgLinearGradient
id={gradientId}
x1="7.00033"
y1="1.73615"
x2="7.00033"
y2="12.2639"
gradientUnits="userSpaceOnUse"
>
<Stop offset="0" stopColor="#FFC738" />
<Stop offset="1" stopColor="#FFA92D" />
</SvgLinearGradient>
</Defs>
<Path
d="M10.297 6.5548H8.38602C8.21382 6.5548 8.08222 6.3994 8.11022 6.23L8.79062 2.0622C8.83542 1.7892 8.49802 1.624 8.30902 1.8256L3.49862 6.9734C3.33202 7.1526 3.45802 7.4452 3.70302 7.4452H5.61402C5.78622 7.4452 5.91782 7.6006 5.88982 7.77L5.20942 11.9378C5.16462 12.2108 5.50202 12.376 5.69102 12.1744L10.5014 7.0266C10.6694 6.8474 10.542 6.5548 10.297 6.5548Z"
fill={`url(#${gradientId})`}
/>
</Svg>
)
}
type PlanType = 'plus' | 'pro' | 'plus-premium'
interface Plan {
id: PlanType
name: string
price: number
recommended?: boolean
points: number // 每月积分
features: string[] // 功能列表
}
export default function MembershipScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { width: screenWidth } = useWindowDimensions()
const [selectedPlan, setSelectedPlan] = useState<PlanType>('pro')
const [agreed, setAgreed] = useState(false)
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
const { t } = useTranslation()
// 订阅计划数据(使用国际化)
const plans: Plan[] = [
{
id: 'plus',
name: 'Plus',
price: 9,
points: 750,
features: [
t('membership.features.points750'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
]
},
{
id: 'pro',
name: 'Pro',
price: 29,
recommended: true,
points: 1080,
features: [
t('membership.features.points1080'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
]
},
{
id: 'plus-premium',
name: 'Plus',
price: 49,
points: 1500,
features: [
t('membership.features.points1500'),
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
t('membership.features.higherQuality'),
t('membership.features.prioritySupport'),
]
},
]
// 下拉菜单选项
const menuOptions = [
{ label: t('membership.terms'), value: 'terms' },
{ label: t('membership.privacy'), value: 'privacy' },
]
// 处理菜单选择
const handleMenuSelect = (value: string) => {
if (value === 'terms') {
router.push('/terms')
} else if (value === 'privacy') {
router.push('/privacy')
}
}
// 轮播图相关
const carouselImages = [
require('@/assets/images/membership.png'),
require('@/assets/images/icon.png'),
require('@/assets/images/generate.png'),
]
const [currentImageIndex, setCurrentImageIndex] = useState(0)
// 获取当前选中计划的信息
const currentPlan = plans.find(plan => plan.id === selectedPlan) || plans[1]
// 计算进度条百分比:当前计划积分 / 最高计划积分
const maxPoints = Math.max(...plans.map(plan => plan.points))
const progressPercentage = (currentPlan.points / maxPoints) * 100
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar
barStyle="light-content"
backgroundColor="#090A0B"
translucent={Platform.OS === 'android'}
/>
<View style={styles.contentWrapper}>
{/* 顶部导航栏 - 固定在顶部 */}
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
>
<LeftArrowIcon className="w-4 h-4" />
</Pressable>
<View style={styles.headerSpacer} />
<View style={styles.pointsContainer}>
<Pressable
style={styles.pointsPill}
onPress={() => setPointsDrawerVisible(true)}
>
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
<MembershipPointsIcon />
<Text style={styles.pointsValue}>60</Text>
</Pressable>
<View style={styles.settingsButtonContainer}>
<Dropdown
options={menuOptions}
onSelect={(value) => handleMenuSelect(value)}
offsetTop={10}
renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable style={styles.settingsButton} onPress={toggle}>
<OmitIcon />
</Pressable>
)}
dropdownStyle={styles.menuDropdown}
renderOption={(option, isSelected) => (
<View style={styles.menuOption}>
{option.value === 'terms' ? (
<TermsIcon />
) : (
<PrivacyIcon />
)}
<Text style={styles.menuOptionText}>{option.label}</Text>
</View>
)}
/>
</View>
</View>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View style={styles.imageContainer}>
<Carousel
width={screenWidth}
height={screenWidth / 1.1}
data={carouselImages}
renderItem={({ item }) => (
<Image
source={item}
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
contentFit="cover"
/>
)}
autoPlay
autoPlayInterval={2000}
loop
onSnapToItem={(index) => setCurrentImageIndex(index)}
/>
<View style={styles.dotsContainer}>
{carouselImages.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentImageIndex && styles.dotActive,
]}
/>
))}
</View>
<LinearGradient
colors={['#090A0B00', '#090A0B']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.imageGradient}
pointerEvents="none"
/>
</View>
{/* 订阅计划标题 */}
<Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text>
{/* 订阅计划卡片 */}
<View style={styles.plansContainer}>
{plans.map((plan, index) => {
const isSelected = selectedPlan === plan.id
return (
<Pressable
key={plan.id}
style={[
styles.planCardWrapper,
index > 0 && styles.planCardSpacing,
]}
onPress={() => setSelectedPlan(plan.id)}
>
{isSelected ? (
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.planCardGradient}
>
<View style={styles.planCard}>
{plan.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
$
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View>
</View>
</LinearGradient>
) : (
<View style={styles.planCardWrapperUnselected}>
<View style={styles.planCard}>
{plan.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
$
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View>
</View>
</View>
)}
</Pressable>
)
})}
</View>
{/* 卡片所属的信息 */}
<View style={styles.pointsMonthlyContainer}>
{/* 积分每月显示 */}
<View style={styles.pointsMonthlyCard}>
<View style={styles.pointsMonthlyHeader}>
<MembershipPointsIcon />
<Text style={styles.pointsMonthlyValue}>
{currentPlan.points.toLocaleString()}
</Text>
<Text style={styles.pointsMonthlyLabel}>{t('membership.pointsPerMonth')}</Text>
</View>
<View style={styles.progressBar}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
/>
</View>
<Text style={styles.pointsMonthlyNote}>{t('membership.pointsAutoRenew')}</Text>
</View>
{/* 功能列表 */}
<View >
{currentPlan.features.map((feature, index) => (
<View key={index} style={styles.featureItem}>
<CheckMarkIcon />
<Text style={styles.featureText}>{feature}</Text>
</View>
))}
</View>
</View>
</ScrollView>
{/* 固定在底部的订阅容器 */}
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
{/* 立即开通按钮 */}
<Pressable
disabled={!agreed}
style={styles.subscribeButtonPressable}
>
<LinearGradient
colors={currentPlan.recommended
? ['#9966FF', '#FF6699', '#FF9966']
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
}
locations={currentPlan.recommended
? [0.0015, 0.4985, 0.9956]
: [0, 0.33, 0.66, 1]
}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 0 }}
style={[
styles.subscribeButton,
!agreed && styles.subscribeButtonDisabled,
]}
>
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
</LinearGradient>
</Pressable>
{/* 协议复选框 */}
<View style={styles.agreementContainer}>
<Pressable
style={styles.checkbox}
onPress={() => setAgreed(!agreed)}
>
{agreed ? <CheckIcon /> : <UncheckedIcon />}
</Pressable>
<View style={styles.agreementTextWrapper}>
<Text style={styles.agreementText}>
{t('membership.agreementText')}{' '}
<Text
style={styles.agreementLink}
onPress={() => router.push('/terms')}
>
{t('membership.terms')}
</Text>
<Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text>
<Text
style={styles.agreementLink}
onPress={() => router.push('/privacy')}
>
{t('membership.privacy')}
</Text>
</Text>
</View>
</View>
</View>
</View>
{/* 积分抽屉 */}
<PointsDrawer
visible={pointsDrawerVisible}
onClose={() => setPointsDrawerVisible(false)}
totalPoints={60}
subscriptionPoints={0}
topUpPoints={0}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
contentWrapper: {
flex: 1,
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
backgroundColor: '#090A0B',
flexGrow: 1,
paddingTop: 56, // 为固定的导航栏留出空间
},
header: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 7,
paddingBottom: 7,
zIndex: 100,
backgroundColor: 'transparent',
},
imageContainer: {
width: '100%',
overflow: 'hidden',
marginTop: -56,
position: 'relative',
},
membershipImage: {
width: '100%',
height: '100%',
},
imageGradient: {
position: 'absolute',
bottom: -20,
left: 0,
right: 0,
height: '30%',
zIndex: 10,
elevation: 10,
},
dotsContainer: {
position: 'absolute',
bottom: 40,
left: 16,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: 4,
zIndex: 20,
elevation: 20,
},
dot: {
width: 4,
height: 4,
borderRadius: 100,
backgroundColor: '#FFFFFF80',
},
dotActive: {
width: 10,
height: 4,
borderRadius: 100,
backgroundColor: '#FFFFFF',
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 100,
},
headerSpacer: {
flex: 1,
},
pointsPill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 100,
backgroundColor: '#2A2A2A80',
},
pointsLabel: {
color: '#F5F5F5',
fontSize: 11,
fontWeight: '500',
},
pointsValue: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
},
sectionTitle: {
textAlign: 'center',
color: '#F5F5F5',
fontSize: 24,
fontWeight: '900',
lineHeight: 32,
marginBottom: 16,
marginTop: 9,
fontStyle: 'italic',
},
plansContainer: {
flexDirection: 'row',
paddingHorizontal: 16,
marginBottom: 16,
},
planCardWrapper: {
flex: 1,
},
planCardGradient: {
flex: 1,
borderRadius: 12,
padding: 2,
},
planCardWrapperUnselected: {
flex: 1,
borderRadius: 12,
padding: 2,
backgroundColor: 'transparent',
},
planCard: {
flex: 1,
paddingTop: 12,
paddingBottom: 16,
paddingHorizontal: 12,
borderRadius: 12,
backgroundColor: '#16181B',
position: 'relative',
overflow: 'hidden',
},
planCardSpacing: {
marginLeft: 12,
},
recommendedBadge: {
position: 'absolute',
top: -3,
right: -2,
paddingHorizontal: 11,
paddingBottom: 1,
borderRadius: 4,
backgroundColor: '#6851EB',
borderTopRightRadius: 12,
borderBottomRightRadius: 0,
},
recommendedText: {
color: '#F5F5F5',
fontSize: 10,
fontWeight: '500',
},
planName: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
marginBottom: 14,
},
priceContainer: {
flexDirection: 'row',
alignItems: 'baseline',
},
priceSymbol: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
marginRight: 2,
},
priceValue: {
color: '#F5F5F5',
fontSize: 24,
fontWeight: '500',
marginRight: 4,
},
priceUnit: {
color: '#ABABAB',
fontSize: 12,
fontWeight: '500',
},
pointsMonthlyContainer: {
marginHorizontal: 16,
backgroundColor: '#191B1F',
paddingHorizontal: 12,
paddingVertical: 12,
borderRadius: 12,
marginBottom: 16,
},
pointsMonthlyCard: {
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: '#272A30',
marginBottom: 24,
},
pointsMonthlyHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
pointsMonthlyLabelWrapper: {
marginLeft: 8,
},
pointsMonthlyValue: {
color: '#F5F5F5',
fontSize: 13,
fontWeight: '500',
},
pointsMonthlyLabel: {
color: '#F5F5F5',
fontSize: 13,
fontWeight: '500',
},
progressBar: {
width: '100%',
height: 3,
backgroundColor: '#484F5B',
borderRadius: 10,
marginBottom: 10,
overflow: 'hidden',
},
progressFill: {
height: '100%',
borderRadius: 2,
},
pointsMonthlyNote: {
color: '#CCCCCC',
fontSize: 11,
fontWeight: '400',
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 16,
marginBottom: 12,
},
featureText: {
flex: 1,
color: '#CCCCCC',
fontSize: 12,
fontWeight: '400',
},
agreementContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
agreementTextWrapper: {
marginLeft: 4,
},
checkbox: {
width: 12,
height: 12,
alignItems: 'center',
justifyContent: 'center',
},
agreementText: {
color: '#CCCCCC',
fontSize: 11,
fontWeight: '400',
},
agreementLink: {
color: '#CCCCCC',
},
subscribeContainer: {
paddingTop: 8,
alignItems: 'center',
paddingHorizontal: 16,
},
subscribeButtonPressable: {
width: '100%',
height: 48,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
subscribeButton: {
width: '100%',
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
},
subscribeButtonDisabled: {
opacity: 0.5,
},
subscribeButtonText: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '500',
},
subscribeButtonText1: {
color: '#0D0D0E',
fontSize: 16,
fontWeight: '500',
},
settingsButtonContainer: {
position: 'relative',
},
settingsButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#2A2A2A80',
borderRadius: 100,
},
pointsContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
menuDropdown: {
width: 180,
minWidth: 180,
borderRadius: 12,
backgroundColor: '#2A2A2A80',
paddingVertical: 4,
right: 12,
},
menuOption: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 2,
gap: 8,
},
menuOptionText: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '400',
flex: 1,
},
})

29
app/modal.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Link } from 'expo-router';
import { StyleSheet } from 'react-native';
import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view';
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});

200
app/privacy.tsx Normal file
View File

@@ -0,0 +1,200 @@
import { View, Text, StyleSheet, ScrollView, Pressable, Platform } from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { StatusBar as RNStatusBar } from 'react-native'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon } from '@/components/icon'
export default function PrivacyScreen() {
const router = useRouter()
const { t } = useTranslation()
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar
barStyle="light-content"
backgroundColor="#090A0B"
translucent={Platform.OS === 'android'}
/>
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => {
router.back()
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<LeftArrowIcon className="w-4 h-4" />
</Pressable>
<Text style={styles.headerTitle}>{t('privacy.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Text style={styles.title}> / Privacy Policy</Text>
<Text style={styles.sectionTitle}>1. / Introduction</Text>
<Text style={styles.paragraph}>
BOWONG AI (HK) LIMITED"我们"使
</Text>
<Text style={styles.paragraph}>
BOWONG AI (HK) LIMITED (hereinafter referred to as "we") values your privacy highly. This Privacy Policy explains how we handle your information when you use our services.
</Text>
<Text style={styles.sectionTitle}>2. / Data Minimization Statement</Text>
<Text style={styles.subsectionTitle}>2.1 / Information We Collect</Text>
<Text style={styles.paragraph}>
Email
</Text>
<Text style={styles.paragraph}>
We adhere to the principle of data minimization. To provide our services and facilitate login authentication, we only collect and store the email address provided by you during registration.
</Text>
<Text style={styles.subsectionTitle}>2.2 / Information We Do NOT Collect</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
We do not store your name, phone number, physical address, or any other personally identifiable information.
</Text>
<Text style={styles.sectionTitle}>3. / Payment Information Security</Text>
<Text style={styles.subsectionTitle}>3.1 / Payment Data Processing</Text>
<Text style={styles.paragraph}>
Stripe
</Text>
<Text style={styles.paragraph}>
All payment transactions are securely processed by our third-party payment processor, Stripe. We do not store or process your credit card numbers, bank account details, or any sensitive payment information on our servers.
</Text>
<Text style={styles.subsectionTitle}>3.2 Stripe / Stripe Privacy Policy</Text>
<Text style={styles.paragraph}>
Stripe Stripe
</Text>
<Text style={styles.paragraph}>
The payment information you provide is subject to Stripe's Privacy Policy. We recommend that you review Stripe's Privacy Policy to understand their data processing practices.
</Text>
<Text style={styles.sectionTitle}>4. 使 / Use of Information</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
Your email address is used solely for the following purposes:
</Text>
<Text style={styles.listItem}>1. / As your unique credential for logging into the system</Text>
<Text style={styles.listItem}>2. / Receiving notifications regarding subscription status, invoices, and system updates</Text>
<Text style={styles.listItem}>3. 退 / Processing customer support and refund requests</Text>
<Text style={styles.sectionTitle}>5. / Data Sharing and Disclosure</Text>
<Text style={styles.paragraph}>
Stripe
</Text>
<Text style={styles.paragraph}>
We will not sell, rent, or share your email address with any third parties, except as required by law or to enforce our terms of service (e.g., processing payments via Stripe).
</Text>
<Text style={styles.sectionTitle}>6. / Contact Us</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
If you have any questions regarding this Privacy Policy, please contact us at:
</Text>
<Text style={styles.listItem}> / Company Name: BOWONG AI (HK) LIMITED</Text>
<Text style={styles.listItem}> / Support Email: support@bowong.ai</Text>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 7,
paddingBottom: 7,
backgroundColor: '#090A0B',
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 100,
zIndex: 10,
},
headerTitle: {
flex: 1,
color: '#F5F5F5',
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginLeft: -32,
},
headerSpacer: {
width: 32,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 32,
},
title: {
color: '#F5F5F5',
fontSize: 24,
fontWeight: '900',
marginBottom: 24,
textAlign: 'center',
},
sectionTitle: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '700',
marginTop: 24,
marginBottom: 12,
},
subsectionTitle: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '600',
marginTop: 16,
marginBottom: 8,
},
paragraph: {
color: '#CCCCCC',
fontSize: 14,
lineHeight: 22,
marginBottom: 12,
},
listItem: {
color: '#CCCCCC',
fontSize: 14,
lineHeight: 22,
marginBottom: 8,
marginLeft: 8,
},
footer: {
color: '#CCCCCC',
fontSize: 14,
lineHeight: 22,
marginTop: 24,
textAlign: 'center',
fontStyle: 'italic',
},
})

118
app/searchResults.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { useState } from 'react'
import {
View,
StyleSheet,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter, useLocalSearchParams } from 'expo-router'
import SearchResultsGrid from '@/components/SearchResultsGrid'
import SearchBar from '@/components/SearchBar'
// 搜索结果数据
const searchResults = [
{
id: 1,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 236,
},
{
id: 2,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 131,
},
{
id: 3,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 236,
},
{
id: 4,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 236,
},
{
id: 5,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 95,
},
{
id: 6,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 236,
},
{
id: 7,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 228,
},
{
id: 8,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 95,
},
{
id: 9,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 228,
},
]
export default function SearchResultsScreen() {
const router = useRouter()
const params = useLocalSearchParams()
const [searchText, setSearchText] = useState((params.q as string) || '')
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* Top Bar with Search */}
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={(text) => {
// 搜索结果页面的搜索按钮可以保持当前页面或执行搜索
}}
onBack={() => router.back()}
readOnly={true}
onInputPress={() => {
router.push({
pathname: '/searchTemplate',
params: { q: searchText, focus: 'true' },
})
}}
onClearPress={() => {
router.push({
pathname: '/searchTemplate',
params: { q: '', focus: 'true' },
})
}}
marginBottom={12}
/>
{/* 搜索结果网格 */}
<SearchResultsGrid results={searchResults} />
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
})

290
app/searchTemplate.tsx Normal file
View File

@@ -0,0 +1,290 @@
import { useState, useEffect, useRef } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Pressable,
StatusBar as RNStatusBar,
Dimensions,
TextInput,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { DeleteIcon, ChangeIcon, Close1Icon } from '@/components/icon'
import SearchBar from '@/components/SearchBar'
const { width: screenWidth } = Dimensions.get('window')
// 探索更多数据
const exploreMore = [
'照片自动唱歌',
'头像ai漫画',
'冬天第一束花',
'真人唱歌动图',
'背景替换',
'闪电动态头像',
'用自己的头像唱歌',
'动漫头像',
'人像',
'戳戳手',
]
export default function SearchTemplateScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const [searchText, setSearchText] = useState('猫咪')
const [isDeleteMode, setIsDeleteMode] = useState(false)
const [searchHistory, setSearchHistory] = useState(['猫咪写真'])
const inputRef = useRef<TextInput>(null)
const isNavigatingRef = useRef(false)
// 当从搜索结果页面返回时,更新搜索文本
useEffect(() => {
if (params.q && typeof params.q === 'string') {
setSearchText(params.q)
}
}, [params.q])
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* Top Bar with Search */}
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={(text) => {
router.push({
pathname: '/searchResults',
params: { q: text },
})
}}
onBack={() => router.push('/(tabs)')}
inputRef={inputRef}
autoFocus={params.focus === 'true'}
/>
{/* 搜索历史和推荐标签 */}
<ScrollView
style={styles.scrollView}
// contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 搜索历史 */}
<View style={styles.historySection}>
<View style={styles.historyHeader}>
<Text style={styles.sectionTitle}>{t('search.history')}</Text>
{isDeleteMode ? (
<View style={styles.deleteActions}>
<Pressable
onPress={() => {
setSearchHistory([])
setIsDeleteMode(false)
}}
>
<Text style={styles.deleteActionText}>{t('search.clearAll')}</Text>
</Pressable>
<View style={styles.deleteActionSeparator} />
<Pressable
onPress={() => setIsDeleteMode(false)}
>
<Text style={styles.deleteActionText}>{t('search.done')}</Text>
</Pressable>
</View>
) : (
<Pressable
onPress={() => setIsDeleteMode(true)}
>
<DeleteIcon />
</Pressable>
)}
</View>
<View style={styles.historyTags}>
{searchHistory.map((item, index) => (
<View
key={index}
style={styles.historyTagContainer}
>
<Pressable
style={styles.historyTag}
onPress={() => {
if (!isDeleteMode && !isNavigatingRef.current) {
isNavigatingRef.current = true
requestAnimationFrame(() => {
router.push({
pathname: '/searchResults',
params: { q: item },
})
setTimeout(() => {
isNavigatingRef.current = false
}, 500)
})
}
}}
>
<Text style={styles.historyTagText}>{item}</Text>
{isDeleteMode && (
<Pressable
style={styles.historyTagDeleteButton}
onPress={() => {
setSearchHistory(searchHistory.filter((_, i) => i !== index))
}}
>
<Close1Icon />
</Pressable>
)}
</Pressable>
</View>
))}
</View>
</View>
{/* 探索更多 */}
<View>
<View style={styles.exploreHeader}>
<Text style={styles.sectionTitle}>{t('search.exploreMore')}</Text>
<Pressable style={styles.refreshButton}>
<ChangeIcon />
<Text style={styles.refreshButtonText}>{t('search.refresh')}</Text>
</Pressable>
</View>
<View style={styles.exploreTags}>
{exploreMore.map((item, index) => (
<Pressable
key={index}
style={styles.exploreTag}
onPress={() => {
if (!isNavigatingRef.current) {
isNavigatingRef.current = true
requestAnimationFrame(() => {
router.push({
pathname: '/searchResults',
params: { q: item },
})
setTimeout(() => {
isNavigatingRef.current = false
}, 500)
})
}
}}
>
<Text style={styles.exploreTagText}>{item}</Text>
</Pressable>
))}
</View>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
paddingHorizontal: 12,
},
historySection: {
paddingBottom:32,
},
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sectionTitle: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
},
historyTags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginTop: 16,
},
historyTagContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
historyTag: {
backgroundColor: '#191B1F',
borderRadius: 100,
paddingHorizontal: 11,
paddingVertical: 6,
borderWidth: 1,
borderColor: '#191B1F',
height: 29,
justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
historyTagText: {
color: '#F5F5F5',
fontSize: 12,
},
historyTagDeleteButton: {
justifyContent: 'center',
alignItems: 'center',
},
deleteActions: {
flexDirection: 'row',
alignItems: 'center',
},
deleteActionText: {
color: '#ABABAB',
fontSize: 14,
fontWeight: '400',
},
deleteActionSeparator: {
width: 1,
height: 14,
backgroundColor: '#ABABAB',
marginHorizontal: 12,
},
exploreHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
},
refreshButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 1,
paddingVertical: 4,
},
refreshButtonText: {
color: '#8A8A8A',
fontSize: 12,
fontWeight: '400',
},
exploreTags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
exploreTag: {
width: (screenWidth - 24 - 8) / 2, // 屏幕宽度 - 左右padding(12*2) - gap(8) 除以2
justifyContent: 'center',
paddingVertical: 2,
},
exploreTagText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '400',
},
})

246
app/searchWorks.tsx Normal file
View File

@@ -0,0 +1,246 @@
import { useState, useRef, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
StatusBar as RNStatusBar,
TextInput,
Pressable,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useTranslation } from 'react-i18next'
import SearchBar from '@/components/SearchBar'
import { DeleteIcon, Close1Icon } from '@/components/icon'
export default function SearchWorksScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const [searchText, setSearchText] = useState('')
const [isDeleteMode, setIsDeleteMode] = useState(false)
const [searchHistory, setSearchHistory] = useState<string[]>(['写真', '萌宠', '合拍', '风景'])
const inputRef = useRef<TextInput>(null)
const isNavigatingRef = useRef(false)
// 当从搜索结果页面返回时,更新搜索文本
useEffect(() => {
if (params.q && typeof params.q === 'string') {
setSearchText(params.q)
}
}, [params.q])
const handleSearch = (text: string) => {
const trimmedText = text.trim()
if (trimmedText) {
// 添加到搜索历史(去重,新搜索词放在最前面)
setSearchHistory((prev) => {
const filtered = prev.filter((item) => item !== trimmedText)
return [trimmedText, ...filtered].slice(0, 10) // 最多保留10条
})
// 跳转到搜索结果页面
router.push({
pathname: '/searchWorksResults',
params: { q: trimmedText },
})
}
}
const handleHistoryTagPress = (item: string) => {
if (!isDeleteMode && !isNavigatingRef.current) {
isNavigatingRef.current = true
requestAnimationFrame(() => {
router.push({
pathname: '/searchWorksResults',
params: { q: item },
})
// 延迟重置,防止快速重复点击
setTimeout(() => {
isNavigatingRef.current = false
}, 500)
})
}
}
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* Top Bar with Search */}
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={handleSearch}
onBack={() => router.push('/worksList')}
placeholder={t('search.searchWorks')}
inputRef={inputRef}
autoFocus={true}
/>
{/* 内容区域 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 搜索历史 */}
{searchHistory.length > 0 && (
<View style={styles.historySection}>
<View style={styles.historyHeader}>
<Text style={styles.sectionTitle}>{t('search.history')}</Text>
{isDeleteMode ? (
<View style={styles.deleteActions}>
<Pressable
onPress={() => {
setSearchHistory([])
setIsDeleteMode(false)
}}
>
<Text style={styles.deleteActionText}>{t('search.clearAll')}</Text>
</Pressable>
<View style={styles.deleteActionSeparator} />
<Pressable
onPress={() => setIsDeleteMode(false)}
>
<Text style={styles.deleteActionText}>{t('search.done')}</Text>
</Pressable>
</View>
) : (
<Pressable
onPress={() => setIsDeleteMode(true)}
>
<DeleteIcon />
</Pressable>
)}
</View>
<View style={styles.historyTags}>
{searchHistory.map((item, index) => (
<View
key={index}
style={styles.historyTagContainer}
>
<Pressable
style={styles.historyTag}
onPress={() => handleHistoryTagPress(item)}
>
<Text style={styles.historyTagText}>{item}</Text>
{isDeleteMode && (
<Pressable
style={styles.historyTagDeleteButton}
onPress={() => {
setSearchHistory(searchHistory.filter((_, i) => i !== index))
}}
>
<Close1Icon />
</Pressable>
)}
</Pressable>
</View>
))}
</View>
</View>
)}
{searchHistory.length === 0 && (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t('search.searchWorksPlaceholder')}</Text>
</View>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
paddingHorizontal: 12,
},
scrollContent: {
paddingBottom: 20,
},
historySection: {
paddingBottom: 32,
},
historyHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
sectionTitle: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
lineHeight: 20,
},
historyTags: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
marginTop: 16,
},
historyTagContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
historyTag: {
backgroundColor: '#191B1F',
borderRadius: 100,
paddingHorizontal: 11,
paddingVertical: 6,
borderWidth: 1,
borderColor: '#191B1F',
height: 29,
justifyContent: 'center',
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
historyTagText: {
color: '#F5F5F5',
fontSize: 12,
},
historyTagDeleteButton: {
justifyContent: 'center',
alignItems: 'center',
},
deleteActions: {
flexDirection: 'row',
alignItems: 'center',
},
deleteActionText: {
color: '#ABABAB',
fontSize: 14,
fontWeight: '400',
},
deleteActionSeparator: {
width: 1,
height: 14,
backgroundColor: '#ABABAB',
marginHorizontal: 12,
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingTop: 100,
},
emptyText: {
color: '#8A8A8A',
fontSize: 14,
},
})

113
app/searchWorksResults.tsx Normal file
View File

@@ -0,0 +1,113 @@
import { useState } from 'react'
import {
StyleSheet,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter, useLocalSearchParams } from 'expo-router'
import { useTranslation } from 'react-i18next'
import SearchBar from '@/components/SearchBar'
import WorksGallery, { type Category, type WorkItem } from '@/components/WorksGallery'
// 模拟搜索结果数据
const mockSearchResults: WorkItem[] = [
{ id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
{ id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
{ id: 3, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
{ id: 4, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
{ id: 5, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
{ id: 6, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
]
export default function SearchWorksResultsScreen() {
const { t } = useTranslation()
const router = useRouter()
const params = useLocalSearchParams()
const [searchText, setSearchText] = useState((params.q as string) || '')
const categories: Category[] = [
t('worksList.all') as Category,
t('worksList.pets') as Category,
t('worksList.portrait') as Category,
t('worksList.together') as Category,
]
const [selectedCategory, setSelectedCategory] = useState<Category>(categories[0])
// 根据分类过滤结果
const filteredWorks = selectedCategory === categories[0]
? mockSearchResults
: mockSearchResults.filter(work => work.category === selectedCategory)
// 按日期分组
const filteredGroupedWorks = filteredWorks.reduce((acc, work) => {
// 使用日期作为分组键,格式化为 YYYY-MM-DD 以便正确分组
const dateKey = work.date instanceof Date
? work.date.toISOString().split('T')[0]
: new Date(work.date).toISOString().split('T')[0]
if (!acc[dateKey]) {
acc[dateKey] = []
}
acc[dateKey].push(work)
return acc
}, {} as Record<string, WorkItem[]>)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* Top Bar with Search */}
<SearchBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearch={(text) => {
router.push({
pathname: '/searchWorksResults',
params: { q: text },
})
}}
onBack={() => router.back()}
placeholder={t('search.searchWorks')}
marginBottom={0}
readOnly={true}
onInputPress={() => {
router.push({
pathname: '/searchWorks',
params: { q: searchText },
})
}}
onClearPress={() => {
router.push({
pathname: '/searchWorks',
params: { q: '' },
})
}}
/>
<WorksGallery
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
groupedWorks={filteredGroupedWorks}
onWorkPress={(id) => {
router.push({
pathname: '/generationRecord' as any,
params: { id: id.toString() },
})
}}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
})

121
app/templateDetail.tsx Normal file
View File

@@ -0,0 +1,121 @@
import {
View,
Text,
StyleSheet,
Pressable,
StatusBar as RNStatusBar,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon } from '@/components/icon'
import SearchResultsGrid from '@/components/SearchResultsGrid'
// 模板详情数据 - 根据实际需求可以改为从 API 获取
const templateDetailData = [
{
id: 1,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 214,
},
{
id: 2,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 236,
},
{
id: 3,
title: '猫咪圣诞写真',
image: require('@/assets/images/favicon.png'),
height: 214,
},
{
id: 4,
title: '猫咪圣诞写真',
image: require('@/assets/images/icon.png'),
height: 200,
},
{
id: 5,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 220,
},
{
id: 6,
title: '猫咪圣诞写真',
image: require('@/assets/images/android-icon-background.png'),
height: 214,
},
]
export default function TemplateDetailScreen() {
const { t } = useTranslation()
const router = useRouter()
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部导航栏 */}
<View style={styles.header}>
<Pressable
onPress={() => router.back()}
style={styles.backButton}
>
<LeftArrowIcon />
</Pressable>
</View>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.mainTitle}>
{t('templateDetail.title')}
</Text>
<Text style={styles.subTitle}>
{t('templateDetail.subtitle')}
</Text>
</View>
{/* 图片网格 */}
<SearchResultsGrid results={templateDetailData} />
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 12,
paddingHorizontal: 12,
paddingBottom: 24,
},
backButton: {
padding: 4,
},
titleSection: {
paddingHorizontal: 12,
paddingBottom: 24,
},
mainTitle: {
color: '#F5F5F5',
fontSize: 20,
fontWeight: '500',
marginBottom: 4,
},
subTitle: {
color: '#ABABAB',
fontSize: 14,
fontWeight: '400',
},
})

319
app/terms.tsx Normal file
View File

@@ -0,0 +1,319 @@
import { View, Text, StyleSheet, ScrollView, Pressable, Platform } from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { StatusBar as RNStatusBar } from 'react-native'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon } from '@/components/icon'
export default function TermsScreen() {
const router = useRouter()
const { t } = useTranslation()
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar
barStyle="light-content"
backgroundColor="#090A0B"
translucent={Platform.OS === 'android'}
/>
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => {
router.back()
}}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<LeftArrowIcon className="w-4 h-4" />
</Pressable>
<Text style={styles.headerTitle}>{t('terms.title')}</Text>
<View style={styles.headerSpacer} />
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<Text style={styles.title}> / Terms of Service</Text>
<Text style={styles.sectionTitle}>1. / Introduction</Text>
<Text style={styles.paragraph}>
使 BOWONG AI (HK) LIMITED"我们""本公司"使访使
</Text>
<Text style={styles.paragraph}>
Welcome to the services provided by BOWONG AI (HK) LIMITED (hereinafter referred to as "we" or "our company"). Please read these Terms of Service carefully before using our services. By accessing or using our services, you agree to be bound by these terms.
</Text>
<Text style={styles.sectionTitle}>2. / Service Description</Text>
<Text style={styles.subsectionTitle}>2.1 / Service Content</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
We provide AI-based design and content generation services, including but not limited to:
</Text>
<Text style={styles.listItem}> AI / AI-powered design tools and template generation</Text>
<Text style={styles.listItem}> / Creative content generation and editing features</Text>
<Text style={styles.listItem}> / Design project management and collaboration tools</Text>
<Text style={styles.listItem}> / Other related technical services</Text>
<Text style={styles.subsectionTitle}>2.2 / Service Modifications</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
We reserve the right to modify, suspend, or terminate any service at any time without prior notice. We shall not be liable for any losses that may result from service modifications.
</Text>
<Text style={styles.sectionTitle}>3. / User Accounts</Text>
<Text style={styles.subsectionTitle}>3.1 / Account Registration</Text>
<Text style={styles.paragraph}>
使
</Text>
<Text style={styles.paragraph}>
To use our services, you need to create an account. When registering, you must:
</Text>
<Text style={styles.listItem}> / Provide true, accurate, and complete information</Text>
<Text style={styles.listItem}> / Maintain and promptly update your account information</Text>
<Text style={styles.listItem}> / Be responsible for all activities under your account</Text>
<Text style={styles.subsectionTitle}>3.2 / Account Security</Text>
<Text style={styles.paragraph}>
使
</Text>
<Text style={styles.paragraph}>
You are responsible for maintaining the confidentiality of your account information, including your password. You agree to be responsible for all activities under your account and to immediately notify us of any unauthorized use.
</Text>
<Text style={styles.sectionTitle}>4. 使 / Acceptable Use</Text>
<Text style={styles.subsectionTitle}>4.1 使 / Legal Use</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
You agree to use our services only for lawful purposes and in compliance with all applicable laws and regulations.
</Text>
<Text style={styles.subsectionTitle}>4.2 / Prohibited Activities</Text>
<Text style={styles.paragraph}></Text>
<Text style={styles.paragraph}>You shall not:</Text>
<Text style={styles.listItem}> 使 / Use the service for any illegal activities</Text>
<Text style={styles.listItem}> / Upload, post, or transmit any illegal, harmful, threatening, abusive, harassing, defamatory, vulgar, obscene, or otherwise objectionable content</Text>
<Text style={styles.listItem}> / Infringe upon the intellectual property or other rights of others</Text>
<Text style={styles.listItem}> / Interfere with or disrupt the normal operation of the service</Text>
<Text style={styles.listItem}> 访 / Attempt to gain unauthorized access to any system or network</Text>
<Text style={styles.listItem}> 使 / Abuse the service using automated tools or scripts</Text>
<Text style={styles.sectionTitle}>5. / Intellectual Property</Text>
<Text style={styles.subsectionTitle}>5.1 / Our Rights</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
The service and all its content, including but not limited to text, graphics, logos, icons, images, audio clips, digital downloads, data compilations, and software, are the property of us or our content providers and are protected by copyright, trademark, and other intellectual property laws.
</Text>
<Text style={styles.subsectionTitle}>5.2 / User Content</Text>
<Text style={styles.paragraph}>
使使
</Text>
<Text style={styles.paragraph}>
You retain all rights to the content you upload or create. By using our services, you grant us a non-exclusive, worldwide, royalty-free license to use, copy, modify, distribute, and publicly display such content solely for the purpose of providing and improving our services.
</Text>
<Text style={styles.sectionTitle}>6. / Paid Services</Text>
<Text style={styles.subsectionTitle}>6.1 / Subscriptions and Payments</Text>
<Text style={styles.paragraph}>
退
</Text>
<Text style={styles.paragraph}>
Certain services may require paid subscriptions. You agree to pay all fees as displayed on our website. All fees are payable in USD or other specified currency and are non-refundable unless otherwise stated by law or us.
</Text>
<Text style={styles.subsectionTitle}>6.2 / Auto-Renewal</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
Subscriptions will automatically renew unless you cancel them. You may cancel auto-renewal at any time in your subscription settings.
</Text>
<Text style={styles.subsectionTitle}>6.3 退 / Refund Policy</Text>
<Text style={styles.paragraph}>
退
</Text>
<Text style={styles.paragraph}>
All payments are final and non-refundable unless otherwise required by law or stated by us. If you are dissatisfied with the service, please contact our customer support.
</Text>
<Text style={styles.sectionTitle}>7. / Privacy</Text>
<Text style={styles.paragraph}>
使
</Text>
<Text style={styles.paragraph}>
We take your privacy seriously. Please refer to our Privacy Policy for information on how we collect, use, and protect your information.
</Text>
<Text style={styles.sectionTitle}>8. / Disclaimer</Text>
<Text style={styles.subsectionTitle}>8.1 "按现状" / Service "As Is"</Text>
<Text style={styles.paragraph}>
"现状""可用"
</Text>
<Text style={styles.paragraph}>
The service is provided on an "as is" and "as available" basis without any express or implied warranties, including but not limited to warranties of merchantability, fitness for a particular purpose, and non-infringement.
</Text>
<Text style={styles.subsectionTitle}>8.2 / Limitation of Liability</Text>
<Text style={styles.paragraph}>
使
</Text>
<Text style={styles.paragraph}>
To the maximum extent permitted by law, we shall not be liable for any indirect, incidental, special, consequential, or punitive damages, even if we have been advised of the possibility of such damages.
</Text>
<Text style={styles.sectionTitle}>9. / Termination</Text>
<Text style={styles.subsectionTitle}>9.1 / Termination Rights</Text>
<Text style={styles.paragraph}>
访
</Text>
<Text style={styles.paragraph}>
We reserve the right to terminate or suspend your access to the service at any time, with or without prior notice, for reasons including but not limited to violation of these terms.
</Text>
<Text style={styles.subsectionTitle}>9.2 / Consequences of Termination</Text>
<Text style={styles.paragraph}>
访
</Text>
<Text style={styles.paragraph}>
Upon termination, your right to access the service will immediately cease. The provisions of these terms that by their nature should survive termination will continue to apply.
</Text>
<Text style={styles.sectionTitle}>10. / Modifications to Terms</Text>
<Text style={styles.paragraph}>
使
</Text>
<Text style={styles.paragraph}>
We reserve the right to modify these terms at any time. Material changes will be notified to you via email or website notice. Continued use of the service constitutes your acceptance of the modified terms.
</Text>
<Text style={styles.sectionTitle}>11. / Governing Law</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
These terms shall be governed by and construed in accordance with the laws of the Hong Kong Special Administrative Region. Any disputes shall be submitted to the competent courts of the Hong Kong Special Administrative Region.
</Text>
<Text style={styles.sectionTitle}>12. / Contact Us</Text>
<Text style={styles.paragraph}>
</Text>
<Text style={styles.paragraph}>
If you have any questions regarding these Terms of Service, please contact us at:
</Text>
<Text style={styles.listItem}> / Company Name: BOWONG AI (HK) LIMITED</Text>
<Text style={styles.listItem}> / Support Email: support@bowong.ai</Text>
<Text style={styles.footer}>
使
</Text>
<Text style={styles.footer}>
By using our services, you acknowledge that you have read, understood, and agree to be bound by these Terms of Service.
</Text>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
paddingTop: 7,
paddingBottom: 7,
backgroundColor: '#090A0B',
},
backButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 100,
zIndex: 10,
},
headerTitle: {
flex: 1,
color: '#F5F5F5',
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginLeft: -32,
},
headerSpacer: {
width: 32,
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingTop: 16,
paddingBottom: 32,
},
title: {
color: '#F5F5F5',
fontSize: 24,
fontWeight: '900',
marginBottom: 24,
textAlign: 'center',
},
sectionTitle: {
color: '#F5F5F5',
fontSize: 18,
fontWeight: '700',
marginTop: 24,
marginBottom: 12,
},
subsectionTitle: {
color: '#F5F5F5',
fontSize: 16,
fontWeight: '600',
marginTop: 16,
marginBottom: 8,
},
paragraph: {
color: '#CCCCCC',
fontSize: 14,
lineHeight: 22,
marginBottom: 12,
},
listItem: {
color: '#CCCCCC',
fontSize: 14,
lineHeight: 22,
marginBottom: 8,
marginLeft: 8,
},
footer: {
color: '#CCCCCC',
fontSize: 14,
lineHeight: 22,
marginTop: 24,
textAlign: 'center',
fontStyle: 'italic',
},
})

130
app/worksList.tsx Normal file
View File

@@ -0,0 +1,130 @@
import { useState } from 'react'
import {
View,
Text,
StyleSheet,
StatusBar as RNStatusBar,
Pressable,
} from 'react-native'
import { StatusBar } from 'expo-status-bar'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon, SearchIcon } from '@/components/icon'
import WorksGallery, { type Category, type WorkItem } from '@/components/WorksGallery'
const worksData: WorkItem[] = [
{ id: 1, date: new Date(2025, 10, 28), duration: '00:05', category: '萌宠' },
{ id: 2, date: new Date(2025, 10, 28), duration: '00:05', category: '写真' },
{ id: 3, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' },
{ id: 4, date: new Date(2025, 10, 27), duration: '00:05', category: '合拍' },
{ id: 5, date: new Date(2025, 10, 27), duration: '00:05', category: '写真' },
{ id: 6, date: new Date(2025, 10, 27), duration: '00:05', category: '萌宠' },
{ id: 7, date: new Date(2025, 10, 26), duration: '00:05', category: '合拍' },
{ id: 8, date: new Date(2025, 10, 26), duration: '00:05', category: '写真' },
]
export default function WorksListScreen() {
const { t } = useTranslation()
const router = useRouter()
const categories: Category[] = [
t('worksList.all') as Category,
t('worksList.pets') as Category,
t('worksList.portrait') as Category,
t('worksList.together') as Category,
]
const [selectedCategory, setSelectedCategory] = useState<Category>(categories[0])
const filteredWorks = selectedCategory === categories[0]
? worksData
: worksData.filter(work => work.category === selectedCategory)
const filteredGroupedWorks = filteredWorks.reduce((acc, work) => {
// 使用日期作为分组键,格式化为 YYYY-MM-DD 以便正确分组
const dateKey = work.date instanceof Date
? work.date.toISOString().split('T')[0]
: new Date(work.date).toISOString().split('T')[0]
if (!acc[dateKey]) {
acc[dateKey] = []
}
acc[dateKey].push(work)
return acc
}, {} as Record<string, WorkItem[]>)
return (
<SafeAreaView style={styles.container} edges={['top']}>
<StatusBar style="light" />
<RNStatusBar barStyle="light-content" />
{/* 顶部导航栏 */}
<View style={styles.header}>
<Pressable
style={styles.backButton}
onPress={() => router.push('/(tabs)/my')}
>
<LeftArrowIcon />
</Pressable>
<Text style={styles.headerTitle}>{t('worksList.title')}</Text>
<Pressable
style={styles.searchButton}
onPress={() => {
router.push('/searchWorks')
}}
>
<SearchIcon />
</Pressable>
</View>
<WorksGallery
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
groupedWorks={filteredGroupedWorks}
onWorkPress={(id) => {
router.push({
pathname: '/generationRecord' as any,
params: { id: id.toString() },
})
}}
/>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 12,
paddingTop: 13,
},
backButton: {
width: 22,
height: 22,
alignItems: 'center',
justifyContent: 'center',
},
headerTitle: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '500',
flex: 1,
textAlign: 'center',
},
searchButton: {
width: 20,
height: 20,
alignItems: 'center',
justifyContent: 'center',
},
})

3
assets/icons/back.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.17773 10L11.8262 15.6484C12.1523 15.9746 12.1523 16.502 11.8262 16.8262C11.5 17.1523 10.9727 17.1523 10.6484 16.8262L4.41016 10.5898C4.08398 10.2637 4.08398 9.73633 4.41016 9.41211L10.6484 3.17383C10.9746 2.84766 11.502 2.84766 11.8262 3.17383C12.1523 3.5 12.1523 4.02734 11.8262 4.35156L6.17773 10Z" fill="#F5F5F5"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1,3 @@
<svg width="10" height="4" viewBox="0 0 10 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="10" height="4" rx="2" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 151 B

View File

@@ -0,0 +1,3 @@
<svg width="4" height="4" viewBox="0 0 4 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="4" height="4" rx="2" fill="white" fill-opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 167 B

3
assets/icons/hot.svg Normal file
View File

@@ -0,0 +1,3 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12H12V3.57628e-06H0V12Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 155 B

5
assets/icons/more.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.25 12C4.25 12.2298 4.29527 12.4574 4.38321 12.6697C4.47116 12.882 4.60006 13.0749 4.76256 13.2374C4.92507 13.3999 5.11798 13.5288 5.3303 13.6168C5.54262 13.7047 5.77019 13.75 6 13.75C6.22981 13.75 6.45738 13.7047 6.6697 13.6168C6.88202 13.5288 7.07493 13.3999 7.23744 13.2374C7.39994 13.0749 7.52884 12.882 7.61679 12.6697C7.70474 12.4574 7.75 12.2298 7.75 12C7.75 11.7702 7.70474 11.5426 7.61679 11.3303C7.52884 11.118 7.39994 10.9251 7.23744 10.7626C7.07493 10.6001 6.88202 10.4712 6.6697 10.3832C6.45738 10.2953 6.22981 10.25 6 10.25C5.77019 10.25 5.54262 10.2953 5.3303 10.3832C5.11798 10.4712 4.92507 10.6001 4.76256 10.7626C4.60006 10.9251 4.47116 11.118 4.38321 11.3303C4.29527 11.5426 4.25 11.7702 4.25 12Z" fill="#F5F5F5"/>
<path d="M10.25 12C10.25 12.2298 10.2953 12.4574 10.3832 12.6697C10.4712 12.882 10.6001 13.0749 10.7626 13.2374C10.9251 13.3999 11.118 13.5288 11.3303 13.6168C11.5426 13.7047 11.7702 13.75 12 13.75C12.2298 13.75 12.4574 13.7047 12.6697 13.6168C12.882 13.5288 13.0749 13.3999 13.2374 13.2374C13.3999 13.0749 13.5288 12.882 13.6168 12.6697C13.7047 12.4574 13.75 12.2298 13.75 12C13.75 11.5359 13.5656 11.0908 13.2374 10.7626C12.9092 10.4344 12.4641 10.25 12 10.25C11.5359 10.25 11.0908 10.4344 10.7626 10.7626C10.4344 11.0908 10.25 11.5359 10.25 12Z" fill="#F5F5F5"/>
<path d="M16.25 12C16.25 12.4641 16.4344 12.9092 16.7626 13.2374C17.0908 13.5656 17.5359 13.75 18 13.75C18.4641 13.75 18.9092 13.5656 19.2374 13.2374C19.5656 12.9092 19.75 12.4641 19.75 12C19.75 11.5359 19.5656 11.0908 19.2374 10.7626C18.9092 10.4344 18.4641 10.25 18 10.25C17.5359 10.25 17.0908 10.4344 16.7626 10.7626C16.4344 11.0908 16.25 11.5359 16.25 12Z" fill="#F5F5F5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

20
assets/icons/shandian.svg Normal file
View File

@@ -0,0 +1,20 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3_3994)">
<path d="M11.0327 7.02295H8.98516C8.80066 7.02295 8.65966 6.85645 8.68966 6.67495L9.41866 2.20945C9.46666 1.91695 9.10516 1.73995 8.90266 1.95595L3.74866 7.47146C3.57016 7.66346 3.70516 7.97695 3.96766 7.97695H6.01516C6.19966 7.97695 6.34066 8.14346 6.31066 8.32495L5.58166 12.7905C5.53366 13.083 5.89516 13.26 6.09766 13.044L11.2517 7.52846C11.4317 7.33645 11.2952 7.02295 11.0327 7.02295Z" fill="url(#paint0_linear_3_3994)"/>
<path d="M11.0327 7.02295H8.98516C8.80066 7.02295 8.65966 6.85645 8.68966 6.67495L9.41866 2.20945C9.46666 1.91695 9.10516 1.73995 8.90266 1.95595L3.74866 7.47146C3.57016 7.66346 3.70516 7.97695 3.96766 7.97695H6.01516C6.19966 7.97695 6.34066 8.14346 6.31066 8.32495L5.58166 12.7905C5.53366 13.083 5.89516 13.26 6.09766 13.044L11.2517 7.52846C11.4317 7.33645 11.2952 7.02295 11.0327 7.02295Z" fill="url(#paint1_linear_3_3994)"/>
</g>
<defs>
<linearGradient id="paint0_linear_3_3994" x1="7.50049" y1="1.86011" x2="7.50011" y2="16.4062" gradientUnits="userSpaceOnUse">
<stop stop-color="#FE9E58"/>
<stop offset="0.5" stop-color="#E959F3"/>
<stop offset="1" stop-color="#5033EF"/>
</linearGradient>
<linearGradient id="paint1_linear_3_3994" x1="7.50049" y1="1.86011" x2="7.50049" y2="13.1398" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFC738"/>
<stop offset="1" stop-color="#FFA92D"/>
</linearGradient>
<clipPath id="clip0_3_3994">
<rect width="15" height="15" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
assets/images/demo-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

BIN
assets/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
assets/images/generate.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

BIN
assets/images/image-17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

14
babel.config.js Normal file
View File

@@ -0,0 +1,14 @@
module.exports = function (api) {
api.cache(true)
let plugins = [];
plugins.push('react-native-worklets/plugin');
return {
presets: [
["babel-preset-expo", { jsxImportSource: "nativewind" }],
"nativewind/babel",
],
plugins: [
...plugins
],
}
}

2676
bun.lock Normal file

File diff suppressed because it is too large Load Diff

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "app/global.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

View File

@@ -0,0 +1,75 @@
import React from 'react'
import { View, StyleSheet } from 'react-native'
import GradientText from './GradientText'
/**
* 渐变文字使用示例
*/
export default function GradientTextExample() {
return (
<View style={styles.container}>
{/* 水平渐变 */}
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.text}
>
</GradientText>
{/* 垂直渐变 */}
<GradientText
colors={['#9966FF', '#FF6699', '#FF9966']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.text}
>
</GradientText>
{/* 对角线渐变 */}
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.largeText}
>
线
</GradientText>
{/* 两色渐变 */}
<GradientText
colors={['#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.boldText}
>
</GradientText>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
gap: 30,
},
text: {
fontSize: 24,
fontWeight: '600',
},
largeText: {
fontSize: 32,
fontWeight: 'bold',
},
boldText: {
fontSize: 28,
fontWeight: 'bold',
},
})

140
components/GradientText.tsx Normal file
View File

@@ -0,0 +1,140 @@
import React, { useMemo } from 'react'
import { TextProps, StyleSheet, TextStyle, View, Text, ViewStyle } from 'react-native'
import Svg, { Defs, LinearGradient as SvgLinearGradient, Stop, Text as SvgText } from 'react-native-svg'
interface GradientTextProps extends Omit<TextProps, 'style'> {
colors: [string, string, ...string[]]
start?: { x: number; y: number }
end?: { x: number; y: number }
style?: TextStyle
}
/**
* 渐变文字组件(使用 SVG 实现,无需原生模块)
*
* @example
* <GradientText
* colors={['#FF9966', '#FF6699', '#9966FF']}
* start={{ x: 0, y: 0 }}
* end={{ x: 1, y: 0 }}
* style={{ fontSize: 24, fontWeight: 'bold' }}
* >
* 渐变文字
* </GradientText>
*/
export default function GradientText({
colors,
start = { x: 0, y: 0 },
end = { x: 1, y: 0 },
style,
children,
...textProps
}: GradientTextProps) {
const gradientId = useMemo(() => `gradient-${Math.random().toString(36).substr(2, 9)}`, [])
// 从 style 中提取字体相关属性
const fontSize = (style?.fontSize as number) || 14
const fontWeight = style?.fontWeight || 'normal'
const fontFamily = style?.fontFamily
const textAlign = style?.textAlign || 'left'
// 使用一个隐藏的 Text 来测量文字尺寸
const [textWidth, setTextWidth] = React.useState(0)
const [textHeight, setTextHeight] = React.useState(fontSize * 1.2)
// 计算渐变坐标(转换为百分比)
const x1 = `${start.x * 100}%`
const y1 = `${start.y * 100}%`
const x2 = `${end.x * 100}%`
const y2 = `${end.y * 100}%`
// 计算文字位置
const textX = textAlign === 'center' ? (textWidth / 2) : textAlign === 'right' ? textWidth : 0
const textY = textHeight * 0.75 // 垂直位置,稍微调整以匹配基线
// 从 style 中提取 ViewStyle 兼容的属性
const containerStyle: ViewStyle = {
...(style?.margin !== undefined && { margin: style.margin }),
...(style?.marginTop !== undefined && { marginTop: style.marginTop }),
...(style?.marginBottom !== undefined && { marginBottom: style.marginBottom }),
...(style?.marginLeft !== undefined && { marginLeft: style.marginLeft }),
...(style?.marginRight !== undefined && { marginRight: style.marginRight }),
...(style?.marginHorizontal !== undefined && { marginHorizontal: style.marginHorizontal }),
...(style?.marginVertical !== undefined && { marginVertical: style.marginVertical }),
}
return (
<View style={[styles.container, containerStyle]}>
{/* 隐藏的 Text 用于测量尺寸和占据空间 */}
<Text
style={[
styles.measureText,
{
fontSize,
fontWeight,
fontFamily,
textAlign,
},
]}
onLayout={(e) => {
const { width, height } = e.nativeEvent.layout
setTextWidth(width || 0)
setTextHeight(Math.max(height || fontSize * 1.2, fontSize * 1.2))
}}
>
{String(children)}
</Text>
{/* SVG 渐变文字 */}
{textWidth > 0 && textHeight > 0 && (
<Svg
width={textWidth}
height={textHeight}
style={styles.svgOverlay}
>
<Defs>
<SvgLinearGradient id={gradientId} x1={x1} y1={y1} x2={x2} y2={y2}>
{colors.map((color, index) => (
<Stop
key={index}
offset={`${(index / (colors.length - 1)) * 100}%`}
stopColor={color}
/>
))}
</SvgLinearGradient>
</Defs>
<SvgText
x={textX}
y={textY}
fontSize={fontSize}
fontWeight={fontWeight}
fontFamily={fontFamily}
textAnchor={textAlign === 'center' ? 'middle' : textAlign === 'right' ? 'end' : 'start'}
fill={`url(#${gradientId})`}
>
{String(children)}
</SvgText>
</Svg>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
backgroundColor: 'transparent',
position: 'relative',
},
measureText: {
opacity: 0,
includeFontPadding: false,
// 确保文本占据空间
minHeight: 1,
},
svgOverlay: {
position: 'absolute',
top: 0,
left: 0,
pointerEvents: 'none',
},
})

184
components/SearchBar.tsx Normal file
View File

@@ -0,0 +1,184 @@
import { useRef, useEffect } from 'react'
import {
View,
Text,
StyleSheet,
TextInput,
Pressable,
} from 'react-native'
import { useTranslation } from 'react-i18next'
import { LeftArrowIcon, CloseIcon } from '@/components/icon'
interface SearchBarProps {
searchText: string
onSearchTextChange: (text: string) => void
onSearch: (text: string) => void
onBack: () => void
placeholder?: string
autoFocus?: boolean
inputRef?: React.RefObject<TextInput | null>
showBackButton?: boolean
showSearchButton?: boolean
readOnly?: boolean
onInputPress?: () => void
onClearPress?: () => void
marginBottom?: number
}
export default function SearchBar({
searchText,
onSearchTextChange,
onSearch,
onBack,
placeholder,
autoFocus = false,
inputRef: externalInputRef,
showBackButton = true,
showSearchButton = true,
readOnly = false,
onInputPress,
onClearPress,
marginBottom = 20,
}: SearchBarProps) {
const { t } = useTranslation()
const internalInputRef = useRef<TextInput>(null)
const inputRef = externalInputRef || internalInputRef
const defaultPlaceholder = placeholder || t('searchBar.placeholder')
useEffect(() => {
if (autoFocus) {
const timer = setTimeout(() => {
inputRef.current?.focus()
}, 100)
return () => clearTimeout(timer)
}
}, [autoFocus, inputRef])
const handleClear = () => {
if (onClearPress) {
onClearPress()
} else {
onSearchTextChange('')
}
}
const inputContainer = (
<>
<TextInput
ref={inputRef}
style={styles.searchInput}
value={searchText}
onChangeText={readOnly ? undefined : onSearchTextChange}
placeholder={defaultPlaceholder}
placeholderTextColor="#ABABAB"
underlineColorAndroid="transparent"
selectionColor="#F5F5F5"
autoCorrect={false}
autoCapitalize="none"
editable={!readOnly}
pointerEvents={readOnly ? 'none' : 'auto'}
onSubmitEditing={() => {
if (searchText.trim()) {
onSearch(searchText.trim())
}
}}
/>
{searchText.length > 0 && (
<Pressable
onPress={(e) => {
if (readOnly && onInputPress) {
e.stopPropagation()
}
handleClear()
}}
style={styles.clearButton}
>
<CloseIcon />
</Pressable>
)}
</>
)
return (
<View style={[styles.topBar, { marginBottom }]}>
{showBackButton && (
<Pressable onPress={onBack}>
<LeftArrowIcon />
</Pressable>
)}
{readOnly && onInputPress ? (
<Pressable
style={styles.searchInputContainer}
onPress={onInputPress}
>
{inputContainer}
</Pressable>
) : (
<View style={styles.searchInputContainer}>
{inputContainer}
</View>
)}
{showSearchButton && (
<Pressable
style={styles.searchButton}
onPress={() => {
if (searchText.trim()) {
onSearch(searchText.trim())
}
}}
>
<Text style={styles.searchButtonText}>{t('searchBar.button')}</Text>
</Pressable>
)}
</View>
)
}
const styles = StyleSheet.create({
topBar: {
height: 44,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 12,
marginTop: 8,
marginBottom: 20,
},
searchInputContainer: {
flex: 1,
marginLeft: 4,
marginRight: 12,
position: 'relative',
height: 40,
},
searchInput: {
flex: 1,
color: '#F5F5F5',
fontSize: 14,
height: 40,
paddingVertical: 8,
paddingLeft: 12,
paddingRight: 36,
borderRadius: 100,
backgroundColor: '#16181B',
},
clearButton: {
position: 'absolute',
right: 8,
top: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
padding: 4,
},
searchButton: {
paddingHorizontal: 0,
paddingVertical: 8,
},
searchButtonText: {
color: '#F5F5F5',
fontSize: 14,
fontWeight: '400',
},
})

View File

@@ -0,0 +1,186 @@
import { useState } from 'react'
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
} from 'react-native'
import { Image } from 'expo-image'
import { useRouter } from 'expo-router'
import { useTranslation } from 'react-i18next'
import { WhiteStarIcon } from '@/components/icon'
const { width: screenWidth } = Dimensions.get('window')
interface SearchResultItem {
id: number
title: string
image: any
height: number
videoUrl?: any
thumbnailUrl?: any
duration?: string
}
interface SearchResultsGridProps {
results: SearchResultItem[]
}
export default function SearchResultsGrid({ results }: SearchResultsGridProps) {
const { t } = useTranslation()
const router = useRouter()
const [gridWidth, setGridWidth] = useState(screenWidth)
const horizontalPadding = 8 * 2 // gridContainer 的左右 padding
const cardGap = 5 // 两个卡片之间的间距
const cardWidth = (gridWidth - horizontalPadding - cardGap) / 2
const handleSameStylePress = (item: SearchResultItem) => {
// 构建模板数据,传递给 generateVideo 页面
const templateData = {
id: item.id,
videoUrl: item.videoUrl || item.image, // 如果没有 videoUrl使用 image
thumbnailUrl: item.thumbnailUrl || item.image, // 如果没有 thumbnailUrl使用 image
title: item.title,
duration: item.duration || '00:00', // 默认时长
}
router.push({
pathname: '/generateVideo' as any,
params: {
template: JSON.stringify(templateData),
},
} as any)
}
if (results.length === 0) {
return (
<View style={styles.emptyContainer}>
<Text style={styles.emptyText}>{t('searchResults.noResults')}</Text>
</View>
)
}
return (
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
<View
style={styles.gridContainer}
onLayout={(event) => {
const { width } = event.nativeEvent.layout
setGridWidth(width)
}}
>
{results.map((item, index) => (
<View
key={item.id}
style={[
styles.card,
{ width: cardWidth },
index % 2 === 0 ? styles.cardLeft : styles.cardRight,
]}
>
<View
style={[
styles.cardImageContainer,
{ height: item.height },
]}
>
<Image
source={item.image}
style={styles.cardImage}
contentFit="cover"
/>
</View>
<Text style={styles.cardTitle} numberOfLines={1}>
{item.title}
</Text>
<Pressable
style={styles.sameStyleButton}
onPress={() => handleSameStylePress(item)}
>
<WhiteStarIcon />
<Text style={styles.sameStyleText}>{t('searchResults.makeSame')}</Text>
</Pressable>
</View>
))}
</View>
</ScrollView>
)
}
const styles = StyleSheet.create({
scrollView: {
flex: 1,
},
scrollContent: {
// paddingBottom: 100,
},
gridContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
paddingHorizontal: 8,
justifyContent: 'space-between',
},
card: {
backgroundColor: '#16181B',
borderBottomLeftRadius: 12,
borderBottomRightRadius: 12,
marginBottom: 12,
},
cardLeft: {
marginRight: 0,
},
cardRight: {
marginLeft: 0,
},
cardImageContainer: {
width: '100%',
borderRadius: 12,
overflow: 'hidden',
marginBottom: 8,
position: 'relative',
},
cardImage: {
width: '100%',
height: '100%',
},
sameStyleButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: '#262A31',
height:32,
marginHorizontal: 8,
marginTop: 10,
marginBottom: 8,
borderRadius: 100,
justifyContent: 'center',
},
sameStyleText: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '500',
lineHeight: 17,
},
cardTitle: {
color: '#FFFFFF',
fontSize: 12,
fontWeight: '500',
paddingHorizontal: 8,
},
emptyContainer: {
flex: 1,
backgroundColor: '#090A0B',
justifyContent: 'center',
alignItems: 'center',
},
emptyText: {
color: '#8A8A8A',
fontSize: 14,
},
})

250
components/WorksGallery.tsx Normal file
View File

@@ -0,0 +1,250 @@
import {
View,
Text,
StyleSheet,
ScrollView,
Dimensions,
Pressable,
} from 'react-native'
import { Image } from 'expo-image'
import { LinearGradient } from 'expo-linear-gradient'
import { useTranslation } from 'react-i18next'
const { width: screenWidth } = Dimensions.get('window')
const GALLERY_GAP = 1
const GALLERY_COLUMNS = 4
const GALLERY_ITEM_SIZE = Math.floor(
(screenWidth - GALLERY_GAP * (GALLERY_COLUMNS - 1)) / GALLERY_COLUMNS
)
type Category = '全部' | '萌宠' | '写真' | '合拍'
interface WorkItem {
id: number
date: Date | string
duration: string
category: Category
}
interface WorksGalleryProps {
categories: Category[]
selectedCategory: Category
onCategoryChange: (category: Category) => void
groupedWorks: Record<string, WorkItem[]>
onWorkPress: (id: number) => void
}
export default function WorksGallery({
categories,
selectedCategory,
onCategoryChange,
groupedWorks,
onWorkPress,
}: WorksGalleryProps) {
const { i18n } = useTranslation()
// 格式化日期函数
const formatDate = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date
const locale = i18n.language === 'zh-CN' ? 'zh-CN' : 'en-US'
if (locale === 'zh-CN') {
// 中文格式2025年11月28日
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
}).format(dateObj).replace(/\//g, '年').replace(/(\d+)$/, '$1日')
} else {
// 英文格式November 28, 2025
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(dateObj)
}
}
return (
<>
{/* 分类标签 */}
<View style={styles.categoryContainer}>
{categories.map((category) => {
const isSelected = selectedCategory === category
return (
<Pressable
key={category}
style={styles.categoryTagWrapper}
onPress={() => onCategoryChange(category)}
>
{isSelected ? (
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.categoryTagGradient}
>
<View style={styles.categoryTag}>
<Text
style={[
styles.categoryTagText,
styles.categoryTagTextActive,
]}
>
{category}
</Text>
</View>
</LinearGradient>
) : (
<View style={styles.categoryTag}>
<Text style={styles.categoryTagText}>
{category}
</Text>
</View>
)}
</Pressable>
)
})}
</View>
{/* 作品列表 */}
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{Object.entries(groupedWorks).map(([dateKey, works]) => {
// 从第一个作品获取日期对象
const dateObj = works[0]?.date
const formattedDate = dateObj ? formatDate(dateObj) : dateKey
return (
<View key={dateKey}>
<Text style={styles.dateText}>{formattedDate}</Text>
<View style={styles.galleryGrid}>
{works.map((item, index) => (
<Pressable
key={item.id}
style={[
styles.galleryItem,
// 每行的前几个 item 有右边距最后一个没有4 列)
index % GALLERY_COLUMNS !== GALLERY_COLUMNS - 1 &&
styles.galleryItemMarginRight,
// 所有item都有下边距最后一行也会有但影响不大
styles.galleryItemMarginBottom,
]}
onPress={() => onWorkPress(item.id)}
>
<Image
source={require('@/assets/images/membership.png')}
style={styles.galleryImage}
contentFit="cover"
/>
<View style={styles.durationBadge}>
<Text style={styles.durationText}>
{item.duration}
</Text>
</View>
</Pressable>
))}
</View>
</View>
)
})}
</ScrollView>
</>
)
}
const styles = StyleSheet.create({
categoryContainer: {
flexDirection: 'row',
paddingHorizontal: 14,
paddingTop: 16,
gap: 8,
},
categoryTagWrapper: {
minWidth: 50,
height: 30,
},
categoryTagGradient: {
width: '100%',
height: '100%',
borderRadius: 8,
padding: 1,
},
categoryTag: {
flex: 1,
borderRadius: 8,
backgroundColor: '#1C1E22',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 12,
},
categoryTagText: {
color: '#F5F5F5',
fontSize: 11,
fontWeight: '500',
},
categoryTagTextActive: {
color: '#F5F5F5',
fontWeight: '500',
},
scrollView: {
flex: 1,
backgroundColor: '#090A0B',
},
scrollContent: {
paddingBottom: 20,
},
dateText: {
color: '#F5F5F5',
fontSize: 12,
fontWeight: '500',
marginBottom: 8,
marginTop: 16,
paddingLeft: 14,
},
galleryGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: 8,
},
galleryItem: {
width: GALLERY_ITEM_SIZE,
// 使用等比例 1:1保证容器永远是正方形
aspectRatio: 1,
overflow: 'hidden',
backgroundColor: '#1C1E22',
position: 'relative',
},
galleryItemMarginRight: {
marginRight: GALLERY_GAP,
},
galleryItemMarginBottom: {
marginBottom: GALLERY_GAP,
},
galleryImage: {
width: '100%',
// 高度由 aspectRatio 决定,避免拉伸
height: undefined,
aspectRatio: 1,
},
durationBadge: {
position: 'absolute',
right: 2,
bottom: 4,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 4,
backgroundColor: '#00000080',
},
durationText: {
color: '#F5F5F5',
fontSize: 10,
fontWeight: '500',
},
})
export type { Category, WorkItem }

View File

@@ -0,0 +1,142 @@
import React, { useState } from "react";
import { View, TextInput, ActivityIndicator } from "react-native";
import { useTranslation } from "react-i18next";
import { Button } from "../ui/button";
import Text from "../ui/Text";
import { authClient, useSession } from "../../lib/auth";
import { Block } from "../ui";
type AuthMode = "login" | "register";
type AuthResponse = { data: unknown; error: { message: string } | null };
type SignInFn = (params: { username: string; password: string }) => Promise<AuthResponse>;
type SignUpFn = (params: { email: string; password: string; name: string }) => Promise<AuthResponse>;
const signIn = authClient.signIn as unknown as { username: SignInFn };
const signUp = authClient.signUp as unknown as { email: SignUpFn };
interface AuthFormProps {
mode?: AuthMode;
onSuccess?: () => void;
onModeChange?: (mode: AuthMode) => void;
}
export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormProps) {
const { t } = useTranslation();
const [currentMode, setCurrentMode] = useState<AuthMode>(mode);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const isLogin = currentMode === "login";
const handleSubmit = async () => {
if (!username.trim() || !password.trim()) {
setError(t("authForm.fillCompleteInfo"));
return;
}
if (!isLogin && !email.trim()) {
setError(t("authForm.fillEmail"));
return;
}
setLoading(true);
setError("");
try {
if (isLogin) {
const res = await signIn.username({ username, password });
if (res.error) throw new Error(res.error.message);
} else {
const res = await signUp.email({ email, password, name: username });
if (res.error) throw new Error(res.error.message);
}
onSuccess?.();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : (isLogin ? t("authForm.loginFailed") : t("authForm.registerFailed"));
setError(msg);
} finally {
setLoading(false);
}
};
const toggleMode = () => {
const newMode = isLogin ? "register" : "login";
setCurrentMode(newMode);
onModeChange?.(newMode);
setEmail("");
setError("");
};
return (
<Block className="w-full px-6">
<Text className="text-2xl font-bold text-center text-white mb-8">
{isLogin ? t("authForm.login") : t("authForm.register")}
</Text>
<TextInput
className="w-full h-12 px-4 mb-4 bg-white/10 rounded-lg"
style={{ color: '#ffffff' }}
placeholder={t("authForm.username")}
placeholderTextColor="#999"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
/>
{!isLogin && (
<TextInput
className="w-full h-12 px-4 mb-4 bg-white/10 rounded-lg"
style={{ color: '#ffffff' }}
placeholder={t("authForm.email")}
placeholderTextColor="#999"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
)}
<TextInput
className="w-full h-12 px-4 mb-4 bg-white/10 rounded-lg"
style={{ color: '#ffffff' }}
placeholder={t("authForm.password")}
placeholderTextColor="#999"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{error ? (
<Text className="text-red-500 text-sm mb-4 text-center">{error}</Text>
) : null}
<Button
variant="gradient"
className="w-full h-16 rounded-lg px-0"
onPress={handleSubmit}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-medium">
{isLogin ? t("authForm.login") : t("authForm.register")}
</Text>
)}
</Button>
<Text
className="text-gray-400 text-center mt-6"
onClick={toggleMode}
>
{isLogin ? t("authForm.noAccountRegister") : t("authForm.haveAccountLogin")}
</Text>
</Block>
);
}
export { useSession };

View File

@@ -0,0 +1,76 @@
import { useState } from 'react';
import { Dimensions, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Carousel, CarouselContent, CarouselItem } from '../ui/carousel';
import { Media } from '../ui/media';
import { useResource } from '@/hooks/use-resource';
const { width: screenWidth } = Dimensions.get('window');
interface MediaCarouselProps {
sources: any[];
width?: number;
height?: number;
autoPlay?: boolean;
loop?: boolean;
}
export function MediaCarousel({
sources,
width = screenWidth,
height = 410,
autoPlay = false,
loop = true,
}: MediaCarouselProps) {
const [current, setCurrent] = useState(0);
return (
<View className="!relative !w-full" style={{ height }}>
<Carousel
className='relative w-full'
width={width}
height={height}
loop={loop}
autoPlay={autoPlay}
onIndexChange={setCurrent}
>
<CarouselContent>
{sources.map((_source, index) => {
const { source, poster } = useResource(_source, { width: screenWidth })
return (
<CarouselItem key={index} className='!w-full !h-full'>
<Media
source={source}
poster={poster}
visible={current === index}
loop={true}
className='!w-full !h-full'
/>
</CarouselItem>
)
})}
</CarouselContent>
</Carousel>
<LinearGradient
colors={['rgba(9, 10, 11, 0)', 'rgba(9, 10, 11, 1)']}
locations={[0.0964, 1]}
className="!absolute !left-0 !right-0 !h-[83px]"
style={{ top: 327 }}
pointerEvents="none"
/>
<View className="!absolute !flex-row !gap-1" style={{ top: 366, left: 16 }} pointerEvents="none">
{sources.map((_, index) => (
<View
key={index}
className={`!h-1 !rounded-sm ${current === index
? '!w-[10px] !bg-white'
: '!w-1 !bg-white/50'
}`}
/>
))}
</View>
</View>
);
}

View File

@@ -0,0 +1,2 @@
export { MediaCarousel } from './MediaCarousel';
export { AuthForm, useSession } from './AuthForm';

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

View File

@@ -0,0 +1,14 @@
import Svg, { Path } from 'react-native-svg';
export const AvatarUploadIcon = ({ className }: { className?: string }) => (
<Svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<Path
d="M11.9601 3.63998C11.6423 3.3222 11.2667 3.17776 10.8334 3.17776H9.44673L9.12895 2.33998C9.04228 2.13776 8.89784 1.96442 8.69561 1.81998C8.49339 1.67553 8.26228 1.58887 8.06006 1.58887H4.88228C4.65117 1.58887 4.44895 1.67553 4.24673 1.81998C4.0445 1.96442 3.90006 2.13776 3.81339 2.33998L3.46673 3.17776H2.05117C1.61784 3.17776 1.24228 3.3222 0.924503 3.63998C0.606725 3.95776 0.46228 4.33331 0.46228 4.76665V10.3422C0.46228 10.7755 0.606725 11.1511 0.924503 11.4689C1.24228 11.7866 1.61784 11.9311 2.05117 11.9311H10.8334C11.2667 11.9311 11.6423 11.7866 11.9601 11.4689C12.2778 11.1511 12.4223 10.7755 12.4223 10.3422V4.76665C12.4223 4.33331 12.2778 3.95776 11.9601 3.63998ZM6.44228 10.0822C4.99784 10.0822 3.81339 8.89776 3.81339 7.45331C3.81339 6.00887 4.99784 4.82442 6.44228 4.82442C7.88672 4.82442 9.07117 6.00887 9.07117 7.45331C9.07117 8.89776 7.88672 10.0822 6.44228 10.0822Z"
fill="white"
/>
<Path
d="M6.44215 5.71997C5.45993 5.71997 4.67993 6.49997 4.67993 7.48219C4.67993 8.46442 5.45993 9.24442 6.44215 9.24442C7.42438 9.24442 8.20438 8.46442 8.20438 7.48219C8.20438 6.49997 7.42438 5.71997 6.44215 5.71997Z"
fill="white"
/>
</Svg>
);

View File

@@ -0,0 +1,10 @@
import Svg, { Path } from 'react-native-svg';
export const ChangeIcon = ({ className }: { className?: string }) => (
<Svg width="18" height="18" viewBox="0 0 18 18" fill="none" className={className}>
<Path
d="M4.63613 7.83113L5.77238 9.25538C5.86174 9.36757 5.90287 9.51066 5.88673 9.65317C5.87059 9.79569 5.7985 9.92596 5.68631 10.0153C5.57413 10.1047 5.43104 10.1458 5.28852 10.1297C5.146 10.1135 5.01574 10.0414 4.92638 9.92926L4.51688 9.41513L4.51013 9.64913C4.51008 10.6911 4.90962 11.6934 5.62645 12.4495C6.34327 13.2057 7.3228 13.6582 8.36325 13.7138L8.58038 13.7194C9.48038 13.7194 10.3129 13.4269 10.9879 12.9296L11.1859 12.7755L11.196 12.7868L11.2478 12.7519C11.313 12.714 11.3854 12.6903 11.4604 12.6821C11.5354 12.674 11.6113 12.6817 11.6831 12.7046L11.7866 12.7519C11.8701 12.8004 11.939 12.8703 11.9862 12.9545C12.0335 13.0386 12.0573 13.1339 12.0552 13.2304C12.0531 13.3268 12.0253 13.421 11.9745 13.503C11.9237 13.5851 11.8518 13.652 11.7664 13.6969C11.0231 14.2821 10.133 14.6514 9.19372 14.764C8.25444 14.8767 7.30226 14.7285 6.44165 14.3357C5.58104 13.9429 4.84525 13.3206 4.31501 12.5372C3.78477 11.7537 3.48056 10.8393 3.43575 9.89438L3.43013 9.65138L3.4335 9.48263L3.12638 9.73126C3.0709 9.77543 3.00727 9.80824 2.93911 9.82783C2.87095 9.84741 2.79961 9.85337 2.72915 9.84538C2.65868 9.83739 2.59049 9.8156 2.52845 9.78125C2.4664 9.7469 2.41174 9.70067 2.36756 9.64519C2.32339 9.58972 2.29058 9.52609 2.27099 9.45793C2.25141 9.38977 2.24545 9.31843 2.25344 9.24796C2.26958 9.10566 2.34159 8.97559 2.45363 8.88638L3.87675 7.74563C3.93221 7.70134 3.99585 7.66842 4.06404 7.64874C4.13223 7.62906 4.20364 7.62302 4.27416 7.63096C4.34469 7.63891 4.41296 7.66067 4.47507 7.69502C4.53718 7.72936 4.59191 7.77562 4.63613 7.83113ZM8.58038 4.50001C9.89261 4.50022 11.1553 5.00131 12.1106 5.90098C13.0658 6.80064 13.6417 8.03102 13.7205 9.34088L14.0524 9.02813C14.1042 8.97975 14.1651 8.94205 14.2315 8.91719C14.298 8.89234 14.3686 8.88081 14.4395 8.88326C14.5104 8.88572 14.5801 8.90211 14.6446 8.9315C14.7092 8.9609 14.7673 9.00271 14.8157 9.05457C14.8641 9.10642 14.9018 9.1673 14.9266 9.23373C14.9515 9.30015 14.963 9.37082 14.9606 9.4417C14.9581 9.51258 14.9417 9.58228 14.9123 9.64683C14.8829 9.71137 14.8411 9.7695 14.7893 9.81788L13.4584 11.0599C13.3647 11.1478 13.2425 11.1993 13.1141 11.205H13.077C12.9976 11.2053 12.919 11.188 12.847 11.1544C12.7751 11.1207 12.7114 11.0716 12.6608 11.0104L11.4345 9.69301C11.3368 9.58828 11.2847 9.44902 11.2896 9.30587C11.2946 9.16273 11.3562 9.02741 11.4609 8.92969C11.5657 8.83198 11.7049 8.77987 11.8481 8.78483C11.9912 8.78978 12.1265 8.8514 12.2243 8.95613L12.6428 9.40388C12.5996 8.6852 12.3665 7.99083 11.9674 7.39162C11.5683 6.7924 11.0173 6.30977 10.3708 5.99297C9.72429 5.67616 9.00529 5.53652 8.28718 5.58829C7.56907 5.64005 6.87753 5.88137 6.28313 6.28763L6.10088 6.42376C6.02782 6.50479 5.93184 6.56168 5.82569 6.58688C5.71954 6.61208 5.60823 6.6044 5.50655 6.56485C5.40487 6.5253 5.31762 6.45575 5.25639 6.36545C5.19517 6.27515 5.16286 6.16836 5.16375 6.05926C5.16375 5.87588 5.256 5.71276 5.39775 5.61601L5.391 5.60588C6.29889 4.88794 7.42292 4.49819 8.58038 4.50001Z"
fill="#F5F5F5"
/>
</Svg>
);

14
components/icon/check.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Svg, { Path, G, Defs, ClipPath,Rect } from 'react-native-svg'
export const CheckIcon = ({ className }: { className?: string }) => (
<Svg width="12" height="12" className={className} viewBox="0 0 12 12" fill="none">
<G clipPath="url(#clip0_3_4111)">
<Path d="M6 0C2.6926 0 0 2.69115 0 6C0 9.30884 2.69115 12 6 12C9.30739 12 12 9.30884 12 6C12 2.69115 9.30884 0 6 0ZM9.14645 4.70082L5.50701 8.34026C5.42436 8.42291 5.31561 8.46496 5.20686 8.46496H5.16916C5.06042 8.46496 4.95167 8.42291 4.86902 8.34026L2.85355 6.32479C2.68826 6.1595 2.68826 5.8898 2.85355 5.7245C3.01885 5.55921 3.28855 5.55921 3.45384 5.7245L5.18801 7.45723L8.54616 4.09908C8.71145 3.93378 8.98115 3.93378 9.14645 4.09908C9.31319 4.26583 9.31319 4.53407 9.14645 4.70082Z" fill="#CCCCCC"/>
</G>
<Defs>
<ClipPath id="clip0_3_4111">
<Rect width="12" height="12" fill="white"/>
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,8 @@
import Svg, { Path } from 'react-native-svg'
export const CheckMarkIcon = ({ className }: { className?: string }) => (
<Svg width="12" height="12" className={className} viewBox="0 0 12 12" fill="none">
<Path d="M2.32549 5.56722C2.4512 5.56722 2.55806 5.61437 2.64606 5.70865L5.1232 8.36065L10.4049 2.68208C10.4929 2.58722 10.5998 2.53979 10.7255 2.53979C10.8512 2.53979 10.9581 2.58694 11.0461 2.68122C11.1323 2.77379 11.1755 2.88522 11.1755 3.01551C11.1755 3.14522 11.1326 3.25608 11.0469 3.34808L5.6572 9.14237C5.51035 9.30065 5.33263 9.37979 5.12406 9.3798C4.91549 9.3798 4.73777 9.30122 4.59092 9.14408L2.00577 6.37722C1.91892 6.28465 1.87549 6.17322 1.87549 6.04294C1.87549 5.91322 1.91892 5.8018 2.00577 5.70865C2.09377 5.61494 2.20035 5.56808 2.32549 5.56808V5.56722Z" fill="#CCCCCC"/>
<Path d="M1.7041 6.04274C1.7041 6.21759 1.76267 6.36788 1.87982 6.49359L4.46582 9.26131C4.64639 9.45445 4.86582 9.55102 5.1241 9.55102C5.38239 9.55102 5.60182 9.45388 5.78239 9.25959L11.1721 3.46531C11.2887 3.33959 11.347 3.18959 11.347 3.01531C11.347 2.83988 11.2884 2.68931 11.1712 2.56359C11.0495 2.43331 10.901 2.36816 10.7255 2.36816C10.5501 2.36816 10.4015 2.43388 10.2798 2.56531L5.12239 8.10845L2.77124 5.59102C2.64953 5.46074 2.50096 5.39559 2.32553 5.39559C2.15067 5.39559 2.0021 5.46074 1.87982 5.59102C1.76267 5.71674 1.7041 5.86702 1.7041 6.04188V6.04274ZM5.12324 8.36045L5.23982 8.23445L10.405 2.68188C10.493 2.58702 10.5998 2.53959 10.7255 2.53959C10.8512 2.53959 10.9581 2.58674 11.0461 2.68102C11.1324 2.77359 11.1755 2.88502 11.1755 3.01531C11.1755 3.14502 11.1327 3.25588 11.047 3.34788L5.65724 9.14216C5.51039 9.30045 5.33267 9.37959 5.1241 9.37959C4.91553 9.37959 4.73782 9.30102 4.59096 9.14388L2.00582 6.37702C1.91896 6.28445 1.87553 6.17302 1.87553 6.04274C1.87553 5.91302 1.91896 5.80159 2.00582 5.70845C2.09382 5.61474 2.20039 5.56788 2.32553 5.56788C2.45124 5.56788 2.5581 5.61502 2.6461 5.70931L5.00582 8.23445L5.12324 8.36045Z" fill="#CCCCCC"/>
</Svg>
);

18
components/icon/close.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Svg, { Path, Rect, G, Defs, ClipPath } from 'react-native-svg';
export const CloseIcon = ({ className }: { className?: string }) => (
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<Rect width="24" height="24" rx="12" fill="#262626" />
<G clipPath="url(#clip0_3512_209)">
<Path
d="M8.81829 7.96875L8.84579 7.994L12.2433 11.3918L15.5158 8.11925C15.6071 8.02702 15.7308 7.97393 15.8605 7.9712C15.9903 7.96848 16.1161 8.01633 16.2112 8.10465C16.3064 8.19297 16.3634 8.31483 16.3703 8.44445C16.3773 8.57408 16.3335 8.70132 16.2483 8.79926L16.2233 8.82676L12.9503 12.0988L16.223 15.3713C16.3154 15.4626 16.3686 15.5862 16.3714 15.716C16.3742 15.8458 16.3263 15.9716 16.238 16.0668C16.1497 16.162 16.0278 16.2191 15.8982 16.2261C15.7685 16.233 15.6412 16.1892 15.5433 16.104L15.5158 16.0785L12.2433 12.806L8.84579 16.204C8.75431 16.2955 8.63092 16.348 8.50155 16.3504C8.37219 16.3528 8.24692 16.305 8.15208 16.217C8.05724 16.129 8.0002 16.0076 7.99297 15.8785C7.98574 15.7493 8.02886 15.6223 8.11329 15.5243L8.13854 15.4968L11.5363 12.0988L8.13879 8.70126C8.04793 8.60966 7.99599 8.48649 7.99382 8.3575C7.99166 8.2285 8.03945 8.10366 8.12719 8.00908C8.21493 7.91449 8.33584 7.85749 8.46463 7.84997C8.59343 7.84246 8.72014 7.88502 8.81829 7.96875Z"
fill="white"
/>
</G>
<Defs>
<ClipPath id="clip0_3512_209">
<Rect x="6" y="6" width="12" height="12" rx="6" fill="white" />
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,8 @@
import Svg, { Path } from 'react-native-svg';
export const Close1Icon = ({ className }: { className?: string }) => (
<Svg width="14" height="14" viewBox="0 0 14 14" fill="none" className={className}>
<Path d="M3.38311 2.66192C3.31119 2.58997 3.21443 2.54832 3.11274 2.54553C3.01105 2.54274 2.91215 2.57902 2.83639 2.64692C2.76063 2.71481 2.71377 2.80916 2.70544 2.91055C2.69711 3.01194 2.72795 3.11266 2.79161 3.19201L2.82057 3.22447L10.4569 10.8608C10.5289 10.9328 10.6256 10.9744 10.7273 10.9772C10.829 10.98 10.9279 10.9437 11.0037 10.8758C11.0794 10.8079 11.1263 10.7136 11.1346 10.6122C11.1429 10.5108 11.1121 10.4101 11.0484 10.3307L11.0195 10.2983L3.38311 2.66192Z" fill="#F5F5F5"/>
<Path d="M10.4569 2.66192C10.5289 2.58997 10.6256 2.54832 10.7273 2.54553C10.829 2.54274 10.9279 2.57902 11.0037 2.64692C11.0794 2.71481 11.1263 2.80916 11.1346 2.91055C11.1429 3.01194 11.1121 3.11266 11.0484 3.19201L11.0195 3.22447L3.38311 10.8608C3.31119 10.9328 3.21443 10.9744 3.11274 10.9772C3.01105 10.98 2.91215 10.9437 2.83639 10.8758C2.76063 10.8079 2.71377 10.7136 2.70544 10.6122C2.69711 10.5108 2.72795 10.4101 2.79161 10.3307L2.82057 10.2983L10.4569 2.66192Z" fill="#F5F5F5"/>
</Svg>
);

View File

@@ -0,0 +1,16 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const DeleteIcon = () => (
<Svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<G clipPath="url(#clip0_5062_1547)">
<Path d="M1.06521 1.8252H11.775C11.9364 1.82522 12.0911 1.88934 12.2052 2.00346C12.3193 2.11758 12.3834 2.27234 12.3834 2.43371C12.3834 2.59508 12.3193 2.74985 12.2052 2.86396C12.0911 2.97808 11.9364 3.0422 11.775 3.04222H1.06521C0.903838 3.0422 0.749084 2.97808 0.634985 2.86396C0.520886 2.74985 0.456787 2.59508 0.456787 2.43371C0.456787 2.27234 0.520886 2.11758 0.634985 2.00346C0.749084 1.88934 0.903838 1.82522 1.06521 1.8252ZM5.36904 0.121094H7.47117C7.63255 0.121094 7.78733 0.185205 7.90145 0.299322C8.01557 0.41344 8.07968 0.568217 8.07968 0.729604C8.07968 0.890991 8.01557 1.04577 7.90145 1.15989C7.78733 1.274 7.63255 1.33811 7.47117 1.33811H5.36904C5.28913 1.33812 5.21 1.32238 5.13617 1.29179C5.06234 1.26121 4.99526 1.21639 4.93876 1.15989C4.88225 1.10338 4.83743 1.0363 4.80685 0.962471C4.77627 0.888643 4.76053 0.809515 4.76053 0.729604C4.76053 0.649694 4.77627 0.570565 4.80685 0.496737C4.83743 0.42291 4.88225 0.355828 4.93876 0.299322C4.99526 0.242817 5.06234 0.197994 5.13617 0.167414C5.21 0.136833 5.28913 0.121094 5.36904 0.121094ZM9.69832 4.25897C9.69832 4.07557 9.77117 3.89969 9.90085 3.77001C10.0305 3.64033 10.2064 3.56748 10.3898 3.56748C10.5732 3.56748 10.7491 3.64033 10.8788 3.77001C11.0084 3.89969 11.0813 4.07557 11.0813 4.25897V11.0743C11.0813 11.5805 10.8802 12.0659 10.5223 12.4238C10.1644 12.7817 9.67895 12.9828 9.17278 12.9828H3.57449C3.06832 12.9828 2.58288 12.7817 2.22497 12.4238C1.86705 12.0659 1.66598 11.5805 1.66598 11.0743V4.25924C1.66598 4.07585 1.73883 3.89997 1.86851 3.77029C1.99819 3.64061 2.17407 3.56775 2.35747 3.56775C2.54086 3.56775 2.71674 3.64061 2.84642 3.77029C2.9761 3.89997 3.04895 4.07585 3.04895 4.25924V11.0746C3.04895 11.365 3.28406 11.6001 3.57449 11.6001H9.17278C9.31216 11.6001 9.44583 11.5447 9.54439 11.4462C9.64295 11.3476 9.69832 11.2139 9.69832 11.0746V4.25924V4.25897Z" fill="#F5F5F5" />
<Path d="M5 6V10" stroke="#F5F5F5" strokeLinecap="round" />
<Path d="M8 6V10" stroke="#F5F5F5" strokeLinecap="round" />
</G>
<Defs>
<ClipPath id="clip0_5062_1547">
<Rect width="13" height="13" fill="white" />
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,24 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const DownArrowIcon = () => (
<Svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<G clipPath="url(#clip0_2_1955)">
<Path
d="M4.50684 7.5293C4.64648 7.66895 4.83594 7.79785 5.01465 7.75879C5.19434 7.78906 5.36328 7.64941 5.50293 7.5293L9.81641 3.19629C10.0352 2.97754 10.0352 2.61816 9.81641 2.39941C9.59766 2.18066 9.23828 2.18066 9.01953 2.39941L5.00488 6.52344L0.980469 2.39941C0.761719 2.18066 0.402344 2.18066 0.183594 2.39941C-0.0351562 2.61816 -0.0351562 2.97754 0.183594 3.19629L4.50684 7.5293Z"
fill="#F5F5F5"
stroke="#F5F5F5"
strokeWidth="0.4"
/>
</G>
<Defs>
<ClipPath id="clip0_2_1955">
<Rect
width="10"
height="10"
fill="white"
transform="matrix(-1 0 0 1 10 0)"
/>
</ClipPath>
</Defs>
</Svg>
);

9
components/icon/edit.tsx Normal file
View File

@@ -0,0 +1,9 @@
import Svg, { Path } from 'react-native-svg';
export const EditIcon = () => (
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<Path d="M5.53035 15.8984C5.3966 15.8955 5.2687 15.843 5.17142 15.7512C5.02784 15.6039 4.9568 15.4566 5.02784 15.3093C5.53035 12.0693 5.81675 11.7748 5.88854 11.7015L12.4144 4.48442C12.8444 4.0426 13.5615 3.96933 13.9922 4.33715L15.6411 5.88388C15.8565 6.10442 16 6.3257 16 6.62024C16 6.91479 15.9282 7.2826 15.7129 7.50388L9.11596 14.7202C9.04417 14.7942 8.82881 15.0888 5.60139 15.8984H5.53035ZM13.2751 4.85297C13.1414 4.85593 13.0135 4.9084 12.9162 5.00024L6.31851 12.1433C6.24747 12.2906 5.96032 13.2475 5.67318 15.0888C7.46635 14.647 8.39883 14.2784 8.61345 14.2052L15.2111 7.06206C15.2829 6.98805 15.3547 6.84078 15.3547 6.76752C15.3547 6.69351 15.3547 6.6195 15.2111 6.54624L13.5615 4.9995C13.4897 4.85297 13.4179 4.85297 13.2751 4.85297Z" fill="#F5F5F5" stroke="#F5F5F5" strokeWidth="0.5" />
<Path d="M14.5 12L15.1752 13.8248L17 14.5L15.1752 15.1752L14.5 17L13.8248 15.1752L12 14.5L13.8248 13.8248L14.5 12Z" fill="#F5F5F5" />
<Path d="M6.50001 3L7.17524 4.82478L9.00001 5.5L7.17524 6.17523L6.50001 8.00001L5.82478 6.17523L4 5.5L5.82478 4.82478L6.50001 3Z" fill="#F5F5F5" />
</Svg>
);

10
components/icon/home.tsx Normal file
View File

@@ -0,0 +1,10 @@
import Svg, { Path } from 'react-native-svg';
export const HomeIcon = ({ className }: { className?: string }) => (
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<Path
d="M16.9717 7.85439L10.9446 1.94699C10.1097 1.12884 8.63951 1.12884 7.80462 1.94699L1.77793 7.85439C1.43791 8.18789 1.25 8.61816 1.25 9.06363V16.8441C1.25 17.851 2.18915 18.6667 3.34812 18.6667H6.69234C7.33388 18.6667 7.85364 18.2148 7.85364 17.6574V14.8591C7.85364 14.2285 8.3415 13.6522 9.0519 13.5231C10.028 13.3455 10.8964 13.9969 10.8964 14.8155V17.6574C10.8964 18.2148 11.4165 18.6667 12.0577 18.6667H15.4019C16.5605 18.6667 17.5 17.8506 17.5 16.8441V9.06363C17.4996 8.61816 17.3121 8.18789 16.9717 7.85439Z"
fill="white"
/>
</Svg>
);

28
components/icon/index.tsx Normal file
View File

@@ -0,0 +1,28 @@
export { CloseIcon } from "./close";
export { PointsIcon } from "./points";
export { OmitIcon } from "./omit";
export { WhiteStarIcon } from "./whiteStar";
export { LeftArrowIcon } from "./leftArrow";
export { HomeIcon } from "./home";
export { VideoIcon } from "./video";
export { MessageIcon } from "./message";
export { MyIcon } from "./my";
export { SearchIcon } from "./search";
export { DownArrowIcon } from "./downArrow";
export { TopArrowIcon } from "./topArrow";
export { SettingsIcon } from "./settings";
export { SameStyleIcon } from "./sameStyle";
export { NoNewsIcon } from "./noNews";
export { WhitePointsIcon } from "./whitePoints";
export { EditIcon } from "./edit";
export { DeleteIcon } from "./delete";
export { AvatarUploadIcon } from "./avatarUpload";
export { ChangeIcon } from "./change";
export { PlusIcon } from "./plus";
export { UploadIcon } from "./upload";
export { Close1Icon } from "./close1";
export { CheckIcon } from "./check";
export { UncheckedIcon } from "./unchecked";
export { RightArrowIcon } from "./rightArrow";
export { TermsIcon } from "./terms";
export { PrivacyIcon } from "./privacy";

View File

@@ -0,0 +1,7 @@
import Svg, { Path } from 'react-native-svg';
export const LeftArrowIcon = ({className}: {className?: string}) => (
<Svg className={className} width="22" height="22" viewBox="0 0 22 22" fill="none">
<Path d="M6.79546 11L13.0087 17.2133C13.3675 17.5721 13.3675 18.1521 13.0087 18.5088C12.65 18.8676 12.0699 18.8676 11.7132 18.5088L4.85112 11.6488C4.49233 11.29 4.49233 10.71 4.85112 10.3533L11.7132 3.49121C12.072 3.13242 12.6521 3.13242 13.0087 3.49121C13.3675 3.85 13.3675 4.43008 13.0087 4.78672L6.79546 11Z" fill="#F5F5F5" />
</Svg>
);

View File

@@ -0,0 +1,7 @@
import Svg, { Path } from 'react-native-svg';
export const MessageIcon = () => (
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<Path d="M9.33297 1.42321C9.33297 0.639286 8.73622 0 8 0C7.26541 0 6.66703 0.6375 6.66703 1.42321C6.66703 1.50357 6.67351 1.58214 6.68486 1.65893C4.84432 2.34286 3.33297 4.44643 3.33297 6.93393V8.89107C3.33297 8.89107 3.33297 11.7107 2.68432 11.7357C2.29676 11.7357 2 12.0536 2 12.4464C2 12.8411 2.29838 13.1571 2.66649 13.1571H13.3335C13.7032 13.1571 14 12.8393 14 12.4464C14 12.05 13.7016 11.7357 13.3335 11.7357C12.667 11.7357 12.667 8.91072 12.667 8.91072V6.93214C12.667 4.44286 11.2546 2.33929 9.31514 1.65536C9.32649 1.58036 9.33297 1.50179 9.33297 1.42321ZM9.99946 13.8661C9.99784 15.0464 9.11081 16 8 16C6.8973 16 6.00216 15.0482 6.00054 13.8661" fill="#F5F5F5" />
</Svg>
);

15
components/icon/my.tsx Normal file
View File

@@ -0,0 +1,15 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const MyIcon = () => (
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<G clipPath="url(#clip0_5062_503)">
<Path d="M3.87988 5.11999C3.87988 5.66103 3.98645 6.19678 4.1935 6.69664C4.40055 7.1965 4.70402 7.65068 5.0866 8.03326C5.46917 8.41583 5.92336 8.71931 6.42322 8.92636C6.92308 9.13341 7.45883 9.23997 7.99987 9.23997C8.54091 9.23997 9.07666 9.13341 9.57652 8.92636C10.0764 8.71931 10.5306 8.41583 10.9131 8.03326C11.2957 7.65068 11.5992 7.1965 11.8062 6.69664C12.0133 6.19678 12.1199 5.66103 12.1199 5.11999C12.1199 4.0273 11.6858 2.97936 10.9131 2.20672C10.1405 1.43407 9.09256 1 7.99987 1C6.90718 1 5.85925 1.43407 5.0866 2.20672C4.31395 2.97936 3.87988 4.0273 3.87988 5.11999Z" fill="#F5F5F5" />
<Path d="M1 12.9593C1 13.7657 1.74578 14.5392 3.07327 15.1094C4.40076 15.6797 6.20122 16.0001 8.07858 16.0001C9.95593 16.0001 11.7564 15.6797 13.0839 15.1094C14.4114 14.5392 15.1572 13.7657 15.1572 12.9593C15.1572 12.5599 14.9741 12.1645 14.6183 11.7956C14.2626 11.4267 13.7412 11.0914 13.0839 10.8091C12.4266 10.5267 11.6462 10.3027 10.7874 10.1499C9.92862 9.99711 9.00815 9.91846 8.07858 9.91846C7.149 9.91846 6.22853 9.99711 5.36972 10.1499C4.51091 10.3027 3.73057 10.5267 3.07327 10.8091C2.41596 11.0914 1.89456 11.4267 1.53882 11.7956C1.18309 12.1645 1 12.5599 1 12.9593Z" fill="#F5F5F5" />
</G>
<Defs>
<ClipPath id="clip0_5062_503">
<Rect width="16" height="16" fill="white" />
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,7 @@
import Svg, { Path } from 'react-native-svg';
export const NoNewsIcon = () => (
<Svg width="56" height="56" viewBox="0 0 56 56" fill="none">
<Path d="M0 56.0001H56V7.77245e-05H0V56.0001Z" fill="white" />
</Svg>
);

9
components/icon/omit.tsx Normal file
View File

@@ -0,0 +1,9 @@
import Svg, { Path } from 'react-native-svg';
export const OmitIcon = () => (
<Svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<Path d="M4.25 12C4.25 12.2298 4.29527 12.4574 4.38321 12.6697C4.47116 12.882 4.60006 13.0749 4.76256 13.2374C4.92507 13.3999 5.11798 13.5288 5.3303 13.6168C5.54262 13.7047 5.77019 13.75 6 13.75C6.22981 13.75 6.45738 13.7047 6.6697 13.6168C6.88202 13.5288 7.07493 13.3999 7.23744 13.2374C7.39994 13.0749 7.52884 12.882 7.61679 12.6697C7.70474 12.4574 7.75 12.2298 7.75 12C7.75 11.7702 7.70474 11.5426 7.61679 11.3303C7.52884 11.118 7.39994 10.9251 7.23744 10.7626C7.07493 10.6001 6.88202 10.4712 6.6697 10.3832C6.45738 10.2953 6.22981 10.25 6 10.25C5.77019 10.25 5.54262 10.2953 5.3303 10.3832C5.11798 10.4712 4.92507 10.6001 4.76256 10.7626C4.60006 10.9251 4.47116 11.118 4.38321 11.3303C4.29527 11.5426 4.25 11.7702 4.25 12Z" fill="#F5F5F5" />
<Path d="M10.25 12C10.25 12.2298 10.2953 12.4574 10.3832 12.6697C10.4712 12.882 10.6001 13.0749 10.7626 13.2374C10.9251 13.3999 11.118 13.5288 11.3303 13.6168C11.5426 13.7047 11.7702 13.75 12 13.75C12.2298 13.75 12.4574 13.7047 12.6697 13.6168C12.882 13.5288 13.0749 13.3999 13.2374 13.2374C13.3999 13.0749 13.5288 12.882 13.6168 12.6697C13.7047 12.4574 13.75 12.2298 13.75 12C13.75 11.5359 13.5656 11.0908 13.2374 10.7626C12.9092 10.4344 12.4641 10.25 12 10.25C11.5359 10.25 11.0908 10.4344 10.7626 10.7626C10.4344 11.0908 10.25 11.5359 10.25 12Z" fill="#F5F5F5" />
<Path d="M16.25 12C16.25 12.4641 16.4344 12.9092 16.7626 13.2374C17.0908 13.5656 17.5359 13.75 18 13.75C18.4641 13.75 18.9092 13.5656 19.2374 13.2374C19.5656 12.9092 19.75 12.4641 19.75 12C19.75 11.5359 19.5656 11.0908 19.2374 10.7626C18.9092 10.4344 18.4641 10.25 18 10.25C17.5359 10.25 17.0908 10.4344 16.7626 10.7626C16.4344 11.0908 16.25 11.5359 16.25 12Z" fill="#F5F5F5" />
</Svg>
);

14
components/icon/plus.tsx Normal file
View File

@@ -0,0 +1,14 @@
import Svg, { Path } from 'react-native-svg';
export const PlusIcon = () => (
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<Path
d="M8 3V13M3 8H13"
stroke="#FFFFFF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);

View File

@@ -0,0 +1,29 @@
import Svg, { Path, Defs, LinearGradient, Stop } from 'react-native-svg';
interface PointsIconProps {
width?: number | string;
height?: number | string;
style?: any;
}
export const PointsIcon = ({width = 14, height = 14, style}: PointsIconProps) => (
<Svg width={width} height={height} viewBox="0 0 14 14" fill="none" style={style} preserveAspectRatio="xMidYMid meet">
<Defs>
<LinearGradient
id="paint0_linear_2_1946"
x1="7.00033"
y1="1.73615"
x2="7.00033"
y2="12.2639"
gradientUnits="userSpaceOnUse"
>
<Stop offset="0" stopColor="#FFC738" />
<Stop offset="1" stopColor="#FFA92D" />
</LinearGradient>
</Defs>
<Path
d="M10.297 6.5548H8.38602C8.21382 6.5548 8.08222 6.3994 8.11022 6.23L8.79062 2.0622C8.83542 1.7892 8.49802 1.624 8.30902 1.8256L3.49862 6.9734C3.33202 7.1526 3.45802 7.4452 3.70302 7.4452H5.61402C5.78622 7.4452 5.91782 7.6006 5.88982 7.77L5.20942 11.9378C5.16462 12.2108 5.50202 12.376 5.69102 12.1744L10.5014 7.0266C10.6694 6.8474 10.542 6.5548 10.297 6.5548Z"
fill="url(#paint0_linear_2_1946)"
/>
</Svg>
);

View File

@@ -0,0 +1,27 @@
import Svg, { Path } from 'react-native-svg';
export const PrivacyIcon = () => (
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<Path
d="M4.16667 5.83333H15.8333C16.2754 5.83333 16.6993 6.00893 17.0119 6.32149C17.3244 6.63405 17.5 7.05797 17.5 7.5V15.8333C17.5 16.2754 17.3244 16.6993 17.0119 17.0119C16.6993 17.3244 16.2754 17.5 15.8333 17.5H4.16667C3.72464 17.5 3.30071 17.3244 2.98815 17.0119C2.67559 16.6993 2.5 16.2754 2.5 15.8333V7.5C2.5 7.05797 2.67559 6.63405 2.98815 6.32149C3.30071 6.00893 3.72464 5.83333 4.16667 5.83333Z"
stroke="#F5F5F5"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M4.16667 5.83333L10 2.5L15.8333 5.83333"
stroke="#F5F5F5"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M6.66667 10H13.3333"
stroke="#F5F5F5"
strokeWidth="1.2"
strokeLinecap="round"
/>
</Svg>
);

View File

@@ -0,0 +1,7 @@
import Svg, { Path } from 'react-native-svg';
export const RightArrowIcon = () => (
<Svg width="6" height="10" viewBox="0 0 6 10" fill="none">
<Path d="M4.47276 5L0.18567 8.9942C-0.0618901 9.22485 -0.0618901 9.59775 0.18567 9.82701C0.43323 10.0577 0.833477 10.0577 1.07956 9.82701L5.81433 5.4171C6.06189 5.18645 6.06189 4.81355 5.81433 4.58428L1.07956 0.172985C0.831995 -0.0576618 0.431748 -0.0576618 0.18567 0.172985C-0.0618901 0.403632 -0.0618901 0.776535 0.18567 1.0058L4.47276 5Z" fill="#F5F5F5"/>
</Svg>
);

View File

@@ -0,0 +1,9 @@
import Svg, { Path } from 'react-native-svg';
export const SameStyleIcon = () => (
<Svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<Path d="M27.7932 4.93529L23.4285 4L26.38 21.2176C26.6919 23.037 26.4942 24.9072 25.8086 26.6211L24.5713 29.7143L26.2706 28.2578C27.753 26.9871 28.7196 25.2182 28.9882 23.2843L30.917 9.39677C31.2053 7.32089 29.8424 5.37442 27.7932 4.93529Z" fill="#E6E6E6" />
<Path d="M16.0806 2.99778L5.09443 4.99525C2.91464 5.39158 1.47222 7.48421 1.87745 9.66237L4.98561 26.3687C5.38851 28.5343 7.46644 29.9666 9.63366 29.5725L20.6198 27.5751C22.7996 27.1787 24.242 25.0861 23.8368 22.9079L20.7286 6.20163C20.3257 4.03603 18.2478 2.60374 16.0806 2.99778Z" fill="#E6E6E6" />
<Path d="M10.5898 19.1379L9.94426 14.2966C9.8423 13.5319 10.608 12.9446 11.3201 13.2413L15.1931 14.8551C15.8502 15.1289 16.0189 15.982 15.5156 16.4853L12.2881 19.7128C11.7024 20.2985 10.6992 19.9589 10.5898 19.1379Z" fill="#070708" />
</Svg>
);

View File

@@ -0,0 +1,14 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const SearchIcon = () => (
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<G clipPath="url(#clip0_5062_371)">
<Path d="M15.9382 14.8633L18.7753 17.7009C18.8465 17.7712 18.903 17.855 18.9416 17.9473C18.9801 18.0396 19 18.1387 19 18.2388C19 18.3388 18.9801 18.4379 18.9416 18.5302C18.903 18.6226 18.8465 18.7063 18.7753 18.7766C18.6324 18.919 18.4391 18.9993 18.2374 19C18.0357 18.9993 17.8424 18.919 17.6996 18.7766L14.8634 15.94C13.3544 17.1819 11.4371 17.8174 9.4851 17.7228C7.53311 17.6281 5.68627 16.8101 4.30449 15.4281C3.20462 14.3281 2.4556 12.9266 2.15212 11.4009C1.84865 9.87519 2.00435 8.29377 2.59953 6.85655C3.19472 5.41932 4.20267 4.19084 5.49596 3.3264C6.78924 2.46195 8.3098 2.00037 9.86538 2C14.2089 2 17.7298 5.52136 17.7298 9.86581C17.7298 11.7287 17.0724 13.484 15.9377 14.8638L15.9382 14.8633ZM9.86538 3.52292C6.36433 3.52858 3.52816 6.36522 3.52249 9.86628C3.52249 13.3692 6.36244 16.2096 9.86538 16.2096C13.3683 16.2096 16.2078 13.3692 16.2078 9.86628C16.2078 6.36286 13.3683 3.52292 9.86538 3.52292Z" fill="#F5F5F5" />
</G>
<Defs>
<ClipPath id="clip0_5062_371">
<Rect width="17" height="17" fill="white" x="2" y="2" />
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,14 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const SettingsIcon = () => (
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<G clipPath="url(#clip0_5062_1363)">
<Path d="M17.7297 16.1303L11.7738 19.577C11.3032 19.8495 10.6945 19.9995 10.0601 19.9995C9.45766 19.9995 8.87644 19.862 8.42333 19.6132L2.40114 16.3015C1.43931 15.7728 0.705602 14.5604 0.694978 13.4811L0.625606 6.72452C0.614982 5.64582 1.32182 4.41901 2.27115 3.86967L8.22647 0.422967C8.69769 0.150481 9.30641 0.000488281 9.94076 0.000488281C10.5432 0.000488281 11.1238 0.137356 11.5769 0.386719L17.5991 3.6978C18.5609 4.22715 19.2947 5.43959 19.3053 6.51891L19.3747 13.2754C19.3859 14.3535 18.6784 15.5809 17.7297 16.1303ZM17.3398 6.53765C17.3366 6.1433 16.9873 5.56708 16.636 5.37396L10.6138 2.06226C10.4545 1.97476 10.2032 1.92289 9.94076 1.92289C9.66577 1.92289 9.39266 1.98164 9.22767 2.07726L3.27172 5.52396C2.92424 5.7252 2.58676 6.31017 2.59113 6.70515L2.6605 13.4617C2.66425 13.856 3.01361 14.4323 3.36484 14.626L9.38703 17.9371C9.54578 18.0246 9.79764 18.0771 10.0601 18.0771C10.3351 18.0771 10.6088 18.0177 10.7732 17.9227L16.7292 14.4754C17.076 14.2741 17.4135 13.6892 17.4098 13.2942L17.3398 6.53765ZM10.0001 14.141C7.66525 14.141 5.76597 12.2836 5.76597 9.99998C5.76597 7.71634 7.66525 5.85831 10.0001 5.85831C12.335 5.85831 14.2349 7.71634 14.2349 9.99998C14.2349 12.2836 12.335 14.141 10.0001 14.141ZM10.0001 7.78072C8.74957 7.78072 7.73149 8.77629 7.73149 9.99998C7.73149 11.223 8.74957 12.2186 10.0001 12.2186C11.2513 12.2186 12.2694 11.223 12.2694 9.99998C12.2694 8.77629 11.2513 7.78072 10.0001 7.78072Z" fill="#F5F5F5" />
</G>
<Defs>
<ClipPath id="clip0_5062_1363">
<Rect width="20" height="20" fill="white" />
</ClipPath>
</Defs>
</Svg>
);

26
components/icon/terms.tsx Normal file
View File

@@ -0,0 +1,26 @@
import Svg, { Path } from 'react-native-svg';
export const TermsIcon = () => (
<Svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<Path
d="M5.83333 2.5H14.1667C14.6087 2.5 15.0326 2.67559 15.3452 2.98815C15.6577 3.30071 15.8333 3.72464 15.8333 4.16667V15.8333C15.8333 16.2754 15.6577 16.6993 15.3452 17.0119C15.0326 17.3244 14.6087 17.5 14.1667 17.5H5.83333C5.39131 17.5 4.96738 17.3244 4.65482 17.0119C4.34226 16.6993 4.16667 16.2754 4.16667 15.8333V4.16667C4.16667 3.72464 4.34226 3.30071 4.65482 2.98815C4.96738 2.67559 5.39131 2.5 5.83333 2.5Z"
stroke="#F5F5F5"
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<Path
d="M6.66667 5.83333H13.3333"
stroke="#F5F5F5"
strokeWidth="1.2"
strokeLinecap="round"
/>
<Path
d="M10 5.83333V14.1667"
stroke="#F5F5F5"
strokeWidth="1.2"
strokeLinecap="round"
/>
</Svg>
);

View File

@@ -0,0 +1,24 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const TopArrowIcon = () => (
<Svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<G clipPath="url(#clip0_3_2634)">
<Path
d="M4.50684 2.4707C4.64648 2.33105 4.83594 2.20215 5.01465 2.24121C5.19434 2.21094 5.36328 2.35059 5.50293 2.4707L9.81641 6.80371C10.0352 7.02246 10.0352 7.38184 9.81641 7.60059C9.59766 7.81934 9.23828 7.81934 9.01953 7.60059L5.00488 3.47656L0.980469 7.60059C0.761719 7.81934 0.402344 7.81934 0.183594 7.60059C-0.0351562 7.38184 -0.0351562 7.02246 0.183594 6.80371L4.50684 2.4707Z"
fill="#F5F5F5"
stroke="#F5F5F5"
strokeWidth="0.5"
/>
</G>
<Defs>
<ClipPath id="clip0_3_2634">
<Rect
width="10"
height="10"
fill="white"
transform="matrix(-1 0 0 -1 10 10)"
/>
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,7 @@
import Svg, { Circle } from 'react-native-svg'
export const UncheckedIcon = ({ className }: { className?: string }) => (
<Svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<Circle cx="6" cy="6" r="5.5" stroke="#8A8A8A" strokeWidth="1" />
</Svg>
);

View File

@@ -0,0 +1,7 @@
import Svg, { Path } from 'react-native-svg';
export const UploadIcon = () => (
<Svg width="21" height="21" viewBox="0 0 21 21" fill="none">
<Path d="M10.9373 10.9615L14.2787 14.302L13.1644 15.4164L11.7248 13.9768V18.375H10.1498V13.9752L8.71028 15.4164L7.59597 14.302L10.9373 10.9615ZM10.9373 2.625C12.2895 2.62506 13.5944 3.122 14.6041 4.02133C15.6138 4.92066 16.2578 6.15967 16.4136 7.50278C17.3934 7.76998 18.2483 8.37304 18.8286 9.20657C19.4089 10.0401 19.6779 11.051 19.5885 12.0627C19.4991 13.0744 19.057 14.0225 18.3395 14.7413C17.622 15.4601 16.6746 15.9039 15.6631 15.9952V14.4092C16.0255 14.3574 16.374 14.234 16.6881 14.0461C17.0023 13.8582 17.2759 13.6097 17.4929 13.3149C17.71 13.0201 17.8661 12.6851 17.9523 12.3293C18.0384 11.9734 18.0528 11.6041 17.9946 11.2427C17.9365 10.8812 17.807 10.5351 17.6136 10.2243C17.4201 9.91349 17.1667 9.64434 16.8682 9.4325C16.5696 9.22075 16.2318 9.07051 15.8746 8.99071C15.5173 8.91082 15.1477 8.90295 14.7874 8.96753C14.9108 8.39335 14.9041 7.79885 14.7679 7.22759C14.6318 6.65632 14.3695 6.12275 14.0004 5.66596C13.6313 5.20916 13.1647 4.84072 12.6347 4.58761C12.1048 4.33449 11.525 4.20312 10.9378 4.20312C10.3505 4.20312 9.7706 4.33449 9.2407 4.58761C8.71076 4.84072 8.24415 5.20916 7.87506 5.66596C7.50597 6.12275 7.24372 6.65632 7.10755 7.22759C6.97137 7.79885 6.9647 8.39335 7.08803 8.96753C6.36956 8.8326 5.62692 8.98861 5.02348 9.40126C4.42004 9.81391 4.00523 10.4493 3.87031 11.1678C3.73539 11.8863 3.8914 12.6289 4.30403 13.2324C4.71666 13.8358 5.35211 14.2506 6.07059 14.3855L6.21234 14.4092V15.9952C5.20075 15.9041 4.25335 15.4604 3.53573 14.7417C2.8181 14.0228 2.37589 13.0748 2.28638 12.063C2.19689 11.0513 2.46579 10.0404 3.04609 9.20675C3.62639 8.37317 4.48117 7.77004 5.46106 7.50278C5.61675 6.1596 6.26069 4.92049 7.2704 4.02113C8.28011 3.12177 9.58519 2.6249 10.9373 2.625Z" fill="#CCCCCC" />
</Svg>
);

15
components/icon/video.tsx Normal file
View File

@@ -0,0 +1,15 @@
import Svg, { Path, G, Defs, ClipPath, Rect } from 'react-native-svg';
export const VideoIcon = () => (
<Svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<G clipPath="url(#clip0_5062_492)">
<Path d="M15.8854 4.81601C15.7094 4.32801 15.1574 3.76801 13.7254 3.60801C12.3254 1.92001 10.2294 0.984009 8.05338 0.984009C7.14938 0.984009 6.22938 1.14401 5.34138 1.48001C2.34938 2.59201 0.541379 5.40801 0.885379 8.39201C-0.122621 9.47201 -0.0906205 10.288 0.117379 10.784C0.357379 11.272 0.94938 11.896 2.51738 11.992C2.51738 11.992 5.74138 11.76 11.0134 9.74401C11.0134 9.74401 14.7334 7.96801 15.1494 7.14401C16.1254 6.09601 16.0934 5.31201 15.8854 4.81601ZM8.63738 5.03201C8.10938 5.03201 7.67738 4.60001 7.67738 4.05601C7.67738 3.51201 8.10938 3.08001 8.63738 3.08001C9.16538 3.08001 9.59738 3.51201 9.59738 4.05601C9.59738 4.60001 9.16538 5.03201 8.63738 5.03201Z" fill="#F5F5F5" />
<Path d="M2.82129 12.664C4.19729 14.248 6.14929 15 8.23729 15.016C8.66929 15.016 9.11729 14.984 9.55729 14.904C9.99729 14.824 10.4293 14.704 10.8533 14.552C13.8213 13.464 15.3653 11.024 15.1493 8.104C11.0213 11.904 2.82129 12.664 2.82129 12.664Z" fill="#F5F5F5" />
</G>
<Defs>
<ClipPath id="clip0_5062_492">
<Rect width="16" height="16" fill="white" />
</ClipPath>
</Defs>
</Svg>
);

View File

@@ -0,0 +1,7 @@
import Svg, { Path } from 'react-native-svg';
export const WhitePointsIcon = () => (
<Svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<Path d="M10.297 6.55474H8.38602C8.21382 6.55474 8.08222 6.39934 8.11022 6.22994L8.79062 2.06214C8.83542 1.78914 8.49802 1.62394 8.30902 1.82554L3.49862 6.97334C3.33202 7.15254 3.45802 7.44514 3.70302 7.44514H5.61402C5.78622 7.44514 5.91782 7.60054 5.88982 7.76994L5.20942 11.9377C5.16462 12.2107 5.50202 12.3759 5.69102 12.1743L10.5014 7.02654C10.6694 6.84734 10.542 6.55474 10.297 6.55474Z" fill="white" />
</Svg>
);

View File

@@ -0,0 +1,8 @@
import Svg, { Path } from 'react-native-svg';
export const WhiteStarIcon = () => (
<Svg width="11" height="10" viewBox="0 0 11 10" fill="none">
<Path d="M4.53107 1.26725C4.69215 0.831945 5.30785 0.831944 5.46893 1.26725L6.27067 3.43393C6.32131 3.57079 6.42921 3.67869 6.56607 3.72933L8.73275 4.53107C9.16806 4.69215 9.16806 5.30785 8.73275 5.46893L6.56607 6.27067C6.42921 6.32131 6.32131 6.42922 6.27067 6.56608L5.46893 8.73275C5.30785 9.16806 4.69215 9.16806 4.53107 8.73275L3.72933 6.56608C3.67869 6.42922 3.57079 6.32131 3.43393 6.27067L1.26725 5.46893C0.831944 5.30785 0.831944 4.69215 1.26725 4.53107L3.43393 3.72933C3.57078 3.67869 3.67869 3.57079 3.72933 3.43393L4.53107 1.26725Z" fill="#F5F5F5" />
<Path d="M8.9791 7.17356C9.04353 6.99943 9.28981 6.99943 9.35424 7.17356L9.5849 7.79692C9.60516 7.85167 9.64832 7.89483 9.70307 7.91509L10.3264 8.14575C10.5006 8.21018 10.5006 8.45646 10.3264 8.52089L9.70307 8.75156C9.64832 8.77182 9.60516 8.81498 9.5849 8.86972L9.35424 9.49309C9.28981 9.66721 9.04353 9.66721 8.9791 9.49309L8.74843 8.86972C8.72817 8.81498 8.68501 8.77182 8.63027 8.75156L8.0069 8.52089C7.83278 8.45646 7.83278 8.21018 8.0069 8.14575L8.63027 7.91509C8.68501 7.89483 8.72817 7.85167 8.74843 7.79692L8.9791 7.17356Z" fill="#F5F5F5" />
</Svg>
);

View File

@@ -0,0 +1,87 @@
import { View, StyleSheet, FlatList, Dimensions, Platform } from 'react-native'
import { Skeleton } from './skeleton'
const { width: screenWidth } = Dimensions.get('window')
const GAP = 2
const NUM_COLUMNS = 3
const ITEM_WIDTH = (screenWidth - GAP * 2) / NUM_COLUMNS
export function AIGenerationRecordDrawerSkeleton() {
return (
<View style={styles.container}>
{/* 顶部标题栏骨架 */}
<View style={styles.header}>
<Skeleton width={120} height={18} borderRadius={4} />
<View style={styles.closeButton}>
<Skeleton width={24} height={24} borderRadius={12} />
</View>
</View>
{/* 图片网格骨架 */}
<FlatList
data={Array.from({ length: 30 }, (_, i) => i)}
keyExtractor={(item) => item.toString()}
numColumns={NUM_COLUMNS}
contentContainerStyle={styles.imageGrid}
renderItem={({ index }) => {
const isLastInRow = (index + 1) % NUM_COLUMNS === 0
return (
<View
style={[
styles.imageItem,
{
width: ITEM_WIDTH,
marginRight: isLastInRow ? 0 : GAP,
marginBottom: GAP,
},
]}
>
<Skeleton width="100%" height="100%" borderRadius={0} />
</View>
)
}}
showsVerticalScrollIndicator={false}
/>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#16181B',
paddingTop: 12,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 16,
paddingBottom: 12,
position: 'relative',
},
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',
},
})

View File

@@ -0,0 +1,57 @@
import { View, StyleSheet } from 'react-native'
import { Skeleton } from './skeleton'
export function ChannelsSkeleton() {
return (
<View style={styles.container}>
{/* 标题栏骨架 */}
<View style={styles.header}>
<Skeleton width={60} height={16} borderRadius={4} />
<Skeleton width={22} height={22} borderRadius={4} />
</View>
{/* 频道选择区域骨架 */}
<View style={styles.channelsSection}>
<View style={styles.channelsGrid}>
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13].map((item) => (
<Skeleton
key={item}
width={80}
height={32}
borderRadius={100}
style={styles.channelButton}
/>
))}
</View>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0A0A0A',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: 27,
paddingBottom: 12,
},
channelsSection: {
paddingHorizontal: 12,
paddingBottom: 12,
},
channelsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
channelButton: {
marginBottom: 0,
},
})

View File

@@ -0,0 +1,80 @@
import { View, StyleSheet } from 'react-native'
import { Skeleton } from './skeleton'
export function GenerateVideoSkeleton() {
return (
<View style={styles.container}>
{/* 顶部导航栏骨架 */}
<View style={styles.header}>
<Skeleton width={22} height={22} borderRadius={4} />
</View>
<View style={styles.content}>
{/* 标题区域骨架 */}
<View style={styles.titleSection}>
<Skeleton width="70%" height={20} borderRadius={4} style={styles.title} />
<Skeleton width="50%" height={14} borderRadius={4} style={styles.subtitle} />
</View>
{/* 上传容器骨架 */}
<Skeleton width="100%" height={140} borderRadius={12} style={styles.uploadContainer} />
{/* 上传参考图按钮骨架 */}
<Skeleton width="100%" height={110} borderRadius={12} style={styles.uploadButton} />
{/* 描述输入区域骨架 */}
<Skeleton width="100%" height={150} borderRadius={12} style={styles.descriptionInput} />
{/* 生成按钮骨架 */}
<Skeleton width="100%" height={48} borderRadius={12} style={styles.generateButton} />
</View>
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#090A0B',
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: 17,
paddingHorizontal: 12,
paddingBottom: 20,
},
content: {
paddingHorizontal: 12,
paddingTop: 16,
backgroundColor: '#1C1E20',
borderTopLeftRadius: 20,
borderTopRightRadius: 20,
gap: 8,
},
titleSection: {
paddingHorizontal: 4,
marginBottom: 11,
gap: 5,
},
title: {
marginBottom: 5,
},
subtitle: {
marginTop: 0,
},
uploadContainer: {
marginBottom: 0,
},
uploadButton: {
marginBottom: 0,
},
descriptionInput: {
marginBottom: 0,
},
generateButton: {
marginTop: 12,
marginBottom: 10,
},
})

Some files were not shown because too many files have changed in this diff Show More