Initial commit: expo-popcore-app
31
.easignore
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||
7
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit",
|
||||
"source.sortMembers": "explicit"
|
||||
}
|
||||
}
|
||||
102
CLAUDE.md
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
3
assets/icons/carousel-activate.svg
Normal 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 |
3
assets/icons/carousel.svg
Normal 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
@@ -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
@@ -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
@@ -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 |
BIN
assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
assets/images/demo-1.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
assets/images/generate.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
assets/images/image-17 copy.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
assets/images/image-17.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
assets/images/membership.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
14
babel.config.js
Normal 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
|
||||
],
|
||||
}
|
||||
}
|
||||
20
components.json
Normal 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"
|
||||
}
|
||||
}
|
||||
75
components/GradientText.example.tsx
Normal 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
@@ -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
@@ -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',
|
||||
},
|
||||
})
|
||||
|
||||
186
components/SearchResultsGrid.tsx
Normal 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
@@ -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 }
|
||||
|
||||
142
components/blocks/AuthForm.tsx
Normal 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 };
|
||||
76
components/blocks/MediaCarousel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
components/blocks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MediaCarousel } from './MediaCarousel';
|
||||
export { AuthForm, useSession } from './AuthForm';
|
||||
213
components/drawer/AIGenerationRecordDrawer.tsx
Normal 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%',
|
||||
},
|
||||
})
|
||||
|
||||
258
components/drawer/EditProfileDrawer.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
|
||||
448
components/drawer/PointsDrawer.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
|
||||
433
components/drawer/TopUpDrawer.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
|
||||
383
components/drawer/UploadReferenceImageDrawer.tsx
Normal 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%',
|
||||
},
|
||||
})
|
||||
|
||||
14
components/icon/avatarUpload.tsx
Normal 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>
|
||||
);
|
||||
10
components/icon/change.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
8
components/icon/checkMark.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
8
components/icon/close1.tsx
Normal 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>
|
||||
);
|
||||
16
components/icon/delete.tsx
Normal 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>
|
||||
);
|
||||
24
components/icon/downArrow.tsx
Normal 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
@@ -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
@@ -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
@@ -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";
|
||||
7
components/icon/leftArrow.tsx
Normal 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>
|
||||
);
|
||||
7
components/icon/message.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
7
components/icon/noNews.tsx
Normal 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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
29
components/icon/points.tsx
Normal 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>
|
||||
);
|
||||
27
components/icon/privacy.tsx
Normal 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>
|
||||
);
|
||||
|
||||
7
components/icon/rightArrow.tsx
Normal 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>
|
||||
);
|
||||
9
components/icon/sameStyle.tsx
Normal 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>
|
||||
);
|
||||
14
components/icon/search.tsx
Normal 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>
|
||||
);
|
||||
14
components/icon/settings.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
|
||||
24
components/icon/topArrow.tsx
Normal 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>
|
||||
);
|
||||
7
components/icon/unchecked.tsx
Normal 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>
|
||||
);
|
||||
7
components/icon/upload.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
7
components/icon/whitePoints.tsx
Normal 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>
|
||||
);
|
||||
8
components/icon/whiteStar.tsx
Normal 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>
|
||||
);
|
||||
87
components/skeleton/AIGenerationRecordDrawerSkeleton.tsx
Normal 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',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
57
components/skeleton/ChannelsSkeleton.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
|
||||
80
components/skeleton/GenerateVideoSkeleton.tsx
Normal 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,
|
||||
},
|
||||
})
|
||||
|
||||