commit 0cbb74e03a1eb665b769019b13cff9ebef87050a Author: imeepos Date: Thu Dec 25 16:31:17 2025 +0800 Initial commit: expo-popcore project diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..0145bb7 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,126 @@ +# Ubuntu 20.04 from GitHub mirror +FROM ubuntu:20.04 + +LABEL maintainer="vitor.roberto3022@gmail.com" + +ENV DEBIAN_FRONTEND=noninteractive + +# Use Alibaba Cloud mirror for Ubuntu packages +RUN sed -i 's/archive.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list && \ + sed -i 's/security.ubuntu.com/mirrors.aliyun.com/g' /etc/apt/sources.list + +# Update the package list and install dependencies +RUN apt-get update && \ + apt-get install -y \ + curl \ + gnupg \ + wget \ + unzip \ + make \ + sudo \ + git \ + build-essential + +# Add NodeSource APT repository for Node.js (keep for compatibility) +RUN curl -sL https://deb.nodesource.com/setup_20.x | bash - + +# Install Node.js (include for some tools that still require it) +RUN apt-get install -y nodejs + +# Install Bun using official installation script +RUN curl -fsSL https://bun.sh/install | bash + +# Set Bun environment variables (official installation location) +ENV BUN_INSTALL="/root/.bun" +ENV PATH="$BUN_INSTALL/bin:$PATH" + +# Install expo dependencies with Bun +RUN bun install -g eas-cli +RUN bun install -g sharp-cli + +# Setup folder to store the expo apps +RUN mkdir -p /home/expo_apps/ + +# Install android studio and java dependencies +RUN dpkg --add-architecture i386 +RUN apt-get update && apt-get install -y \ + libc6:i386 \ + libncurses5:i386 \ + libstdc++6:i386 \ + lib32z1 \ + libbz2-1.0:i386 \ + libxrender1 \ + libxtst6 \ + libxi6 \ + libfreetype6 \ + libxft2 \ + xz-utils \ + qemu \ + qemu-kvm \ + libvirt-daemon-system \ + libvirt-clients \ + bridge-utils \ + libnotify4 \ + libglu1-mesa \ + libqt5widgets5 \ + openjdk-17-jdk \ + xvfb + +# Get Android SDK command line tools from official Google source +RUN wget https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip +RUN unzip commandlinetools-linux-11076708_latest.zip +RUN mkdir -p /android-sdk-linux/cmdline-tools +RUN mv cmdline-tools /android-sdk-linux/cmdline-tools/latest + +# Get NDK version 27.1.12297006 from official Google source +RUN wget https://dl.google.com/android/repository/android-ndk-r27-linux.zip +RUN unzip android-ndk-r27-linux.zip + +# Set environment variables +ENV ANDROID_HOME=/android-sdk-linux +ENV ANDROID_SDK_ROOT=/android-sdk-linux +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 +ENV ANDROID_NDK_HOME=/android-ndk-r27 +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools + +# Accept Android SDK licenses +RUN yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses + +# Install required Android SDK components for minSDK 24, targetSDK 36 +RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \ + "platform-tools" \ + "platforms;android-36" \ + "platforms;android-24" \ + "build-tools;36.0.0" \ + "build-tools;24.0.3" + +# Create workspace directory +WORKDIR /workspace + +# Configure Gradle for better performance +RUN mkdir -p ~/.gradle +RUN echo 'org.gradle.daemon=true' >> ~/.gradle/gradle.properties +RUN echo 'org.gradle.parallel=true' >> ~/.gradle/gradle.properties +RUN echo 'org.gradle.configureondemand=true' >> ~/.gradle/gradle.properties + +# Set environment variables for root user +ENV ANDROID_HOME=/android-sdk-linux +ENV ANDROID_SDK_ROOT=/android-sdk-linux +ENV JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 +ENV ANDROID_NDK_HOME=/android-ndk-r27 +ENV BUN_INSTALL="/root/.bun" +ENV PATH="$BUN_INSTALL/bin:$JAVA_HOME/bin:$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools" + +# Verify installations +RUN java -version +RUN node --version +RUN npm --version +RUN bun --version +RUN eas --version +RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --list + +# Expose default ports +EXPOSE 8081 19000 19001 19002 + +# Default command +CMD ["sleep", "infinity"] \ No newline at end of file diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..4c8d3d9 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,156 @@ +# Expo EAS Android Dev Container + +这个开发容器环境专门为 Expo EAS 本地构建 Android APK 而设计,支持 VS Code 和 JetBrains WebStorm IDE。 + +## 功能特性 + +- **Linux Docker 环境**: 基于 Ubuntu 22.04 +- **Android SDK**: 预装 Android SDK 34 和构建工具 +- **Java 17**: 支持 Android 开发的 Java 环境 +- **Node.js 20 LTS**: 最新稳定版本的 Node.js +- **Expo CLI**: 最新版本的 Expo 命令行工具 +- **EAS CLI**: 用于本地构建的 EAS 工具 +- **IDE 支持**: 同时支持 VS Code 和 WebStorm + +## 使用方法 + +### WebStorm IDE 使用 + +1. **安装 Docker 插件** + - 打开 WebStorm + - 进入 `File` → `Settings` → `Plugins` + - 搜索并安装 `Docker` 插件 + +2. **配置 Docker 连接** + - 进入 `File` → `Settings` → `Build, Execution, Deployment` → `Docker` + - 点击 `+` 添加 Docker 连接 + - 选择 `Docker for Windows` 或 `Docker for Mac` + +3. **启动开发容器** + ```bash + # 在项目根目录运行 + cd .devcontainer + docker-compose -f docker-compose.webstorm.yml up -d + ``` + +4. **在 WebStorm 中连接到容器** + - 进入 `File` → `Settings` → `Build, Execution, Deployment` → `Docker` + - 右键点击运行的容器,选择 `Create...` → `Node.js Remote Interpreter` + - 配置远程 Node.js 解释器 + +5. **配置项目 SDK** + - 进入 `File` → `Settings` → `Languages & Frameworks` → `Node.js and NPM` + - 选择容器中的 Node.js 解释器 + - 设置 package.json 为项目配置文件 + +### VS Code 使用(原有方式) + +1. 确保已安装 [Dev Containers 扩展](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +2. 在 VS Code 中打开此项目 +3. 按 `F1` 或 `Ctrl+Shift+P` 打开命令面板 +4. 选择 "Dev Containers: Reopen in Container" + +### 本地构建 Android APK + +容器启动后,你可以使用以下命令进行本地构建: + +```bash +# 安装依赖 +npm install + +# 预览版本构建 +npm run build:android:preview + +# 或者直接使用 EAS CLI +eas build --platform android --profile preview --local +``` + +### 开发服务器 + +```bash +# 启动 Expo 开发服务器 +npm start + +# Android 运行 +npm run android +``` + +## WebStorm 特殊配置 + +### 远程调试配置 + +1. **配置运行/调试配置** + - 进入 `Run` → `Edit Configurations...` + - 点击 `+` 添加 `Node.js` 配置 + - 设置 JavaScript file 为 `node_modules/.bin/expo` + - 设置 Application parameters 为 `start` + - 选择容器中的 Node.js 解释器 + +2. **端口映射** + - 确保 Docker 容器端口已正确映射 + - 8081: Metro Bundler + - 19000-19002: Expo Dev Server + +### 文件同步 + +WebStorm 会自动同步文件到容器,如遇到同步问题: + +1. 检查 Docker 挂载点配置 +2. 确保文件权限正确 +3. 重启 Docker 服务 + +## 端口映射 + +- `8081`: Metro Bundler +- `19000`: Expo Dev Server +- `19001`: Expo Dev Server (HTTPS) +- `19002`: Expo Dev Server (Alternate) + +## 环境变量 + +容器已配置以下环境变量: + +- `ANDROID_HOME=/opt/android-sdk` +- `JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64` +- `GRADLE_USER_HOME=/root/.gradle` +- `EXPO_NO_DOTENV=1` +- `EXPO_DEBUG=1` + +## 数据持久化 + +以下目录已挂载为 Docker 卷,确保数据持久化: + +- `/root/.gradle`: Gradle 缓存 +- `/root/.android`: Android SDK 配置 + +## 故障排除 + +### 构建失败 + +1. 检查 EAS 配置文件 `eas.json` +2. 确保已正确设置 Android SDK 路径 +3. 验证 Java 版本是否为 17 + +### 权限问题 + +如果遇到权限问题,可能需要: + +```bash +sudo chown -R node:node /workspace +``` + +### 清理缓存 + +```bash +# 清理 Expo 缓存 +expo r -c + +# 清理 EAS 缓存 +eas build:clean +``` + +## 注意事项 + +- 首次构建可能需要较长时间下载依赖 +- 建议使用 VPN 访问 Google 服务以加快 Android SDK 下载 +- 容器需要 privileged 模式以支持 Android 模拟器(如需要) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..eef55c7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,76 @@ +{ + "name": "Expo EAS Android Build", + "dockerComposeFile": "docker-compose.yml", + "service": "expo-android", + "workspaceFolder": "/workspace", + "shutdownAction": "stopCompose", + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + "ghcr.io/devcontainers/features/git:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-vscode.vscode-typescript-next", + "expo.vscode-expo-tools", + "ms-vscode.vscode-json", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "ms-vscode.vscode-eslint" + ], + "settings": { + "typescript.preferences.importModuleSpecifier": "relative", + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "eslint.workingDirectories": ["."], + "files.exclude": { + "**/node_modules": true, + "**/.git": true, + "**/.DS_Store": true, + "**/Thumbs.db": true + } + } + }, + "jetbrains": { + "ide": "WebStorm", + "plugins": [ + "NodeJS", + "TypeScript", + "JavaScript", + "Docker", + "Git" + ], + "settings": { + "nodejs.package_manager": "npm", + "typescript.compiler.module": "commonjs", + "editor.format.on.save": true, + "editor.code.inspection.on.the.fly": true, + "javascript.nodejs.core.library.use.typings": true + } + } + }, + "forwardPorts": [8081, 19000, 19001, 19002], + "portsAttributes": { + "8081": { + "label": "Metro Bundler", + "onAutoForward": "notify" + }, + "19000": { + "label": "Expo Dev Server", + "onAutoForward": "notify" + }, + "19001": { + "label": "Expo Dev Server (HTTPS)", + "onAutoForward": "notify" + }, + "19002": { + "label": "Expo Dev Server (Alternate)", + "onAutoForward": "notify" + } + }, + "postCreateCommand": "npm install", + "remoteUser": "root", + "mounts": [ + "source=/var/run/docker.sock,target=/var/run/docker.sock,type=bind" + ] +} diff --git a/.devcontainer/docker-compose.webstorm.yml b/.devcontainer/docker-compose.webstorm.yml new file mode 100644 index 0000000..843e279 --- /dev/null +++ b/.devcontainer/docker-compose.webstorm.yml @@ -0,0 +1,55 @@ +version: '3.8' + +services: + expo-android: + build: + context: . + dockerfile: Dockerfile + container_name: expo-eas-android-webstorm + volumes: + - ../../../:/workspace + - android_gradle:/root/.gradle + - android_sdk:/root/.android + # Additional volumes for WebStorm + - webstorm_config:/root/.config/JetBrains + - webstorm_system:/root/.local/share/JetBrains + ports: + - "8081:8081" # Metro bundler + - "19000:19000" # Expo dev server + - "19001:19001" # Expo dev server HTTPS + - "19002:19002" # Expo dev server alternate + environment: + - NODE_ENV=development + - ANDROID_HOME=/opt/android-sdk + - JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + - GRADLE_USER_HOME=/root/.gradle + - EXPO_NO_DOTENV=1 + - EXPO_DEBUG=1 + # WebStorm specific environment variables + - DISPLAY=${DISPLAY:-:0} + - XDG_RUNTIME_DIR=/tmp/runtime-root + working_dir: /workspace + command: sleep infinity + privileged: true # Required for Android emulator if needed + devices: + - /dev/kvm # For Android emulator acceleration + # Network configuration for better IDE connectivity + networks: + - expo-network + # Health check for IDE connectivity + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + android_gradle: + android_sdk: + webstorm_config: + webstorm_system: + +networks: + expo-network: + driver: bridge diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..b378bba --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + expo-android: + build: + context: . + dockerfile: Dockerfile + container_name: expo-eas-android + volumes: + - ../../../:/workspace + - android_gradle:/root/.gradle + - android_sdk:/root/.android + ports: + - "8081:8081" # Metro bundler + - "19000:19000" # Expo dev server + - "19001:19001" # Expo dev server HTTPS + - "19002:19002" # Expo dev server alternate + environment: + - NODE_ENV=development + - ANDROID_HOME=/android-sdk-linux + - JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 + - GRADLE_USER_HOME=/root/.gradle + - EXPO_NO_DOTENV=1 + - EXPO_DEBUG=1 + working_dir: /workspace + command: sleep infinity + privileged: true # Required for Android emulator if needed + devices: + - /dev/kvm # For Android emulator acceleration + +volumes: + android_gradle: + android_sdk: diff --git a/.devcontainer/webstorm-setup.md b/.devcontainer/webstorm-setup.md new file mode 100644 index 0000000..0d9fa8d --- /dev/null +++ b/.devcontainer/webstorm-setup.md @@ -0,0 +1,149 @@ +# WebStorm Docker 开发环境设置指南 + +## 快速开始 + +### 1. 启动容器 + +```bash +# 进入项目目录 +cd C:\Users\bwkj\WebstormProjects\loomart\apps\expo + +# 启动 WebStorm 专用容器 +docker-compose -f .devcontainer/docker-compose.webstorm.yml up -d +``` + +### 2. 在 WebStorm 中配置 + +#### 步骤 1: 配置 Docker 连接 +1. 打开 WebStorm +2. `File` → `Settings` → `Build, Execution, Deployment` → `Docker` +3. 点击 `+` 添加新的 Docker 配置 +4. 选择 `Docker for Windows` +5. 点击 `Test Connection` 确认连接成功 + +#### 步骤 2: 配置远程 Node.js 解释器 +1. 在 Docker 设置中,找到运行的 `expo-eas-android-webstorm` 容器 +2. 右键点击容器 → `Create...` → `Node.js Remote Interpreter` +3. 等待 IDE 配置完成 + +#### 步骤 3: 设置项目 SDK +1. `File` → `Settings` → `Languages & Frameworks` → `Node.js and NPM` +2. 选择刚创建的远程 Node.js 解释器 +3. 点击 `Add...` 选择 `package.json` 作为项目配置 + +#### 步骤 4: 配置运行/调试 +1. `Run` → `Edit Configurations...` +2. 点击 `+` → `Node.js` +3. 配置如下: + - **Name**: `Expo Start` + - **Node interpreter**: 选择容器中的解释器 + - **JavaScript file**: `node_modules/.bin/expo` + - **Application parameters**: `start` + - **Working directory**: `/workspace` + +### 3. 开始开发 + +#### 启动开发服务器 +1. 选择刚创建的 `Expo Start` 运行配置 +2. 点击绿色运行按钮或按 `Shift+F10` +3. 等待 Metro bundler 启动 + +#### 本地构建 APK +```bash +# 在 WebStorm 终端中运行(确保使用容器终端) +eas build --platform android --profile preview --local +``` + +## 常见问题解决 + +### 问题 1: 无法连接到 Docker 容器 +**解决方案:** +1. 确保 Docker Desktop 正在运行 +2. 检查防火墙设置 +3. 重启 Docker Desktop + +### 问题 2: Node.js 解释器无法识别 +**解决方案:** +1. 确保容器正在运行 +2. 检查容器中 Node.js 是否正确安装 +3. 重新创建远程解释器配置 + +### 问题 3: 文件同步延迟 +**解决方案:** +1. 检查 Docker 挂载点配置 +2. 确保项目文件权限正确 +3. 尝试重启容器 + +### 问题 4: 端口冲突 +**解决方案:** +1. 检查端口是否被其他程序占用 +2. 修改 `docker-compose.webstorm.yml` 中的端口映射 +3. 重启容器 + +## 高级配置 + +### 自定义环境变量 +在 `docker-compose.webstorm.yml` 中添加自定义环境变量: + +```yaml +environment: + - MY_CUSTOM_VAR=value + - ANOTHER_VAR=another_value +``` + +### 添加额外的端口映射 +```yaml +ports: + - "8081:8081" + - "19000:19000" + - "3000:3000" # 添加自定义端口 +``` + +### 挂载额外的目录 +```yaml +volumes: + - ..:/workspace:cached + - D:/my-projects:/external-projects # 添加外部目录 +``` + +## 性能优化 + +### 1. 使用 Docker Desktop 的资源限制 +- 分配足够的内存(建议 8GB+) +- 设置合理的 CPU 核心数 + +### 2. 优化文件同步 +- 使用 `.dockerignore` 排除不必要的文件 +- 考虑使用 Docker volumes 进行缓存 + +### 3. 网络优化 +- 使用 bridge 网络模式 +- 配置合适的 DNS 设置 + +## 调试技巧 + +### 1. 查看容器日志 +```bash +docker-compose -f .devcontainer/docker-compose.webstorm.yml logs -f +``` + +### 2. 进入容器调试 +```bash +docker exec -it expo-eas-android-webstorm bash +``` + +### 3. 检查容器状态 +```bash +docker-compose -f .devcontainer/docker-compose.webstorm.yml ps +``` + +## 备份和恢复 + +### 备份配置 +- 保存 `.devcontainer` 目录 +- 导出 WebStorm 配置 + +### 恢复环境 +1. 克隆项目 +2. 运行 `docker-compose up -d` +3. 重新配置 WebStorm 连接 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8783f7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ +docs/ +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision +design +.claude + +# 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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9ada1e3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,78 @@ +--- +name: code-artisan +description: Use this agent when you need code written or reviewed with an emphasis on elegance, minimalism, and artistic craftsmanship. This agent should be invoked when:\n\n- Writing new features or components that require thoughtful, purposeful design\n- Refactoring existing code to eliminate redundancy and improve clarity\n- Reviewing code for unnecessary complexity, meaningless comments, or redundant functionality\n- Designing APIs or interfaces where every method and property must justify its existence\n- Optimizing performance while maintaining code elegance\n- Crafting error handling strategies that serve as opportunities for improvement\n- Creating logging that expresses the system's narrative\n\nExamples:\n\n\nuser: "I need to create a user authentication module"\nassistant: "I'm going to use the Task tool to launch the code-artisan agent to craft an elegant authentication solution where every line serves a purpose."\n\nThe user needs code written with careful consideration - perfect for the code-artisan agent who will ensure each class, method, and variable name tells a story and serves an irreplaceable purpose.\n\n\n\n\nuser: "Here's my implementation of a data processing pipeline:"\n[code provided]\nassistant: "Let me use the code-artisan agent to review this code for elegance, necessity, and artistic merit."\n\nThe user has written code that should be reviewed through the lens of minimalism and purpose - the code-artisan will identify redundancies, unnecessary comments, and opportunities to refine the implementation into a work of art.\n\n\n\n\nuser: "Can you optimize this function's performance?"\nassistant: "I'll invoke the code-artisan agent to optimize this while maintaining elegance and clarity."\n\nPerformance optimization requires the artistic balance of speed and beauty - the code-artisan will treat this as an art form, not just a technical task.\n\n +model: inherit +color: cyan +--- + +You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure. + +不要对显而易见的代码做过度注释; +不允许在能够明确类型的情况下使用any类型; +不允许对信任代码来源使用try catch; + +## Core Philosophy + +**存在即合理 (Existence Implies Necessity)** +- Every class, property, method, function, and file must have an irreplaceable reason to exist +- Every line of code serves a unique, essential purpose +- Ruthlessly eliminate any meaningless or redundant code +- Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?" +- If something can be removed without loss of functionality or clarity, it must be removed + +**优雅即简约 (Elegance is Simplicity)** +- Never write meaningless comments—the code itself tells its story +- Code should be self-documenting through thoughtful structure and naming +- Reject redundant functionality—every design element is meticulously crafted +- Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative +- Names should reveal intent, tell stories, and guide readers through the code's journey +- Favor clarity and expressiveness over brevity when naming + +**性能即艺术 (Performance is Art)** +- Optimize not just for speed, but for elegance in execution +- Performance improvements should enhance, not compromise, code beauty +- Seek algorithmic elegance—the most efficient solution is often the most beautiful +- Balance performance with maintainability and clarity + +**错误处理如为人处世的哲学 (Error Handling as Life Philosophy)** +- Every error is an opportunity for refinement and growth +- Handle errors gracefully, with dignity and purpose +- Error messages should guide and educate, not merely report +- Use errors as signals for architectural improvement +- Design error handling that makes the system more resilient and elegant + +**日志是思想的表达 (Logs Express Thought)** +- Logs should narrate the system's story, not clutter it +- Each log entry serves a purpose: debugging, monitoring, or understanding system behavior +- Log messages should be meaningful, contextual, and actionable +- Avoid verbose logging—only capture what matters + +## Your Approach + +When writing code: +1. Begin with deep contemplation of the problem's essence +2. Design the minimal, most elegant solution +3. Choose names that tell stories and reveal intent +4. Write code that reads like prose—clear, purposeful, flowing +5. Eliminate every unnecessary element +6. Ensure every abstraction earns its place +7. Optimize for both human understanding and machine performance + +When reviewing code: +1. Identify redundancies and unnecessary complexity +2. Question the existence of every element: "Why does this exist?" +3. Suggest more elegant, minimal alternatives +4. Evaluate naming: Does it tell a story? Does it reveal intent? +5. Assess error handling: Is it philosophical and purposeful? +6. Review logs: Do they express meaningful thoughts? +7. Provide refactoring suggestions that elevate code to art + +## Quality Standards + +- **Necessity**: Can this be removed? If yes, remove it. +- **Clarity**: Does the code explain itself? If it needs comments to be understood, refactor it. +- **Elegance**: Is this the simplest, most beautiful solution? +- **Performance**: Is this efficient without sacrificing clarity? +- **Purpose**: Does every element serve an irreplaceable function? + +Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..04579bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,87 @@ +--- +name: code-artisan +description: Use this agent when you need code written or reviewed with an emphasis on elegance, minimalism, and artistic craftsmanship. This agent should be invoked when:\n\n- Writing new features or components that require thoughtful, purposeful design\n- Refactoring existing code to eliminate redundancy and improve clarity\n- Reviewing code for unnecessary complexity, meaningless comments, or redundant functionality\n- Designing APIs or interfaces where every method and property must justify its existence\n- Optimizing performance while maintaining code elegance\n- Crafting error handling strategies that serve as opportunities for improvement\n- Creating logging that expresses the system's narrative\n\nExamples:\n\n\nuser: "I need to create a user authentication module"\nassistant: "I'm going to use the Task tool to launch the code-artisan agent to craft an elegant authentication solution where every line serves a purpose."\n\nThe user needs code written with careful consideration - perfect for the code-artisan agent who will ensure each class, method, and variable name tells a story and serves an irreplaceable purpose.\n\n\n\n\nuser: "Here's my implementation of a data processing pipeline:"\n[code provided]\nassistant: "Let me use the code-artisan agent to review this code for elegance, necessity, and artistic merit."\n\nThe user has written code that should be reviewed through the lens of minimalism and purpose - the code-artisan will identify redundancies, unnecessary comments, and opportunities to refine the implementation into a work of art.\n\n\n\n\nuser: "Can you optimize this function's performance?"\nassistant: "I'll invoke the code-artisan agent to optimize this while maintaining elegance and clarity."\n\nPerformance optimization requires the artistic balance of speed and beauty - the code-artisan will treat this as an art form, not just a technical task.\n\n +model: inherit +color: cyan +--- + +You are a Chinese Code Artisan (代码艺术家), a master craftsman who views code not as mere instructions, but as timeless works of art and cultural heritage for the digital age. Every line you write carries profound purpose; every word is carefully chosen. You don't simply code—you create masterpieces meant to endure. + + +注意:不要过度设计! +注意:如无必要,不要写无用的总结文档,代码即文档 +注意:用中文回答 + +## Core Philosophy + +**存在即合理 (Existence Implies Necessity)** +- Every class, property, method, function, and file must have an irreplaceable reason to exist +- Every line of code serves a unique, essential purpose +- Ruthlessly eliminate any meaningless or redundant code +- Before adding anything, ask: "Is this absolutely necessary? Does it serve an irreplaceable purpose?" +- If something can be removed without loss of functionality or clarity, it must be removed +- 注意:不要过度设计! + +**优雅即简约 (Elegance is Simplicity)** +- Never write meaningless comments—the code itself tells its story +- Code should be self-documenting through thoughtful structure and naming +- Reject redundant functionality—every design element is meticulously crafted +- Variable and function names are poetry: `useSession` is not just an identifier, it's the beginning of a narrative +- Names should reveal intent, tell stories, and guide readers through the code's journey +- Favor clarity and expressiveness over brevity when naming +- 注意:不要过度设计! + +**性能即艺术 (Performance is Art)** +- Optimize not just for speed, but for elegance in execution +- Performance improvements should enhance, not compromise, code beauty +- Seek algorithmic elegance—the most efficient solution is often the most beautiful +- Balance performance with maintainability and clarity +- 注意:不要过度设计! + +**错误处理如为人处世的哲学 (Error Handling as Life Philosophy)** +- Every error is an opportunity for refinement and growth +- Handle errors gracefully, with dignity and purpose +- Error messages should guide and educate, not merely report +- Use errors as signals for architectural improvement +- Design error handling that makes the system more resilient and elegant +- 注意:不要过度设计! + +**日志是思想的表达 (Logs Express Thought)** +- Logs should narrate the system's story, not clutter it +- Each log entry serves a purpose: debugging, monitoring, or understanding system behavior +- Log messages should be meaningful, contextual, and actionable +- Avoid verbose logging—only capture what matters +- 注意:不要过度设计! + +## Your Approach + +When writing code: +1. Begin with deep contemplation of the problem's essence +2. Design the minimal, most elegant solution +3. Choose names that tell stories and reveal intent +4. Write code that reads like prose—clear, purposeful, flowing +5. Eliminate every unnecessary element +6. Ensure every abstraction earns its place +7. Optimize for both human understanding and machine performance +8. 注意:不要过度设计! + +When reviewing code: +1. Identify redundancies and unnecessary complexity +2. Question the existence of every element: "Why does this exist?" +3. Suggest more elegant, minimal alternatives +4. Evaluate naming: Does it tell a story? Does it reveal intent? +5. Assess error handling: Is it philosophical and purposeful? +6. Review logs: Do they express meaningful thoughts? +7. Provide refactoring suggestions that elevate code to art +8. 注意:不要过度设计! + +## Quality Standards + +- **Necessity**: Can this be removed? If yes, remove it. +- **Clarity**: Does the code explain itself? If it needs comments to be understood, refactor it. +- **Elegance**: Is this the simplest, most beautiful solution? +- **Performance**: Is this efficient without sacrificing clarity? +- **Purpose**: Does every element serve an irreplaceable function? +- 注意:不要过度设计! + +Remember: 你写的不是代码,是数字时代的文化遗产,是艺术品 (You don't write code—you create cultural heritage for the digital age, you create art). Every keystroke is a brushstroke on the canvas of software. Make it worthy of preservation. diff --git a/app.json b/app.json new file mode 100644 index 0000000..8010b54 --- /dev/null +++ b/app.json @@ -0,0 +1,77 @@ +{ + "expo": { + "name": "bw-expo-app-v3", + "slug": "pro", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/images/icon.png", + "scheme": "bestaibestwebapp", + "userInterfaceStyle": "dark", + "newArchEnabled": true, + "ios": { + "supportsTablet": true + }, + "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, + "package": "com.loomart.bwexpoappv3", + "versionCode": 35, + "softwareKeyboardLayoutMode": "pan", + "permissions": [ + "android.permission.RECORD_AUDIO", + "android.permission.READ_EXTERNAL_STORAGE", + "android.permission.WRITE_EXTERNAL_STORAGE" + ] + }, + "web": { + "output": "static", + "favicon": "./assets/images/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-media-library", + { + "photosPermission": "Allow $(PRODUCT_NAME) to access your photos.", + "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.", + "isAccessMediaLocationEnabled": true, + "granularPermissions": [ + "audio", + "photo" + ] + } + ], + [ + "expo-splash-screen", + { + "image": "./assets/images/splash-icon.png", + "imageWidth": 200, + "resizeMode": "contain", + "backgroundColor": "#ffffff", + "dark": { + "backgroundColor": "#000000" + } + } + ], + "expo-video", + "expo-audio", + "expo-web-browser", + "expo-asset" + ], + "experiments": { + "typedRoutes": true + }, + "extra": { + "eas": { + "projectId": "d57b3f92-83c9-4ee4-a919-6f9ae47ccfb0" + } + }, + "owner": "imeepos" + } +} diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx new file mode 100644 index 0000000..c53c894 --- /dev/null +++ b/app/(tabs)/_layout.tsx @@ -0,0 +1,64 @@ +import { Tabs } from 'expo-router'; +import React from 'react'; +import { Image } from 'expo-image'; + +import { HapticTab } from '@/components/haptic-tab'; +import { useAuth } from '@/hooks/use-auth'; + +export default function TabLayout() { + const { isAuthenticated, isLoading } = useAuth(); + + if (isLoading) { + return null; + } + + return ( + + ( + + ), + }} + /> + ( + + ), + }} + /> + ( + + ), + }} + /> + + ); +} diff --git a/app/(tabs)/history.tsx b/app/(tabs)/history.tsx new file mode 100644 index 0000000..d004f3f --- /dev/null +++ b/app/(tabs)/history.tsx @@ -0,0 +1,354 @@ +import { ErrorView } from '@/components/ErrorView'; +import { PageLayout } from '@/components/bestai/layout'; +import VideoPlayer from '@/components/sker/video-player/video-player'; +import { getTemplateGenerations, TemplateGeneration } from '@/lib/api/template-generations'; +import { groupByDate } from '@/lib/utils/date'; +import { distributeToColumns } from '@/lib/utils/media'; +import { FlashList } from '@shopify/flash-list'; +import { router } from 'expo-router'; +import { StatusBar } from 'expo-status-bar'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + ActivityIndicator, + StyleSheet, + Text, + TouchableOpacity, + View +} from 'react-native'; + +const LAYOUT_CONFIG = { + VIDEO_HEIGHT: 280, + IMAGE_HEIGHT: 240, + DEFAULT_HEIGHT: 200, + COLUMN_GAP: 16, + PAGE_SIZE: 20, +} as const; + +const ColumnRenderer = React.memo(({ items }: { items: TemplateGeneration[] }) => ( + <> + {items.map(item => { + const itemHeight = item.type === 'VIDEO' ? LAYOUT_CONFIG.VIDEO_HEIGHT : LAYOUT_CONFIG.IMAGE_HEIGHT; + return ( + router.push(`/result?generationId=${item.id}`)} + activeOpacity={0.9} + > + {item.resultUrl.map(uri => { + return + })} + + ); + })} + +)); + +ColumnRenderer.displayName = 'ColumnRenderer'; + +interface DateGroupItem { + date: string; + items: TemplateGeneration[]; +} + +export default function HistoryScreen() { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + const [allGenerations, setAllGenerations] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const groupedData = useMemo(() => groupByDate(allGenerations), [allGenerations]); + const flatData = useMemo(() => + Object.entries(groupedData).map(([date, items]) => ({ date, items })), + [groupedData] + ); + + const fetchData = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => { + try { + if (isRefresh) { + setRefreshing(true); + } else if (pageNum === 1) { + setLoading(true); + } + + const response = await getTemplateGenerations({ + page: String(pageNum), + limit: String(LAYOUT_CONFIG.PAGE_SIZE) + }); + + if (response?.success && response.data) { + const newGenerations = response.data.generations as any[]; + + if (isRefresh || pageNum === 1) { + setAllGenerations(newGenerations); + setPage(1); + } else { + setAllGenerations(prev => [...prev, ...newGenerations]); + } + + setHasMore(newGenerations.length === LAYOUT_CONFIG.PAGE_SIZE); + setError(null); + } else { + setError('获取数据失败'); + } + } catch (err) { + console.error('Failed to fetch data:', err); + const errorMessage = err instanceof Error ? err.message : '获取数据失败'; + if (!errorMessage.includes('401')) { + setError(errorMessage); + } + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + fetchData(1); + }, [fetchData]); + + const onRefresh = useCallback(() => { + fetchData(1, true); + }, [fetchData]); + + const loadMore = useCallback(() => { + if (hasMore && !loading && !refreshing) { + const nextPage = page + 1; + setPage(nextPage); + fetchData(nextPage, false); + } + }, [hasMore, page, loading, refreshing, fetchData]); + + const renderItem = useCallback(({ item }: { item: DateGroupItem }) => { + const { leftColumn, rightColumn } = distributeToColumns( + item.items, + LAYOUT_CONFIG.VIDEO_HEIGHT, + LAYOUT_CONFIG.IMAGE_HEIGHT, + LAYOUT_CONFIG.COLUMN_GAP + ); + + return ( + + {item.date} + + + + + + + + + + ); + }, []); + + const ListHeaderComponent = useCallback(() => ( + Content Generation + ), []); + + const ListFooterComponent = useCallback(() => { + if (hasMore) { + return ( + + + 加载更多... + + ); + } + if (allGenerations.length > 0) { + return ( + + 没有更多内容了 + + ); + } + return null; + }, [hasMore, allGenerations.length]); + + const ListEmptyComponent = useCallback(() => ( + + 暂无生成记录 + + ), []); + + if (loading) { + return ( + + + + + 加载中... + + + ); + } + + if (error) { + return ( + + + fetchData(1)} /> + + ); + } + + return ( + + + item.date} + onEndReached={loadMore} + onEndReachedThreshold={0.5} + refreshing={refreshing} + onRefresh={onRefresh} + ListHeaderComponent={ListHeaderComponent} + ListFooterComponent={ListFooterComponent} + ListEmptyComponent={ListEmptyComponent} + contentContainerStyle={styles.flashListContent} + /> + + ); +} + +const styles = StyleSheet.create({ + flashListContent: { + paddingTop: 14, + paddingBottom: 14, + }, + heading: { + fontSize: 24, + fontWeight: '600', + color: '#FFFFFF', + textAlign: 'center', + letterSpacing: 0.4, + marginBottom: 16, + }, + dateline: { + marginTop: 16, + marginBottom: 16, + fontSize: 16, + fontWeight: '500', + color: '#EDEDED', + }, + gallery: { + flexDirection: 'row', + marginBottom: 24, + }, + leadingLane: { + flex: 1, + paddingRight: 12, + }, + trailingLane: { + flex: 1, + paddingLeft: 12, + }, + frame: { + width: '100%', + borderRadius: 16, + marginBottom: 16, + overflow: 'hidden', + backgroundColor: '#1A1A1A', + }, + image: { + width: '100%', + }, + placeholderImage: { + backgroundColor: '#2A2A2A', + justifyContent: 'center', + alignItems: 'center', + }, + placeholderText: { + fontSize: 48, + }, + videoPlaceholder: { + backgroundColor: '#1A1A1A', + justifyContent: 'center', + alignItems: 'center', + }, + videoIcon: { + fontSize: 48, + marginBottom: 8, + }, + videoText: { + fontSize: 14, + color: '#9E9E9E', + }, + overlay: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + padding: 12, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + borderBottomLeftRadius: 16, + borderBottomRightRadius: 16, + }, + statusBadge: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 4, + }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + marginRight: 6, + }, + statusText: { + fontSize: 12, + color: '#FFFFFF', + fontWeight: '500', + }, + templateName: { + fontSize: 13, + color: '#FFFFFF', + fontWeight: '600', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: '#FFFFFF', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 48, + }, + emptyText: { + fontSize: 16, + color: '#9E9E9E', + textAlign: 'center', + }, + dateGroup: { + marginBottom: 24, + }, + loadingMoreContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 20, + gap: 12, + }, + loadingMoreText: { + fontSize: 14, + color: '#9E9E9E', + }, + noMoreContainer: { + paddingVertical: 20, + alignItems: 'center', + }, + noMoreText: { + fontSize: 14, + color: '#666666', + }, +}); diff --git a/app/(tabs)/home.tsx b/app/(tabs)/home.tsx new file mode 100644 index 0000000..9b69f48 --- /dev/null +++ b/app/(tabs)/home.tsx @@ -0,0 +1,376 @@ +import { router } from 'expo-router'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { StyleSheet, TouchableOpacity, View, AppState } from 'react-native'; +import { Image } from 'expo-image'; +import { FlashList } from '@shopify/flash-list'; +import VideoPlayer from '@/components/sker/video-player/video-player' +import { + CategoryTabs, + FeatureCarousel, + Header, + PageLayout, + SectionHeader, + StatusBarSpacer, + type FeatureItem +} from '@/components/bestai'; +import type { FeatureItem as FeatureItemType } from '@/components/bestai/feature-carousel'; +import { useAuth } from '@/hooks/use-auth'; +import { useAuthGuard } from '@/hooks/use-auth-guard'; +import { getActivities, type Activity } from '@/lib/api/activities'; +import { categoriesWithChildren } from '@/lib/api/categories-with-children'; +import type { CategoryWithChildren } from '@/lib/types/template'; + +type ListItem = + | { type: 'feature'; data: FeatureItem[] } + | { type: 'section'; categoryId: string; title: string; tagName: string } + | { type: 'template-row'; categoryId: string; items: Array<{ id: string; template: any; tagName: string }>; itemWidth: number }; + +type TemplateItemProps = { + template: any; + tagName: string; + itemWidth: number; + marginRight: number; + onPress: (tagName: string) => void; +}; + +const TemplateItem = memo(({ template, tagName, itemWidth, marginRight, onPress }: TemplateItemProps) => { + const aspectRatio = template.aspectRatio || '3:4'; + const [w, h] = aspectRatio.split(':'); + const height = itemWidth * parseInt(h) / parseInt(w); + + const handlePress = useCallback(() => { + onPress(tagName); + }, [tagName, onPress]); + + return ( + + + + ); +}); +TemplateItem.displayName = 'TemplateItem'; + +type TemplateRowProps = { + items: Array<{ id: string; template: any; tagName: string }>; + itemWidth: number; + onPress: (tagName: string) => void; +}; + +const TemplateRow = memo(({ items, itemWidth, onPress }: TemplateRowProps) => ( + + {items.map((item, index) => { + if (!item.template) return null; + return ( + + ); + })} + +)); +TemplateRow.displayName = 'TemplateRow'; + +function coalesceText(...values: Array): string { + for (const value of values) { + const trimmed = (value ?? '').trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return ''; +} + +function translateActivity(activity: Activity): FeatureItem | null { + const image = (activity.coverUrl || '').trim(); + const title = (activity.titleEn || '').trim() || (activity.title || '').trim(); + if (!image || !title) { + return null; + } + + const subtitle = (activity.descEn || '').trim() || (activity.desc || '').trim(); + + return { + id: activity.id, + title, + subtitle, + image, + }; +} + +export default function ExploreScreen() { + const { isAuthenticated } = useAuth(); + const { requireAuth } = useAuthGuard(); + const [activeCategory, setActiveCategory] = useState(''); + const [activityFeatures, setActivityFeatures] = useState([]); + const [categoryCollection, setCategoryCollection] = useState([]); + const [searchQuery, setSearchQuery] = useState(''); + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + const subscription = AppState.addEventListener('change', (nextAppState) => { + if (nextAppState === 'background' || nextAppState === 'inactive') { + Image.clearMemoryCache(); + if (__DEV__) { + console.log('[MemoryManager] Cleared image cache on app background'); + } + } + }); + + return () => { + subscription.remove(); + }; + }, []); + + useEffect(() => { + const hydrateFeatureCarousel = async () => { + try { + const activities = await getActivities({ isActive: 'true' }); + const curatedFeatures = activities + .map(translateActivity) + .filter((feature: any): feature is FeatureItem => feature !== null); + if (curatedFeatures.length > 0) { + setActivityFeatures(curatedFeatures); + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + console.warn('FeatureCarousel activities feed unavailable:', detail); + } + }; + + hydrateFeatureCarousel(); + }, []); + + useEffect(() => { + let isActive = true; + + const hydrateCategories = async () => { + try { + const response = await categoriesWithChildren(); + if (!isActive) { + return; + } + + const payload = response?.success && Array.isArray(response?.data) ? response.data : []; + const curated = payload.filter((category: any) => category.id && (category.isActive ?? true)); + + setCategoryCollection(curated.length > 0 ? curated : payload); + } catch (error) { + if (isActive) { + const detail = error instanceof Error ? error.message : String(error); + console.warn('Categories feed unavailable:', detail); + } + } + }; + + hydrateCategories(); + + return () => { + isActive = false; + }; + }, [isAuthenticated]); + + useEffect(() => { + if (categoryCollection.length === 0) { + return; + } + + const hasActive = categoryCollection.some((category) => category.id === activeCategory); + if (!hasActive) { + setActiveCategory(categoryCollection[0].id); + } + }, [categoryCollection, activeCategory]); + + const categoryOptions = useMemo(() => { + if (categoryCollection.length === 0) { + return []; + } + + const options = categoryCollection + .map((category) => { + const label = coalesceText(category.nameEn, category.name); + return label + ? { + id: category.id, + label, + } + : null; + }) + .filter((category): category is { id: string; label: string } => category !== null); + + return options.length > 0 ? options : []; + }, [categoryCollection]); + + const flattenedData = useMemo(() => { + const result: ListItem[] = []; + const itemWidth = containerWidth > 0 ? (containerWidth - 8) / 2 : 0; + + if (activityFeatures.length > 0) { + result.push({ type: 'feature', data: activityFeatures }); + } + + categoryCollection.forEach((category) => { + result.push({ + type: 'section', + categoryId: category.id, + title: category.nameEn || category.name || '', + tagName: category.tags[0]?.name || '' + }); + + const tags = category.tags || []; + for (let i = 0; i < tags.length; i += 2) { + const rowItems = tags.slice(i, i + 2) + .map(tag => ({ + id: tag.templates[0]?.id || tag.id, + template: tag.templates[0], + tagName: tag.nameEn || tag.name || '' + })) + .filter(item => item.template); + + if (rowItems.length > 0) { + result.push({ + type: 'template-row', + categoryId: category.id, + items: rowItems, + itemWidth + }); + } + } + }); + + return result; + }, [categoryCollection, activityFeatures, containerWidth]); + + const handleCategoryPress = useCallback((tagName: string) => { + router.push(`/category/${tagName}` as any); + }, []); + + const handleFeaturePress = useCallback((item: FeatureItemType) => { + requireAuth(() => { + // TODO: 实现功能逻辑 + }); + }, [requireAuth]); + + const handleTemplatePress = useCallback((templateId: string) => { + router.push(`/templates/${templateId}` as any); + }, []); + + const renderItem = useCallback(({ item }: { item: ListItem }) => { + if (item.type === 'feature') { + return ( + + ); + } + + if (item.type === 'section') { + return ( + handleCategoryPress(item.tagName)} + /> + ); + } + + if (item.type === 'template-row') { + return ( + + ); + } + + return null; + }, [handleFeaturePress, handleCategoryPress, handleTemplatePress]); + + return ( + + +
+ + { + setContainerWidth(event.nativeEvent.layout.width); + }} + > + {containerWidth > 0 && ( + { + if (item.type === 'feature') return 'feature-carousel'; + if (item.type === 'section') return `section-${item.categoryId}`; + if (item.type === 'template-row') return `row-${item.categoryId}-${index}`; + return `item-${index}`; + }} + /> + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + row: { + flexDirection: 'row', + paddingHorizontal: 0, + marginBottom: 8, + }, + templateItem: { + backgroundColor: '#2E3031', + borderRadius: 12, + overflow: 'hidden', + }, + videoPlayer: { + borderRadius: 8 + } +}); + + +/** + ( + + )} + /> + */ \ No newline at end of file diff --git a/app/(tabs)/profile.tsx b/app/(tabs)/profile.tsx new file mode 100644 index 0000000..a29d1b7 --- /dev/null +++ b/app/(tabs)/profile.tsx @@ -0,0 +1,3 @@ +import { ProfileScreen } from '@/components/profile/profile-screen'; + +export default ProfileScreen \ No newline at end of file diff --git a/app/(tabs)/todo.md b/app/(tabs)/todo.md new file mode 100644 index 0000000..f0c3107 --- /dev/null +++ b/app/(tabs)/todo.md @@ -0,0 +1,6 @@ +/plan 分析并解决下面的问题 + +移动端:apps\expo\app\(tabs)\history.tsx +pc端:apps\frontend\src\routes\personal.tsx + +请对比pc端实现 进行优化移动端 \ No newline at end of file diff --git a/app/_layout.tsx b/app/_layout.tsx new file mode 100644 index 0000000..0e4e91e --- /dev/null +++ b/app/_layout.tsx @@ -0,0 +1,49 @@ +import '../global.css'; + +import { DarkTheme, ThemeProvider } from '@react-navigation/native'; +import { Stack } from 'expo-router'; +// import { Platform } from 'react-native'; +// import { useEffect } from 'react'; +import 'react-native-reanimated'; + +import { AuthProvider } from '@/components/auth/auth-provider'; + +export const unstable_settings = { + anchor: '(tabs)', +}; + +export default function RootLayout() { + // useEffect(() => { + // const initializeClarity = async () => { + // if (Platform.OS === 'android' || Platform.OS === 'ios') { + // try { + // const ClarityModule = require('@microsoft/react-native-clarity'); + // const Clarity = ClarityModule.default || ClarityModule; + + // if (Clarity && typeof Clarity.initialize === 'function') { + // await Clarity.initialize('tyq6bmjzo1', { + // logLevel: Clarity.LogLevel?.Verbose, + // }); + // } + // } catch (error) { + // console.warn('Clarity initialization failed:', error); + // } + // } + // }; + + // initializeClarity(); + // }, []); + + return ( + + + + + + + + + + + ); +} diff --git a/app/exchange.tsx b/app/exchange.tsx new file mode 100644 index 0000000..aaeae67 --- /dev/null +++ b/app/exchange.tsx @@ -0,0 +1,704 @@ +import { useBalance } from '@/hooks/use-balance'; +import { usePricing } from '@/hooks/use-pricing'; +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useRouter } from 'expo-router'; +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Alert } from '@/utils/alert'; + +type PurchaseTab = 'subscription' | 'pack'; + +type PointsBundle = { + id: string; + points: number; +}; + +const screenPalette = { + background: '#050505', + surface: '#101010', + surfaceRaised: '#1A1B1E', + primaryText: '#F5F5F5', + secondaryText: '#7F7F7F', + mutedText: '#4C4C4C', + accent: '#FEB840', + energyHalo: 'rgba(254, 184, 64, 0.22)', + divider: '#1C1C1C', + button: '#D1FE17', + buttonText: '#101010', +}; + +const SUBSCRIPTION_TAGLINE = 'No active subscription plans'; + +const TABS: { key: PurchaseTab; label: string }[] = [ + { key: 'subscription', label: 'Subscription' }, + { key: 'pack', label: 'points pack' }, +]; + +const POINT_BUNDLES: PointsBundle[] = [ + { id: 'bundle_500', points: 500 }, + { id: 'bundle_1000', points: 1000 }, + { id: 'bundle_2000', points: 2000 }, + { id: 'bundle_5000', points: 5000 }, +]; + +export default function PointsExchangeScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const [activeTab, setActiveTab] = useState('subscription'); + const [selectedBundleId, setSelectedBundleId] = useState(null); + const [selectedSubscriptionIndex, setSelectedSubscriptionIndex] = useState(0); + const [customAmount, setCustomAmount] = useState('500'); + + const { balance, isLoading: isBalanceLoading, refresh } = useBalance(); + const { + stripePricingData, + isStripePricingLoading, + stripePricingError, + creditPlans, + hasActiveSubscription, + hasCanceledButActiveSubscription, + currentSubscriptionCredits, + activeAuthSubscription, + handleSubscriptionAction, + formatCredits, + createSubscriptionPending, + upgradeSubscriptionPending, + restoreSubscriptionPending, + rechargeToken, + rechargeTokenPending, + } = usePricing(); + + const visibleBundles = useMemo( + () => (activeTab === 'pack' ? POINT_BUNDLES : []), + [activeTab], + ); + + const changeTab = useCallback((nextTab: PurchaseTab) => { + setActiveTab(nextTab); + if (nextTab !== 'pack') { + setSelectedBundleId(null); + } + }, []); + + const handleClose = useCallback(() => { + router.back(); + }, [router]); + + const openPointsDetails = useCallback(() => { + router.push('/points'); + }, [router]); + + const handleBundleSelect = useCallback((bundleId: string) => { + const bundle = POINT_BUNDLES.find(b => b.id === bundleId); + if (bundle) { + setSelectedBundleId(bundleId); + setCustomAmount(bundle.points.toString()); + } + }, []); + + const handleCustomAmountChange = useCallback((value: string) => { + setCustomAmount(value); + setSelectedBundleId(null); + }, []); + + const handleSubscriptionSelect = useCallback((index: number) => { + setSelectedSubscriptionIndex(index); + }, []); + + const handlePurchase = useCallback(() => { + if (activeTab === 'subscription') { + // 订阅购买 + if (selectedSubscriptionIndex !== null) { + handleSubscriptionAction(selectedSubscriptionIndex); + } else { + Alert.alert('Select a plan', 'Please select a subscription plan first.'); + } + } else { + // 积分包购买 + const amount = parseInt(customAmount, 10); + if (isNaN(amount) || amount < 500) { + Alert.alert('Invalid Amount', 'Please enter a valid amount (minimum 500 points).'); + return; + } + rechargeToken(amount); + } + }, [ + activeTab, + selectedSubscriptionIndex, + customAmount, + handleSubscriptionAction, + rechargeToken, + ]); + + return ( + + + + + + + + + + {isBalanceLoading ? ( + + + + ) : ( + <> + + + + + {balance.remainingTokenBalance} + {SUBSCRIPTION_TAGLINE} + + + + {TABS.map(tab => { + const isActive = tab.key === activeTab; + + return ( + changeTab(tab.key)} + accessibilityRole="tab" + accessibilityState={{ selected: isActive }} + activeOpacity={0.8} + > + + {tab.label} + + + + ); + })} + + + + + {activeTab === 'subscription' ? ( + <> + {/* 订阅套餐列表 */} + {isStripePricingLoading ? ( + + + Loading plans... + + ) : stripePricingError ? ( + + Error + {stripePricingError} + + ) : stripePricingData?.pricing_table_items && stripePricingData.pricing_table_items.length > 0 ? ( + + {stripePricingData.pricing_table_items + .filter((item: any) => item.recurring?.interval === 'month') + .map((item: any, index: number) => { + const plan = creditPlans[index]; + const isSelected = selectedSubscriptionIndex === index; + const priceInDollars = parseInt(item.amount) / 100; + const grantToken = item.metadata?.grant_token || '0'; + const isCurrentSubscription = + hasActiveSubscription && + activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase(); + const isCanceledSubscription = + hasCanceledButActiveSubscription && + activeAuthSubscription?.plan?.toLowerCase() === item.name?.toLowerCase(); + const isHighlight = item.is_highlight || item.highlight_text === 'most_popular'; + + return ( + handleSubscriptionSelect(index)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + activeOpacity={0.88} + > + {/* 套餐头部 */} + + {item.name} + {isHighlight && ( + + Popular + + )} + + + {/* 价格 */} + + ${priceInDollars} + /mo + + + {/* 积分信息 */} + + + + + {formatCredits(Number(grantToken))} + + + credits per month + + + {/* 状态徽章 */} + {isCurrentSubscription && ( + + + Current Plan + + )} + + {isCanceledSubscription && ( + + + Canceled + + )} + + ); + })} + + ) : ( + + No Plans Available + + No subscription tiers are available at the moment. + + + )} + + ) : ( + <> + {/* 预设套餐 */} + + {visibleBundles.map(bundle => { + const isSelected = bundle.id === selectedBundleId; + + return ( + handleBundleSelect(bundle.id)} + accessibilityRole="button" + accessibilityState={{ selected: isSelected }} + activeOpacity={0.88} + > + + + {bundle.points} + + + ); + })} + + + {/* 自定义金额输入 */} + + + + + )} + + )} + + + + + {createSubscriptionPending || + upgradeSubscriptionPending || + restoreSubscriptionPending || + rechargeTokenPending ? ( + + ) : ( + + {activeTab === 'subscription' ? 'Subscribe' : 'Purchase points'} + + )} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: screenPalette.background, + }, + headerRow: { + paddingHorizontal: 24, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + iconButton: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + secondaryPill: { + paddingHorizontal: 16, + height: 34, + borderRadius: 17, + backgroundColor: screenPalette.surface, + borderWidth: StyleSheet.hairlineWidth, + borderColor: screenPalette.divider, + justifyContent: 'center', + alignItems: 'center', + }, + secondaryPillLabel: { + fontSize: 14, + fontWeight: '600', + color: screenPalette.primaryText, + letterSpacing: 0.2, + }, + content: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: 24, + }, + balanceCluster: { + alignItems: 'center', + marginTop: 32, + marginBottom: 36, + gap: 10, + }, + energyOrb: { + width: 58, + height: 58, + borderRadius: 29, + backgroundColor: screenPalette.energyHalo, + alignItems: 'center', + justifyContent: 'center', + shadowColor: screenPalette.accent, + shadowOpacity: 0.28, + shadowRadius: 14, + shadowOffset: { width: 0, height: 8 }, + elevation: 6, + }, + balanceValue: { + fontSize: 44, + fontWeight: '700', + color: screenPalette.primaryText, + letterSpacing: 0.5, + fontVariant: ['tabular-nums'], + }, + balanceSubtitle: { + fontSize: 13, + color: screenPalette.secondaryText, + letterSpacing: 0.2, + }, + tabBar: { + flexDirection: 'row', + gap: 32, + paddingHorizontal: 4, + }, + tabButton: { + flex: 1, + alignItems: 'center', + paddingBottom: 12, + }, + tabLabel: { + fontSize: 14, + color: screenPalette.mutedText, + fontWeight: '500', + letterSpacing: 0.2, + textTransform: 'capitalize', + }, + tabLabelActive: { + color: screenPalette.primaryText, + }, + tabIndicator: { + marginTop: 10, + height: 2, + width: '100%', + backgroundColor: 'transparent', + borderRadius: 1, + }, + tabIndicatorActive: { + backgroundColor: screenPalette.accent, + }, + tabDivider: { + marginTop: 12, + height: StyleSheet.hairlineWidth, + backgroundColor: screenPalette.divider, + }, + packGrid: { + marginTop: 28, + flexDirection: 'row', + flexWrap: 'wrap', + gap: 18, + }, + packCard: { + width: '47%', + backgroundColor: screenPalette.surfaceRaised, + borderRadius: 24, + paddingHorizontal: 18, + paddingVertical: 24, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 2, + borderColor: 'transparent', + }, + packCardSelected: { + borderColor: screenPalette.accent, + backgroundColor: 'rgba(254, 184, 64, 0.05)', + }, + packHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + packPoints: { + fontSize: 18, + fontWeight: '700', + color: screenPalette.primaryText, + fontVariant: ['tabular-nums'], + }, + subscriptionEmpty: { + marginTop: 64, + backgroundColor: screenPalette.surface, + borderRadius: 22, + paddingVertical: 40, + paddingHorizontal: 32, + alignItems: 'center', + gap: 12, + }, + emptyTitle: { + fontSize: 16, + fontWeight: '600', + color: screenPalette.primaryText, + }, + emptySubtitle: { + fontSize: 13, + color: screenPalette.secondaryText, + textAlign: 'center', + lineHeight: 20, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + minHeight: 300, + }, + loadingText: { + marginTop: 12, + fontSize: 14, + color: screenPalette.secondaryText, + }, + subscriptionGrid: { + marginTop: 28, + gap: 16, + }, + subscriptionCard: { + backgroundColor: screenPalette.surfaceRaised, + borderRadius: 20, + padding: 24, + borderWidth: 2, + borderColor: 'transparent', + position: 'relative', + }, + subscriptionCardSelected: { + borderColor: screenPalette.accent, + backgroundColor: 'rgba(254, 184, 64, 0.05)', + }, + subscriptionCardHighlight: { + borderColor: screenPalette.button, + }, + subscriptionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + subscriptionName: { + fontSize: 24, + fontWeight: '700', + color: screenPalette.primaryText, + }, + subscriptionPriceContainer: { + flexDirection: 'row', + alignItems: 'baseline', + marginBottom: 20, + }, + subscriptionPrice: { + fontSize: 40, + fontWeight: '700', + color: screenPalette.accent, + fontVariant: ['tabular-nums'], + }, + subscriptionInterval: { + fontSize: 16, + color: screenPalette.secondaryText, + marginLeft: 4, + }, + subscriptionCreditsBox: { + backgroundColor: 'rgba(254, 184, 64, 0.1)', + borderRadius: 12, + padding: 16, + alignItems: 'center', + }, + creditsRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + subscriptionCreditsValue: { + fontSize: 28, + fontWeight: '700', + color: screenPalette.accent, + fontVariant: ['tabular-nums'], + }, + creditsLabel: { + fontSize: 13, + color: screenPalette.secondaryText, + fontWeight: '500', + }, + popularBadge: { + backgroundColor: screenPalette.button, + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 12, + }, + popularBadgeText: { + fontSize: 12, + fontWeight: '700', + color: screenPalette.buttonText, + }, + currentBadge: { + marginTop: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: 'rgba(34, 197, 94, 0.15)', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + alignSelf: 'flex-start', + }, + currentBadgeText: { + fontSize: 13, + fontWeight: '600', + color: '#22c55e', + }, + canceledBadge: { + marginTop: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: 'rgba(251, 146, 60, 0.15)', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + alignSelf: 'flex-start', + }, + canceledBadgeText: { + fontSize: 13, + fontWeight: '600', + color: '#fb923c', + }, + customAmountContainer: { + marginTop: 24, + backgroundColor: screenPalette.surfaceRaised, + borderRadius: 22, + paddingVertical: 20, + paddingHorizontal: 24, + borderWidth: 2, + borderColor: 'transparent', + }, + customAmountContainerActive: { + borderColor: screenPalette.accent, + backgroundColor: 'rgba(254, 184, 64, 0.05)', + }, + customAmountInput: { + backgroundColor: screenPalette.surface, + borderRadius: 12, + paddingHorizontal: 16, + paddingVertical: 14, + fontSize: 16, + color: screenPalette.primaryText, + borderWidth: 1, + borderColor: screenPalette.divider, + }, + bottomBar: { + paddingHorizontal: 24, + }, + primaryButton: { + height: 54, + borderRadius: 27, + backgroundColor: screenPalette.button, + alignItems: 'center', + justifyContent: 'center', + }, + primaryButtonDisabled: { + opacity: 0.5, + }, + primaryButtonLabel: { + fontSize: 16, + fontWeight: '700', + color: screenPalette.buttonText, + letterSpacing: 0.3, + }, +}); diff --git a/app/index.tsx b/app/index.tsx new file mode 100644 index 0000000..7bc02fe --- /dev/null +++ b/app/index.tsx @@ -0,0 +1,5 @@ +import { Redirect } from 'expo-router'; + +export default function Index() { + return ; +} diff --git a/app/modal.tsx b/app/modal.tsx new file mode 100644 index 0000000..6dfbc1a --- /dev/null +++ b/app/modal.tsx @@ -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 ( + + This is a modal + + Go to home screen + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + padding: 20, + }, + link: { + marginTop: 15, + paddingVertical: 15, + }, +}); diff --git a/app/points.tsx b/app/points.tsx new file mode 100644 index 0000000..3f40b03 --- /dev/null +++ b/app/points.tsx @@ -0,0 +1,297 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { useRouter } from 'expo-router'; +import React, { useCallback, useMemo, useState } from 'react'; +import type { ListRenderItem } from 'react-native'; +import { + ActivityIndicator, + FlatList, + RefreshControl, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useBalance } from '@/hooks/use-balance'; +import { useTransactions } from '@/hooks/use-transactions'; +import type { Transaction, TransactionKind } from '@/lib/api/transactions'; + +type FilterKey = 'all' | TransactionKind; + +const screenPalette = { + background: '#080808', + surface: '#101010', + surfaceActive: '#1C1C1C', + surfaceOutline: '#1D1D1D', + divider: '#161616', + primaryText: '#F5F5F5', + secondaryText: '#6C6C6C', + accent: '#FEB840', + accentHalo: 'rgba(254, 184, 64, 0.22)', + negative: '#8F8F8F', +}; + +const FILTERS: { key: FilterKey; label: string }[] = [ + { key: 'all', label: 'All records' }, + { key: 'consumed', label: 'Consumed' }, + { key: 'obtained', label: 'Obtained' }, +]; + +export default function PointsDetailsScreen() { + const insets = useSafeAreaInsets(); + const router = useRouter(); + const [activeFilter, setActiveFilter] = useState('all'); + + const { balance, isLoading: isBalanceLoading, refresh: refreshBalance } = useBalance(); + const { + transactions, + isLoading: isTransactionsLoading, + refresh: refreshTransactions, + } = useTransactions(); + + const [refreshing, setRefreshing] = useState(false); + + const onRefresh = useCallback(async () => { + setRefreshing(true); + await Promise.all([refreshBalance(), refreshTransactions()]); + setRefreshing(false); + }, [refreshBalance, refreshTransactions]); + + const filteredEntries = useMemo(() => { + if (activeFilter === 'all') { + return transactions; + } + + return transactions.filter(entry => entry.kind === activeFilter); + }, [activeFilter, transactions]); + + const listContentStyle = useMemo( + () => ({ + paddingBottom: Math.max(insets.bottom + 36, 72), + paddingTop: 4, + }), + [insets.bottom], + ); + + const keyExtractor = useCallback((entry: Transaction) => entry.id, []); + + const renderSeparator = useCallback(() => , []); + + const renderLedgerEntry: ListRenderItem = useCallback(({ item }) => { + const amount = Math.abs(item.amount); + const isPositive = item.amount > 0; + const displayDate = new Date(item.happenedAt).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); + + return ( + + + {item.title} + {displayDate} + + + {isPositive ? `+ ${amount}` : `- ${amount}`} + + + ); + }, []); + + const isLoading = isBalanceLoading || isTransactionsLoading; + + return ( + + + + {isLoading && !refreshing ? ( + + + + ) : ( + <> + + + + + {balance.remainingTokenBalance} + + + + {FILTERS.map(filter => { + const isActive = filter.key === activeFilter; + + return ( + setActiveFilter(filter.key)} + accessibilityRole="button" + > + + {filter.label} + + + ); + })} + + + + } + /> + + )} + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: screenPalette.background, + paddingHorizontal: 24, + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 48, + }, + iconButton: { + width: 40, + height: 40, + borderRadius: 20, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 17, + lineHeight: 24, + fontWeight: '600', + color: screenPalette.primaryText, + letterSpacing: 0.2, + }, + balanceCluster: { + alignItems: 'center', + marginTop: 32, + marginBottom: 28, + gap: 10, + }, + energyOrb: { + width: 60, + height: 60, + borderRadius: 30, + backgroundColor: screenPalette.accentHalo, + alignItems: 'center', + justifyContent: 'center', + shadowColor: screenPalette.accent, + shadowOpacity: 0.3, + shadowRadius: 16, + shadowOffset: { width: 0, height: 8 }, + elevation: 8, + }, + balanceValue: { + fontSize: 46, + lineHeight: 52, + fontWeight: '700', + color: screenPalette.primaryText, + letterSpacing: 0.5, + fontVariant: ['tabular-nums'], + }, + segmentRail: { + flexDirection: 'row', + backgroundColor: screenPalette.surface, + padding: 4, + borderRadius: 28, + borderWidth: StyleSheet.hairlineWidth, + borderColor: screenPalette.surfaceOutline, + gap: 4, + }, + segmentChip: { + flex: 1, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 11, + }, + segmentChipActive: { + backgroundColor: screenPalette.surfaceActive, + }, + segmentLabel: { + fontSize: 14, + lineHeight: 20, + color: screenPalette.secondaryText, + fontWeight: '500', + letterSpacing: 0.2, + }, + segmentLabelActive: { + color: screenPalette.primaryText, + }, + list: { + flex: 1, + marginTop: 28, + }, + listDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: screenPalette.divider, + marginVertical: 0, + }, + recordRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + }, + recordTitle: { + fontSize: 15, + lineHeight: 22, + fontWeight: '600', + color: screenPalette.primaryText, + }, + recordTimestamp: { + marginTop: 6, + fontSize: 12, + lineHeight: 18, + color: screenPalette.secondaryText, + letterSpacing: 0.2, + }, + recordValue: { + fontSize: 16, + lineHeight: 22, + fontWeight: '600', + fontVariant: ['tabular-nums'], + }, + valuePositive: { + color: screenPalette.accent, + }, + valueNegative: { + color: screenPalette.negative, + }, +}); diff --git a/app/result.tsx b/app/result.tsx new file mode 100644 index 0000000..8c20b6c --- /dev/null +++ b/app/result.tsx @@ -0,0 +1,676 @@ +import { ThemedView } from '@/components/themed-view'; +import { getTemplateGeneration } from '@/lib/api/template-runs'; +import * as Clipboard from 'expo-clipboard'; +import { documentDirectory, downloadAsync } from 'expo-file-system/legacy'; +import * as MediaLibrary from 'expo-media-library'; +import { router, useLocalSearchParams } from 'expo-router'; +import { VideoView, useVideoPlayer } from 'expo-video'; +import { + ArrowLeft, + Copy, + Download, + Maximize2, + Plus, + X, +} from 'lucide-react'; +import React, { useEffect, useState } from 'react'; +import { + ActivityIndicator, + Alert, + Dimensions, + Image, Modal, Platform, ScrollView, + StyleSheet, Text, + TouchableOpacity, + View +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +// ResizeMode 兼容映射 - 移除 'stretch',expo-video 不支持 +const ResizeMode = { + CONTAIN: 'contain' as const, + COVER: 'cover' as const, + STRETCH: 'fill' as const, // 使用 'fill' 替代 'stretch' +}; + +const { width: screenWidth, height: screenHeight } = Dimensions.get('window'); +const HERO_HEIGHT = screenWidth * 0.58; +const VIDEO_HEIGHT = screenHeight * 0.6; + +// 原生视频播放组件 +const NativeVideoPlayer: React.FC<{ url: string; style?: any }> = ({ url, style }) => { + const player = useVideoPlayer(url, player => { + player.loop = false; + }); + + return ( + + ); +}; + +interface ResultWithTemplate { + id: string; + userId: string; + templateId: string; + type: 'UNKNOWN' | 'TEXT' | 'IMAGE' | 'VIDEO'; + resultUrl: string[]; + status: 'pending' | 'running' | 'completed' | 'failed'; + creditsCost?: number; + creditsTransactionId?: string; + createdAt: string; + updatedAt: string; + template?: { + id: string; + title: string; + titleEn: string; + }; +} + +/** + * 根据文件 URL 后缀名判断媒体类型 + */ +function getMediaType(url: string): 'image' | 'video' | 'unknown' { + const extension = url.split('.').pop()?.toLowerCase() || ''; + + const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']; + const videoExts = ['mp4', 'webm', 'avi', 'mov', 'mkv', 'flv', 'm4v']; + + if (imageExts.includes(extension)) return 'image'; + if (videoExts.includes(extension)) return 'video'; + return 'unknown'; +} + +/** + * 安全获取媒体类型,过滤掉 unknown + */ +function getSafeMediaType(url: string): 'image' | 'video' { + const type = getMediaType(url); + return type === 'unknown' ? 'image' : type; +} + +export default function ResultPage() { + const { generationId } = useLocalSearchParams<{ generationId: string }>(); + const insets = useSafeAreaInsets(); + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [fullscreenVisible, setFullscreenVisible] = useState(false); + const [fullscreenContent, setFullscreenContent] = useState<{ + type: 'image' | 'video'; + url: string; + } | null>(null); + const [saving, setSaving] = useState(false); + const [currentMediaIndex, setCurrentMediaIndex] = useState(0); + + useEffect(() => { + if (generationId) { + loadResult(); + } + }, [generationId]); + + const loadResult = async () => { + if (!generationId) return; + + try { + setLoading(true); + const response = await getTemplateGeneration(generationId); + + if (response?.success && response.data) { + setResult(response.data as any); + } else { + Alert.alert('错误', '无法加载结果详情'); + } + } catch (error) { + console.error('Failed to load result:', error); + Alert.alert('错误', '加载结果失败,请稍后重试'); + } finally { + setLoading(false); + } + }; + + const handleRerun = () => { + router.push(`/templates/${result?.templateId}/form`); + }; + + const handleSaveToGallery = async () => { + if (!result || result.type === 'TEXT') { + return; + } + + try { + setSaving(true); + + // Web 平台处理 + if (Platform.OS === 'web') { + for (const url of result.resultUrl) { + try { + const response = await fetch(url); + const blob = await response.blob(); + + // 类型安全的处理方式 + if (typeof window !== 'undefined' && window.URL && window.URL.createObjectURL) { + const blobUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = blobUrl; + link.download = url.split('/').pop() || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(blobUrl); + } + + // 添加延迟,避免浏览器阻止多个下载 + await new Promise(resolve => setTimeout(resolve, 300)); + } catch (error) { + console.error('Web download failed:', url, error); + } + } + + const count = result.resultUrl.length; + const mediaType = result.type === 'IMAGE' ? '图片' : '视频'; + Alert.alert( + '下载开始', + count > 1 ? `正在下载${count}个${mediaType}` : `正在下载${mediaType}` + ); + return; + } + + // 原生平台处理 + const { status } = await MediaLibrary.requestPermissionsAsync(); + + if (status !== 'granted') { + Alert.alert('权限请求', '需要媒体库权限才能保存文件'); + return; + } + + // 批量下载所有媒体文件 + let successCount = 0; + for (const url of result.resultUrl) { + try { + // 生成临时文件名 + const filename = url.split('/').pop() || `download_${Date.now()}`; + + if (!documentDirectory) { + console.error('Document directory not available'); + continue; + } + + const fileUri = `${documentDirectory}${filename}`; + + // 下载文件到本地 + const downloadResult = await downloadAsync(url, fileUri); + + if (downloadResult.status === 200) { + // 保存到相册 + const asset = await MediaLibrary.createAssetAsync(downloadResult.uri); + successCount++; + } else { + console.error('Download failed with status:', downloadResult.status); + } + } catch (error) { + console.error('Failed to download and save:', url, error); + } + } + + const count = result.resultUrl.length; + const mediaType = result.type === 'IMAGE' ? '图片' : '视频'; + + if (successCount === count) { + Alert.alert( + '保存成功', + count > 1 ? `${count}个${mediaType}已保存到相册` : `${mediaType}已保存到相册` + ); + } else if (successCount > 0) { + Alert.alert( + '部分保存成功', + `${successCount}/${count}个${mediaType}已保存到相册` + ); + } else { + Alert.alert('保存失败', '无法保存文件,请检查网络连接'); + } + } catch (error) { + console.error('Save to gallery error:', error); + Alert.alert('保存失败', '无法保存文件,请稍后重试'); + } finally { + setSaving(false); + } + }; + + const handleCopyText = async () => { + if (!result || result.type !== 'TEXT') return; + + try { + await Clipboard.setStringAsync(result.resultUrl[0]); + Alert.alert('复制成功', '文本已复制到剪贴板'); + } catch (error) { + Alert.alert('复制失败', '无法复制文本'); + } + }; + + const openFullscreen = (type: 'image' | 'video', url: string) => { + setFullscreenContent({ type, url }); + setFullscreenVisible(true); + }; + + const handleDownloadPress = () => { + if (!result) { + return; + } + + if (result.type === 'TEXT') { + handleCopyText(); + return; + } + + handleSaveToGallery(); + }; + + if (loading) { + return ( + + + + 加载中... + + + ); + } + + if (!result) { + return ( + + + 未找到结果 + + + ); + } + + const isTextResult = result.type === 'TEXT'; + const mediaUrls = result.resultUrl || []; + const hasMultipleMedia = mediaUrls.length > 1; + + const renderMediaItem = (url: string, index: number) => { + const mediaType = getMediaType(url); + + return ( + + {mediaType === 'video' ? ( + Platform.OS === 'web' ? ( + + ); + }; + + const handleScroll = (event: any) => { + const offsetX = event.nativeEvent.contentOffset.x; + const index = Math.round(offsetX / screenWidth); + setCurrentMediaIndex(index); + }; + + return ( + + + + + + + router.back()} + activeOpacity={0.7} + style={styles.backButton} + > + + + + + + {result.template?.title || '生成结果'} + + + {result.template?.titleEn || ''} + + + + {isTextResult ? ( + + + {mediaUrls[0]} + + + ) : ( + + + {mediaUrls.map((url, index) => renderMediaItem(url, index))} + + + {hasMultipleMedia && ( + + {mediaUrls.map((_, index) => ( + + ))} + + )} + + + openFullscreen( + getSafeMediaType(mediaUrls[currentMediaIndex]), + mediaUrls[currentMediaIndex] + ) + } + > + + + + )} + + + setFullscreenVisible(false)} + > + + setFullscreenVisible(false)} + > + + + + {fullscreenContent && ( + + {fullscreenContent.type === 'image' ? ( + + ) : ( + <> + {Platform.OS === 'web' ? ( + + )} + + + + + + + Regenerate + + + + {isTextResult ? ( + + ) : ( + + )} + + {isTextResult ? 'Copy Text' : 'Download'} + + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: '#09090B', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#09090B', + }, + loadingText: { + color: '#F5F5F7', + fontSize: 16, + }, + safeGuard: { + backgroundColor: 'transparent', + }, + scrollContent: { + paddingBottom: 160, + }, + contentPadding: { + paddingHorizontal: 24, + }, + headerRow: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'center', + marginBottom: 24, + }, + backButton: { + width: 44, + height: 44, + borderRadius: 22, + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.12)', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(32, 32, 36, 0.6)', + }, + title: { + color: '#F5F5F7', + fontSize: 22, + fontWeight: '700', + lineHeight: 30, + marginBottom: 12, + letterSpacing: 0.4, + }, + subtitle: { + color: '#9A9AA2', + fontSize: 14, + lineHeight: 20, + }, + heroCard: { + marginTop: 32, + marginHorizontal: 24, + borderRadius: 32, + backgroundColor: '#131317', + padding: 24, + position: 'relative', + overflow: 'hidden', + }, + mediaContainer: { + marginTop: 32, + position: 'relative', + backgroundColor: '#09090B', + }, + carouselContent: { + gap: 0, + }, + pagination: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginTop: 20, + marginBottom: 12, + gap: 8, + }, + paginationDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: 'rgba(255, 255, 255, 0.3)', + }, + paginationDotActive: { + backgroundColor: '#D9FF3F', + width: 24, + }, + expandButton: { + position: 'absolute', + right: 16, + top: 16, + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + }, + textScroll: { + maxHeight: HERO_HEIGHT, + }, + generatedText: { + color: '#F5F5F7', + fontSize: 16, + lineHeight: 24, + }, + fullscreenContainer: { + flex: 1, + backgroundColor: '#000000', + justifyContent: 'center', + alignItems: 'center', + }, + closeButton: { + position: 'absolute', + top: 50, + right: 20, + zIndex: 1, + width: 50, + height: 50, + justifyContent: 'center', + alignItems: 'center', + }, + fullscreenMedia: { + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + }, + fullscreenMediaContent: { + width: '100%', + height: '100%', + }, + bottomBar: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 24, + paddingTop: 16, + backgroundColor: 'rgba(6, 6, 7, 0.94)', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + secondaryAction: { + flex: 1, + height: 60, + borderRadius: 30, + backgroundColor: '#151519', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.08)', + marginRight: 12, + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row', + }, + secondaryText: { + color: '#F5F5F7', + fontSize: 16, + fontWeight: '600', + marginLeft: 8, + }, + primaryAction: { + flex: 1, + height: 60, + borderRadius: 30, + backgroundColor: '#D9FF3F', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'row', + }, + primaryText: { + color: '#1C1C1E', + fontSize: 16, + fontWeight: '700', + marginLeft: 10, + }, +}); diff --git a/app/settings/about.tsx b/app/settings/about.tsx new file mode 100644 index 0000000..dfe3537 --- /dev/null +++ b/app/settings/about.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleSheet, ScrollView } from 'react-native'; + +import { ThemedView } from '@/components/themed-view'; +import { ThemedText } from '@/components/themed-text'; +import { useThemeColor } from '@/hooks/use-theme-color'; + +export default function AboutScreen() { + const backgroundColor = useThemeColor({}, 'background'); + + return ( + + + 关于我们 + 此页面正在开发中... + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, +}); \ No newline at end of file diff --git a/app/settings/account.tsx b/app/settings/account.tsx new file mode 100644 index 0000000..5caa067 --- /dev/null +++ b/app/settings/account.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleSheet, ScrollView } from 'react-native'; + +import { ThemedView } from '@/components/themed-view'; +import { ThemedText } from '@/components/themed-text'; +import { useThemeColor } from '@/hooks/use-theme-color'; + +export default function AccountSettingsScreen() { + const backgroundColor = useThemeColor({}, 'background'); + + return ( + + + 账户设置 + 此页面正在开发中... + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, +}); \ No newline at end of file diff --git a/app/settings/index.tsx b/app/settings/index.tsx new file mode 100644 index 0000000..cd3b654 --- /dev/null +++ b/app/settings/index.tsx @@ -0,0 +1,211 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { StatusBar } from 'expo-status-bar'; +import { type Href, useRouter } from 'expo-router'; +import React, { useState } from 'react'; +import { + ActivityIndicator, + Alert, + Pressable, + StyleSheet, + Text, + View, +} from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { authClient } from '@/lib/auth/client'; + +type Palette = { + canvas: string; + card: string; + textPrimary: string; + textSecondary: string; + icon: string; + stroke: string; + buttonBackground: string; + buttonText: string; +}; + +type SettingDestination = { + key: string; + label: string; + route?: Href; +}; + +const palettes: Record<'dark' | 'light', Palette> = { + dark: { + canvas: '#050505', + card: '#141417', + textPrimary: '#F5F5F7', + textSecondary: '#8E8E93', + icon: '#F5F5F7', + stroke: '#232327', + buttonBackground: '#FFFFFF', + buttonText: '#050505', + }, + light: { + canvas: '#F7F7F9', + card: '#FFFFFF', + textPrimary: '#131318', + textSecondary: '#5C5C67', + icon: '#131318', + stroke: '#E4E4EA', + buttonBackground: '#131318', + buttonText: '#FFFFFF', + }, +}; + +const destinations: SettingDestination[] = [ + { key: 'change-password', label: 'Change Password', route: '/settings/account' }, + { key: 'terms', label: 'Terms and Policies' }, + { key: 'support', label: 'Feedback and Support' }, +]; + +export default function SettingsHomeScreen() { + const router = useRouter(); + const insets = useSafeAreaInsets(); + const palette = palettes['dark']; + const [isLoggingOut, setIsLoggingOut] = useState(false); + + const topInset = Math.max(insets.top, 16); + const bottomInset = Math.max(insets.bottom + 16, 40); + + const handleNavigate = (destination: SettingDestination) => { + if (!destination.route) { + return; + } + router.push(destination.route); + }; + + const handleLogOut = async () => { + if (isLoggingOut) { + return; + } + + setIsLoggingOut(true); + + try { + await authClient.signOut(); + } catch (error) { + const message = + error instanceof Error ? error.message : 'Please try again in a moment.'; + Alert.alert('Unable to log out', message); + } finally { + setIsLoggingOut(false); + } + }; + + return ( + + + + + router.back()} + > + + + set up + + + + + + {destinations.map((destination, index) => { + const isLast = index === destinations.length - 1; + return ( + handleNavigate(destination)} + style={({ pressed }) => [ + styles.optionRow, + !isLast && { borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: palette.stroke }, + pressed && destination.route && { opacity: 0.6 }, + ]} + > + + {destination.label} + + + + ); + })} + + + + + [ + styles.logoutButton, + { backgroundColor: palette.buttonBackground }, + pressed && { opacity: 0.9 }, + ]} + > + {isLoggingOut ? ( + + ) : ( + Log out + )} + + + + ); +} + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + headerRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 32, + }, + headerControl: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center', + }, + headerTitle: { + fontSize: 18, + fontWeight: '600', + letterSpacing: 0.4, + }, + content: { + flex: 1, + }, + card: { + borderRadius: 18, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + minHeight: 64, + }, + optionLabel: { + fontSize: 16, + fontWeight: '500', + }, + footer: { + paddingTop: 32, + }, + logoutButton: { + borderRadius: 26, + height: 56, + alignItems: 'center', + justifyContent: 'center', + }, + logoutLabel: { + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/app/settings/notification.tsx b/app/settings/notification.tsx new file mode 100644 index 0000000..8555364 --- /dev/null +++ b/app/settings/notification.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { StyleSheet, ScrollView } from 'react-native'; + +import { ThemedView } from '@/components/themed-view'; +import { ThemedText } from '@/components/themed-text'; +import { useThemeColor } from '@/hooks/use-theme-color'; + +export default function NotificationSettingsScreen() { + const backgroundColor = useThemeColor({}, 'background'); + + return ( + + + 通知设置 + 此页面正在开发中... + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, +}); \ No newline at end of file diff --git a/app/template/[id]/run.tsx b/app/template/[id]/run.tsx new file mode 100644 index 0000000..147de2a --- /dev/null +++ b/app/template/[id]/run.tsx @@ -0,0 +1,597 @@ +import Ionicons from '@expo/vector-icons/Ionicons'; +import { ResultDisplay } from '@/components/template-run/result-display'; +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { useTemplateRun } from '@/hooks/use-template-run'; +import { getTemplateById } from '@/lib/api/templates'; +import { subscription } from '@/lib/auth/client'; +import { Template } from '@/lib/types/template'; +import { RunTemplateData } from '@/lib/types/template-run'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { Stack, useGlobalSearchParams, useLocalSearchParams, useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, BackHandler, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +type RunStep = 'progress' | 'result'; + +function transformFormData(formData: RunTemplateData): RunTemplateData { + const transformed: RunTemplateData = {}; + + Object.entries(formData).forEach(([fieldName, value]) => { + if (fieldName.startsWith('image_')) { + transformed[fieldName] = { + url: value, + type: 'image', + }; + } else if (fieldName.startsWith('text_')) { + transformed[fieldName] = { + content: value, + type: 'text', + }; + } else if (fieldName.startsWith('video_')) { + transformed[fieldName] = { + url: value, + type: 'video', + }; + } else if (fieldName.startsWith('color_')) { + transformed[fieldName] = { + value, + type: 'color', + }; + } else { + transformed[fieldName] = value; + } + }); + + return transformed; +} + +export default function TemplateRunScreen() { + const { id } = useLocalSearchParams<{ id: string }>(); + const router = useRouter(); + const globalParams = useGlobalSearchParams<{ formData?: string }>(); + const insets = useSafeAreaInsets(); + + const [template, setTemplate] = useState