diff --git a/.dockerignore b/.dockerignore index b6d3e3e8..6c26c8db 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,14 +8,14 @@ **/*.pyo **/*.db **/.env -frontend -ui-showcase +apps/frontend +apps/ui-showcase node_modules -backend/runtime_data -backend/output -backend/workflow.log -example_uploads +apps/backend/runtime_data +apps/backend/output +apps/backend/workflow.log +examples outputs -assets -rules +docs/assets +docs/rules /error_correction diff --git a/.gitattributes b/.gitattributes index 02a36cfc..6183ebc5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,2 @@ -backend/models/weight/*.pth filter=lfs diff=lfs merge=lfs -text -backend/text_eraser_model/weight/*.pth filter=lfs diff=lfs merge=lfs -text +apps/backend/models/weight/*.pth filter=lfs diff=lfs merge=lfs -text +apps/backend/text_eraser_model/weight/*.pth filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 92b34622..cef42a16 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -3,7 +3,7 @@ name: Backend CI on: pull_request: paths: - - "backend/**" + - "apps/backend/**" - "requirements.txt" - "pyproject.toml" - "Dockerfile" @@ -15,7 +15,7 @@ on: branches: - main paths: - - "backend/**" + - "apps/backend/**" - "requirements.txt" - "pyproject.toml" - "Dockerfile" @@ -52,4 +52,4 @@ jobs: python -m pip install -r requirements.txt - name: Run backend tests - run: python -m pytest backend/tests -v + run: python -m pytest apps/backend/tests -v diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml index 3538bcab..6a54c6b8 100644 --- a/.github/workflows/frontend-ci.yml +++ b/.github/workflows/frontend-ci.yml @@ -3,14 +3,14 @@ name: Frontend CI on: pull_request: paths: - - "frontend/**" + - "apps/frontend/**" - ".github/workflows/frontend-ci.yml" - ".github/workflows/release-ci-cd.yml" push: branches: - main paths: - - "frontend/**" + - "apps/frontend/**" - ".github/workflows/frontend-ci.yml" - ".github/workflows/release-ci-cd.yml" @@ -34,16 +34,16 @@ jobs: with: node-version: "22" cache: npm - cache-dependency-path: frontend/package-lock.json + cache-dependency-path: apps/frontend/package-lock.json - name: Install dependencies - working-directory: frontend + working-directory: apps/frontend run: npm ci - name: Run tests - working-directory: frontend + working-directory: apps/frontend run: npm test - name: Build production assets - working-directory: frontend + working-directory: apps/frontend run: npm run build diff --git a/.github/workflows/mobile-ci.yml b/.github/workflows/mobile-ci.yml new file mode 100644 index 00000000..e86c9023 --- /dev/null +++ b/.github/workflows/mobile-ci.yml @@ -0,0 +1,55 @@ +name: Mobile CI + +on: + pull_request: + paths: + - "apps/mobile/**" + - ".github/workflows/mobile-ci.yml" + push: + branches: + - main + paths: + - "apps/mobile/**" + - ".github/workflows/mobile-ci.yml" + +permissions: + contents: read + +concurrency: + group: mobile-ci-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: apps/mobile + +jobs: + test: + name: Analyze, test and build mobile app + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.44.6" + channel: stable + cache: true + cache-key: flutter-${{ runner.os }}-${{ hashFiles('apps/mobile/pubspec.lock') }} + + - name: Install dependencies + run: flutter pub get + + - name: Check formatting + run: dart format --output=none --set-exit-if-changed lib test + + - name: Analyze Dart code + run: flutter analyze --no-fatal-infos --fatal-warnings + + - name: Run Flutter tests + run: flutter test + + - name: Build Android debug APK + run: flutter build apk --debug diff --git a/.github/workflows/release-ci-cd.yml b/.github/workflows/release-ci-cd.yml index 718048a3..c4db2b8d 100644 --- a/.github/workflows/release-ci-cd.yml +++ b/.github/workflows/release-ci-cd.yml @@ -41,14 +41,14 @@ jobs: python -m pip install -r requirements.txt - name: Run backend tests - run: python -m pytest backend/tests -v + run: python -m pytest apps/backend/tests -v - name: Validate eraser model shell: bash run: | set -euo pipefail - test -s backend/text_eraser_model/weight/best.pth - test "$(wc -c < backend/text_eraser_model/weight/best.pth)" -gt 100000000 + test -s apps/backend/text_eraser_model/weight/best.pth + test "$(wc -c < apps/backend/text_eraser_model/weight/best.pth)" -gt 100000000 - name: Build production image run: docker build --tag error-correction-backend:${{ github.sha }} . @@ -70,29 +70,29 @@ jobs: with: node-version: "22" cache: npm - cache-dependency-path: frontend/package-lock.json + cache-dependency-path: apps/frontend/package-lock.json - name: Install dependencies - working-directory: frontend + working-directory: apps/frontend run: npm ci - name: Run tests - working-directory: frontend + working-directory: apps/frontend run: npm test - name: Build production assets - working-directory: frontend + working-directory: apps/frontend run: npm run build - name: Validate and package build shell: bash run: | set -euo pipefail - test -f frontend/dist/app.html - test -d frontend/dist/assets - find frontend/dist/assets -type f -print -quit | grep -q . - cp frontend/dist/app.html frontend/dist/index.html - tar -C frontend/dist -czf error-correction-web.tar.gz . + test -f apps/frontend/dist/app.html + test -d apps/frontend/dist/assets + find apps/frontend/dist/assets -type f -print -quit | grep -q . + cp apps/frontend/dist/app.html apps/frontend/dist/index.html + tar -C apps/frontend/dist -czf error-correction-web.tar.gz . - name: Upload production assets uses: actions/upload-artifact@v7 @@ -244,7 +244,7 @@ jobs: git lfs install --local git lfs pull origin test -f docker-compose.yml - test "$(wc -c < backend/text_eraser_model/weight/best.pth)" -gt 100000000 + test "$(wc -c < apps/backend/text_eraser_model/weight/best.pth)" -gt 100000000 docker compose config --quiet docker compose up -d --build --remove-orphans diff --git a/.gitignore b/.gitignore index a867eff7..aee3132b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,30 +17,32 @@ uv.lock # Node 依赖与构建产物 node_modules/ -ui-showcase/dist/ -ui-showcase/package-lock.json +apps/ui-showcase/dist/ +apps/ui-showcase/package-lock.json # 前端构建产物 -frontend/dist/ -frontend/uploads/ -frontend/output +apps/frontend/dist/ +apps/frontend/uploads/ +apps/frontend/output presentation-vue/ # 后端运行产物 -backend/runtime_data/ -backend/output/ -backend/*.db -backend/db/*.db +apps/backend/runtime_data/ +apps/backend/output/ +apps/backend/*.db +apps/backend/db/*.db output/ +outputs/ __pycache__/ -backend/static/vue/assets +.pytest_cache/ +apps/backend/static/vue/assets # 模型权重文件 -backend/models/weight/ -backend/text_eraser_model/weight/ +apps/backend/models/weight/ +apps/backend/text_eraser_model/weight/ # 示例上传文件 -example_uploads/notes/ +examples/notes/ # 日志 workflow.log @@ -49,7 +51,7 @@ dataset/ # 环境变量 .env -backend/.env +apps/backend/.env *.pem PR.md TODO.md diff --git a/AGENTS.md b/AGENTS.md index eb110cb5..01da40a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,31 +1,31 @@ # AGENTS -本文件用于说明项目规则目录 `rules/` 的结构,方便在开始开发或修改代码前快速定位需要查看的规范。 +本文件用于说明项目规则目录 `docs/rules/` 的结构,方便在开始开发或修改代码前快速定位需要查看的规范。 ## 规则目录 -### `rules/development` +### `docs/rules/development` -- `development/架构规则.md`:项目架构、核心入口和本地文档使用原则。 -- `development/前端规则.md`:Vue、API 调用、主题、组件和设计规则。 -- `development/后端规则.md`:模块副作用、数据库、线程安全、Agent、OCR 和文件路径规则。 -- `development/测试规则.md`:测试组织、断言原则和外部依赖保护。 -- `development/代码注释规则.md`:注释意图、前端注释位置和推荐写法。 +- `docs/rules/development/架构规则.md`:项目架构、核心入口和本地文档使用原则。 +- `docs/rules/development/前端规则.md`:Vue、API 调用、主题、组件和设计规则。 +- `docs/rules/development/后端规则.md`:模块副作用、数据库、线程安全、Agent、OCR 和文件路径规则。 +- `docs/rules/development/测试规则.md`:测试组织、断言原则和外部依赖保护。 +- `docs/rules/development/代码注释规则.md`:注释意图、前端注释位置和推荐写法。 -### `rules/workflow` +### `docs/rules/workflow` -- `workflow/提交规则.md`:commit message 格式和强制 checklist 要求。 -- `workflow/常用命令索引.md`:本地命令类规则入口。 -- `workflow/环境配置规则.md`:依赖安装、环境变量和 Provider 配置。 -- `workflow/开发启动规则.md`:前后端开发服务、conda 环境和端口检查。 -- `workflow/测试构建规则.md`:后端测试、前端测试和生产构建。 -- `workflow/变更同步规则.md`:跨后端、前端、文档、配置和测试的同步要求。 -- `workflow/协作提交流程规则.md`:fork、个人分支、PR 和 review 流程要求。 +- `docs/rules/workflow/提交规则.md`:commit message 格式和强制 checklist 要求。 +- `docs/rules/workflow/常用命令索引.md`:本地命令类规则入口。 +- `docs/rules/workflow/环境配置规则.md`:依赖安装、环境变量和 Provider 配置。 +- `docs/rules/workflow/开发启动规则.md`:前后端开发服务、conda 环境和端口检查。 +- `docs/rules/workflow/测试构建规则.md`:后端测试、前端测试和生产构建。 +- `docs/rules/workflow/变更同步规则.md`:跨后端、前端、文档、配置和测试的同步要求。 +- `docs/rules/workflow/协作提交流程规则.md`:fork、个人分支、PR 和 review 流程要求。 ## 使用建议 - 开始任务前,先查看本文件。 -- 涉及前端改动时,优先查看 `development/前端规则.md`。 -- 涉及后端改动时,优先查看 `development/后端规则.md`。 -- 涉及测试、构建、提交流程时,查看 `development/测试规则.md` 和 `workflow/` 下对应文件。 +- 涉及前端改动时,优先查看 `docs/rules/development/前端规则.md`。 +- 涉及后端改动时,优先查看 `docs/rules/development/后端规则.md`。 +- 涉及测试、构建、提交流程时,查看 `docs/rules/development/测试规则.md` 和 `docs/rules/workflow/` 下对应文件。 - 修改规则时,优先更新对应规则文件,而不是只改本文件。 diff --git a/Dockerfile b/Dockerfile index 4d245877..522a145d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ - PYTHONPATH=/app/backend + PYTHONPATH=/app/apps/backend WORKDIR /app @@ -28,7 +28,7 @@ RUN sed \ --index-url "${TORCH_INDEX_URL}" \ && rm -f /tmp/requirements-runtime.txt -COPY backend ./backend +COPY apps/backend ./apps/backend RUN groupadd --gid 10001 app \ && useradd --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app \ @@ -36,7 +36,7 @@ RUN groupadd --gid 10001 app \ && chown -R app:app /app USER app -WORKDIR /app/backend +WORKDIR /app/apps/backend EXPOSE 8000 diff --git a/README.md b/README.md index 32547780..b0584331 100644 --- a/README.md +++ b/README.md @@ -6,17 +6,17 @@ | 首页 | 错题工作台 | |------|------------| -| ![登录过程](assets/readme/登录过程.gif) | ![错题工作台](assets/readme/错题工作台.png) | +| ![登录过程](docs/assets/readme/登录过程.gif) | ![错题工作台](docs/assets/readme/错题工作台.png) | | 笔记整理 | 主题切换 | |----------|----------| -| ![笔记分割过程](assets/readme/笔记分割过程.gif) | ![主题切换](assets/readme/主题切换.gif) | +| ![笔记分割过程](docs/assets/readme/笔记分割过程.gif) | ![主题切换](docs/assets/readme/主题切换.gif) | ## 功能演示 | AI 找题 | Provider 配置 | |---------|---------------| -| ![AI 找题](assets/readme/AI找题.gif) | ![Provider 配置](assets/readme/Provider配置.gif) | +| ![AI 找题](docs/assets/readme/AI找题.gif) | ![Provider 配置](docs/assets/readme/Provider配置.gif) | ## 功能 @@ -36,38 +36,30 @@ ## 项目结构 ``` -├── backend/ # Flask 后端(纯 API 服务) -│ ├── core/ # 核心模块 -│ │ ├── config.py # 集中配置(路径、Settings) -│ │ ├── llm.py # LLM 初始化(多 provider 支持) -│ │ ├── state.py # 全局会话状态(session_files、锁) -│ │ └── mail.py # SMTP 邮件发送 -│ ├── web_app.py # Flask 应用工厂 + Blueprint 注册 -│ ├── routes/ # Flask Blueprint 路由(auth、chat、device、notes、projects 等) -│ ├── src/ # 核心模块(LangGraph workflow、OCR 客户端、工具函数) -│ ├── agents/ # LangChain Agent -│ │ ├── error_correction/ # 题目分割 + OCR 纠错 -│ │ ├── solve/ # 解题 Agent -│ │ ├── teach/ # 教学讲解 Agent -│ │ └── note/ # 笔记整理 Agent -│ ├── db/ # SQLite + SQLAlchemy ORM -│ │ ├── models.py # 数据模型(User、Question、Note、Chat 等) -│ │ ├── crud/ # CRUD 模块化包 -│ │ └── migrate.py # 数据库自动迁移 -│ └── tests/ # 后端测试 -├── frontend/ # Vue 3 + Vite + Tailwind CSS -│ ├── app.html # SPA 入口 -│ └── src/ -│ ├── views/ # 页面级组件(HomeView、WorkspaceView) -│ ├── components/ # 基础组件与业务组件 -│ ├── composables/ # 组合式函数(useAuth、useTheme 等) -│ ├── router/ # Vue Router 路由配置 -│ ├── api/ # 按领域拆分的 API 调用层 -│ ├── utils/ # 工具函数(Markdown 渲染、MathJax、DOMPurify) -│ └── __tests__/ # 前端测试(Vitest) -├── example_uploads/ # 示例测试文件 -├── rules/ # 项目规则、协作流程和开发规范 -├── backend/.env.example # 环境变量模板 +├── apps/ +│ ├── backend/ # Flask 后端(纯 API 服务) +│ │ ├── .env.example # 环境变量模板 +│ │ ├── core/ # 核心模块 +│ │ │ ├── config.py # 集中配置(路径、Settings) +│ │ │ ├── llm.py # LLM 初始化(多 provider 支持) +│ │ │ ├── state.py # 全局会话状态(session_files、锁) +│ │ │ └── mail.py # SMTP 邮件发送 +│ │ ├── web_app.py # Flask 应用工厂 + Blueprint 注册 +│ │ ├── routes/ # Flask Blueprint 路由 +│ │ ├── pipeline/ # LangGraph workflow、OCR 客户端、工具函数 +│ │ ├── agents/ # LangChain Agent +│ │ ├── db/ # SQLite + SQLAlchemy ORM +│ │ └── tests/ # 后端测试 +│ ├── frontend/ # Vue 3 + Vite + Tailwind CSS +│ │ ├── app.html # SPA 入口 +│ │ └── src/ # 页面、组件、API、工具与 Vitest 测试 +│ ├── ui-showcase/ # Base 组件独立展示站 +│ └── mobile/ # Flutter 移动端(Android/iOS 为主要验证平台) +├── docs/ +│ ├── assets/ # README 图片和演示资源 +│ ├── deploy/ # 部署与 CI/CD 文档 +│ └── rules/ # 项目规则、协作流程和开发规范 +├── examples/ # 示例测试文件 └── requirements.txt # Python 依赖 ``` @@ -75,28 +67,28 @@ ### 1. 安装依赖 -需要 Python 3.11+、Node.js 18+。 +需要 Python 3.11+、Node.js 18+。移动端开发还需要 Flutter 3.44.x stable 和 Dart 3.12.x。 ```bash # 后端依赖 pip install -r requirements.txt # 前端依赖 -cd frontend && npm install +cd apps/frontend && npm install ``` ### 2. 配置环境变量 ```bash # 复制模板到项目根目录 -cp backend/.env.example .env +cp apps/backend/.env.example .env ``` 编辑 `.env`,必须配置 `SECRET_KEY`。 LLM API Provider(OpenAI / Anthropic / PaddleOCR)配置已迁移到数据库,启动后在系统设置页面管理。 -SMTP 邮件配置(注册验证码、找回密码)通过 `.env` 的 `APP_SMTP_*` 变量管理,详见 `backend/.env.example`。 +SMTP 邮件配置(注册验证码、找回密码)通过 `.env` 的 `APP_SMTP_*` 变量管理,详见 `apps/backend/.env.example`。 ### 3. 启动 @@ -104,10 +96,10 @@ SMTP 邮件配置(注册验证码、找回密码)通过 `.env` 的 `APP_SMTP ```bash # 终端 1:启动后端 -cd backend && python web_app.py +cd apps/backend && python web_app.py # 终端 2:启动前端开发服务器 -cd frontend && npm run dev +cd apps/frontend && npm run dev ``` 前端开发服务器会自动将 `/api`、`/images`、`/download`、`/erased`、`/uploads` 请求代理到后端 `localhost:5001`。 @@ -116,11 +108,12 @@ cd frontend && npm run dev > **注意**:后端已重构为纯 API 服务器,不提供前端页面。直接访问 `:5001` 只会得到 JSON 404。 -更完整的启动说明见 [rules/workflow/开发启动规则.md](rules/workflow/开发启动规则.md),测试与构建说明见 [rules/workflow/测试构建规则.md](rules/workflow/测试构建规则.md)。 +更完整的启动说明见 [docs/rules/workflow/开发启动规则.md](docs/rules/workflow/开发启动规则.md),测试与构建说明见 [docs/rules/workflow/测试构建规则.md](docs/rules/workflow/测试构建规则.md)。 +移动端启动、BLE 权限和构建说明见 [apps/mobile/README.md](apps/mobile/README.md)。 生产环境采用单仓库 Tag 发布:后端由 Docker Compose 运行,前端发布到 1Panel -静态网站目录。配置步骤见 [deploy/cicd.md](deploy/cicd.md) 和 -[deploy/1panel.md](deploy/1panel.md)。 +静态网站目录。配置步骤见 [docs/deploy/cicd.md](docs/deploy/cicd.md) 和 +[docs/deploy/1panel.md](docs/deploy/1panel.md)。 ## 支持的文件格式 @@ -130,20 +123,20 @@ PDF(`.pdf`)、图片(`.jpg` `.jpeg` `.png` `.bmp` `.tiff` `.webp`),单次上 ```bash # 后端测试 -cd backend && python -m pytest tests/ -v +python -m pytest apps/backend/tests -v # 前端测试 -cd frontend && npm test +cd apps/frontend && npm test ``` -详见 [backend/tests/README.md](backend/tests/README.md) 和 [frontend/src/__tests__/README.md](frontend/src/__tests__/README.md)。 +详见 [apps/backend/tests/README.md](apps/backend/tests/README.md) 和 [apps/frontend/src/__tests__/README.md](apps/frontend/src/__tests__/README.md)。 ## 项目规则 项目规则集中维护在 [AGENTS.md](AGENTS.md)。 -- `rules/development/`:架构、前端、后端、测试和代码注释规则 -- `rules/workflow/`:提交、协作、同步、启动、环境和测试构建规则 +- `docs/rules/development/`:架构、前端、后端、测试和代码注释规则 +- `docs/rules/workflow/`:提交、协作、同步、启动、环境和测试构建规则 团队协作采用 fork-based workflow:开发者先 fork 主仓库,将分支推送到自己的 fork 仓库,再通过 Pull Request 提交到主仓库 review。 diff --git a/backend/.env.example b/apps/backend/.env.example similarity index 96% rename from backend/.env.example rename to apps/backend/.env.example index ed009c3f..b5e8ede4 100644 --- a/backend/.env.example +++ b/apps/backend/.env.example @@ -16,14 +16,14 @@ LANGSMITH_TRACING=false # ============================================================================ # 输出目录配置(可选) # ============================================================================ -# 目录配置由 config.py 集中管理(默认:backend/runtime_data/) +# 目录配置由 config.py 集中管理(默认:apps/backend/runtime_data/) # 如需覆盖运行时输出的根目录,请设置 APP_RUNTIME_DIR(绝对路径) # APP_RUNTIME_DIR= # ============================================================================ # 文字擦除模型权重(可选) # ============================================================================ -# 默认路径:backend/text_eraser_model/weight/best.pth +# 默认路径:apps/backend/text_eraser_model/weight/best.pth # 如需指定其他位置,取消注释并填写绝对路径 # APP_MODEL_PATH=/path/to/your/best.pth # CPU 推理线程数与滑窗批量;2 核 4 GB 服务器建议保持为 2 diff --git a/backend/agents/__init__.py b/apps/backend/agents/__init__.py similarity index 100% rename from backend/agents/__init__.py rename to apps/backend/agents/__init__.py diff --git a/backend/agents/error_correction/__init__.py b/apps/backend/agents/error_correction/__init__.py similarity index 100% rename from backend/agents/error_correction/__init__.py rename to apps/backend/agents/error_correction/__init__.py diff --git a/backend/agents/error_correction/agent.py b/apps/backend/agents/error_correction/agent.py similarity index 100% rename from backend/agents/error_correction/agent.py rename to apps/backend/agents/error_correction/agent.py diff --git a/backend/agents/error_correction/prompts.py b/apps/backend/agents/error_correction/prompts.py similarity index 100% rename from backend/agents/error_correction/prompts.py rename to apps/backend/agents/error_correction/prompts.py diff --git a/backend/agents/error_correction/schemas.py b/apps/backend/agents/error_correction/schemas.py similarity index 100% rename from backend/agents/error_correction/schemas.py rename to apps/backend/agents/error_correction/schemas.py diff --git a/backend/agents/error_correction/tools/__init__.py b/apps/backend/agents/error_correction/tools/__init__.py similarity index 100% rename from backend/agents/error_correction/tools/__init__.py rename to apps/backend/agents/error_correction/tools/__init__.py diff --git a/backend/agents/error_correction/tools/file_tools.py b/apps/backend/agents/error_correction/tools/file_tools.py similarity index 100% rename from backend/agents/error_correction/tools/file_tools.py rename to apps/backend/agents/error_correction/tools/file_tools.py diff --git a/backend/agents/error_correction/tools/question_tools.py b/apps/backend/agents/error_correction/tools/question_tools.py similarity index 100% rename from backend/agents/error_correction/tools/question_tools.py rename to apps/backend/agents/error_correction/tools/question_tools.py diff --git a/backend/agents/note/__init__.py b/apps/backend/agents/note/__init__.py similarity index 100% rename from backend/agents/note/__init__.py rename to apps/backend/agents/note/__init__.py diff --git a/backend/agents/note/agent.py b/apps/backend/agents/note/agent.py similarity index 100% rename from backend/agents/note/agent.py rename to apps/backend/agents/note/agent.py diff --git a/backend/agents/note/prompts.py b/apps/backend/agents/note/prompts.py similarity index 100% rename from backend/agents/note/prompts.py rename to apps/backend/agents/note/prompts.py diff --git a/backend/agents/note/schemas.py b/apps/backend/agents/note/schemas.py similarity index 100% rename from backend/agents/note/schemas.py rename to apps/backend/agents/note/schemas.py diff --git a/backend/agents/solve/__init__.py b/apps/backend/agents/solve/__init__.py similarity index 100% rename from backend/agents/solve/__init__.py rename to apps/backend/agents/solve/__init__.py diff --git a/backend/agents/solve/agent.py b/apps/backend/agents/solve/agent.py similarity index 100% rename from backend/agents/solve/agent.py rename to apps/backend/agents/solve/agent.py diff --git a/backend/agents/solve/prompts.py b/apps/backend/agents/solve/prompts.py similarity index 100% rename from backend/agents/solve/prompts.py rename to apps/backend/agents/solve/prompts.py diff --git a/backend/agents/solve/schemas.py b/apps/backend/agents/solve/schemas.py similarity index 100% rename from backend/agents/solve/schemas.py rename to apps/backend/agents/solve/schemas.py diff --git a/backend/agents/teach/__init__.py b/apps/backend/agents/teach/__init__.py similarity index 100% rename from backend/agents/teach/__init__.py rename to apps/backend/agents/teach/__init__.py diff --git a/backend/agents/teach/agent.py b/apps/backend/agents/teach/agent.py similarity index 100% rename from backend/agents/teach/agent.py rename to apps/backend/agents/teach/agent.py diff --git a/backend/agents/teach/prompts.py b/apps/backend/agents/teach/prompts.py similarity index 100% rename from backend/agents/teach/prompts.py rename to apps/backend/agents/teach/prompts.py diff --git a/backend/core/__init__.py b/apps/backend/core/__init__.py similarity index 100% rename from backend/core/__init__.py rename to apps/backend/core/__init__.py diff --git a/backend/core/config.py b/apps/backend/core/config.py similarity index 99% rename from backend/core/config.py rename to apps/backend/core/config.py index 266adc7d..42154acf 100644 --- a/backend/core/config.py +++ b/apps/backend/core/config.py @@ -19,8 +19,8 @@ ) ) -_BACKEND_ROOT = Path(__file__).resolve().parent.parent # backend/core/ → backend/ -_PROJECT_ROOT = _BACKEND_ROOT.parent +_BACKEND_ROOT = Path(__file__).resolve().parent.parent # apps/backend/core/ → apps/backend/ +_PROJECT_ROOT = _BACKEND_ROOT.parent.parent _ENV_FILE = _PROJECT_ROOT / ".env" diff --git a/backend/core/llm.py b/apps/backend/core/llm.py similarity index 100% rename from backend/core/llm.py rename to apps/backend/core/llm.py diff --git a/backend/core/mail.py b/apps/backend/core/mail.py similarity index 100% rename from backend/core/mail.py rename to apps/backend/core/mail.py diff --git a/backend/core/model_selection.py b/apps/backend/core/model_selection.py similarity index 100% rename from backend/core/model_selection.py rename to apps/backend/core/model_selection.py diff --git a/backend/core/quota.py b/apps/backend/core/quota.py similarity index 100% rename from backend/core/quota.py rename to apps/backend/core/quota.py diff --git a/backend/core/reasoning.py b/apps/backend/core/reasoning.py similarity index 100% rename from backend/core/reasoning.py rename to apps/backend/core/reasoning.py diff --git a/backend/core/state.py b/apps/backend/core/state.py similarity index 100% rename from backend/core/state.py rename to apps/backend/core/state.py diff --git a/backend/core/workflow_run_store.py b/apps/backend/core/workflow_run_store.py similarity index 100% rename from backend/core/workflow_run_store.py rename to apps/backend/core/workflow_run_store.py diff --git a/backend/db/__init__.py b/apps/backend/db/__init__.py similarity index 100% rename from backend/db/__init__.py rename to apps/backend/db/__init__.py diff --git a/backend/db/crud/__init__.py b/apps/backend/db/crud/__init__.py similarity index 100% rename from backend/db/crud/__init__.py rename to apps/backend/db/crud/__init__.py diff --git a/backend/db/crud/chat.py b/apps/backend/db/crud/chat.py similarity index 100% rename from backend/db/crud/chat.py rename to apps/backend/db/crud/chat.py diff --git a/backend/db/crud/devices.py b/apps/backend/db/crud/devices.py similarity index 100% rename from backend/db/crud/devices.py rename to apps/backend/db/crud/devices.py diff --git a/backend/db/crud/email_verification.py b/apps/backend/db/crud/email_verification.py similarity index 100% rename from backend/db/crud/email_verification.py rename to apps/backend/db/crud/email_verification.py diff --git a/backend/db/crud/notes.py b/apps/backend/db/crud/notes.py similarity index 100% rename from backend/db/crud/notes.py rename to apps/backend/db/crud/notes.py diff --git a/backend/db/crud/projects.py b/apps/backend/db/crud/projects.py similarity index 100% rename from backend/db/crud/projects.py rename to apps/backend/db/crud/projects.py diff --git a/backend/db/crud/providers.py b/apps/backend/db/crud/providers.py similarity index 100% rename from backend/db/crud/providers.py rename to apps/backend/db/crud/providers.py diff --git a/backend/db/crud/questions.py b/apps/backend/db/crud/questions.py similarity index 100% rename from backend/db/crud/questions.py rename to apps/backend/db/crud/questions.py diff --git a/backend/db/crud/review.py b/apps/backend/db/crud/review.py similarity index 100% rename from backend/db/crud/review.py rename to apps/backend/db/crud/review.py diff --git a/backend/db/crud/split_records.py b/apps/backend/db/crud/split_records.py similarity index 100% rename from backend/db/crud/split_records.py rename to apps/backend/db/crud/split_records.py diff --git a/backend/db/crud/stats.py b/apps/backend/db/crud/stats.py similarity index 100% rename from backend/db/crud/stats.py rename to apps/backend/db/crud/stats.py diff --git a/backend/db/crud/tags.py b/apps/backend/db/crud/tags.py similarity index 100% rename from backend/db/crud/tags.py rename to apps/backend/db/crud/tags.py diff --git a/backend/db/crud/users.py b/apps/backend/db/crud/users.py similarity index 100% rename from backend/db/crud/users.py rename to apps/backend/db/crud/users.py diff --git a/backend/db/crud/workflow_runs.py b/apps/backend/db/crud/workflow_runs.py similarity index 100% rename from backend/db/crud/workflow_runs.py rename to apps/backend/db/crud/workflow_runs.py diff --git a/backend/db/migrate.py b/apps/backend/db/migrate.py similarity index 100% rename from backend/db/migrate.py rename to apps/backend/db/migrate.py diff --git a/backend/db/models.py b/apps/backend/db/models.py similarity index 100% rename from backend/db/models.py rename to apps/backend/db/models.py diff --git a/backend/gunicorn.conf.py b/apps/backend/gunicorn.conf.py similarity index 100% rename from backend/gunicorn.conf.py rename to apps/backend/gunicorn.conf.py diff --git a/backend/package-lock.json b/apps/backend/package-lock.json similarity index 100% rename from backend/package-lock.json rename to apps/backend/package-lock.json diff --git a/backend/pipeline/paddleocr_client.py b/apps/backend/pipeline/paddleocr_client.py similarity index 100% rename from backend/pipeline/paddleocr_client.py rename to apps/backend/pipeline/paddleocr_client.py diff --git a/backend/pipeline/utils.py b/apps/backend/pipeline/utils.py similarity index 99% rename from backend/pipeline/utils.py rename to apps/backend/pipeline/utils.py index c4267fc7..018df3d4 100644 --- a/backend/pipeline/utils.py +++ b/apps/backend/pipeline/utils.py @@ -15,7 +15,7 @@ from core.config import settings -load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env")) +load_dotenv(str(settings.project_root / ".env")) console = Console() diff --git a/backend/pipeline/workflow.py b/apps/backend/pipeline/workflow.py similarity index 100% rename from backend/pipeline/workflow.py rename to apps/backend/pipeline/workflow.py diff --git a/backend/postgresql_schema.sql b/apps/backend/postgresql_schema.sql similarity index 99% rename from backend/postgresql_schema.sql rename to apps/backend/postgresql_schema.sql index 63dc3731..8825e85c 100644 --- a/backend/postgresql_schema.sql +++ b/apps/backend/postgresql_schema.sql @@ -1,4 +1,4 @@ --- PostgreSQL schema for backend/db/models.py +-- PostgreSQL schema for apps/backend/db/models.py -- Run with: psql "$DATABASE_URL" -f postgresql_schema.sql BEGIN; diff --git a/backend/routes/__init__.py b/apps/backend/routes/__init__.py similarity index 100% rename from backend/routes/__init__.py rename to apps/backend/routes/__init__.py diff --git a/backend/routes/auth.py b/apps/backend/routes/auth.py similarity index 100% rename from backend/routes/auth.py rename to apps/backend/routes/auth.py diff --git a/backend/routes/chat.py b/apps/backend/routes/chat.py similarity index 100% rename from backend/routes/chat.py rename to apps/backend/routes/chat.py diff --git a/backend/routes/device.py b/apps/backend/routes/device.py similarity index 100% rename from backend/routes/device.py rename to apps/backend/routes/device.py diff --git a/backend/routes/notes.py b/apps/backend/routes/notes.py similarity index 100% rename from backend/routes/notes.py rename to apps/backend/routes/notes.py diff --git a/backend/routes/projects.py b/apps/backend/routes/projects.py similarity index 100% rename from backend/routes/projects.py rename to apps/backend/routes/projects.py diff --git a/backend/routes/questions.py b/apps/backend/routes/questions.py similarity index 100% rename from backend/routes/questions.py rename to apps/backend/routes/questions.py diff --git a/backend/routes/settings.py b/apps/backend/routes/settings.py similarity index 100% rename from backend/routes/settings.py rename to apps/backend/routes/settings.py diff --git a/backend/routes/stats.py b/apps/backend/routes/stats.py similarity index 100% rename from backend/routes/stats.py rename to apps/backend/routes/stats.py diff --git a/backend/routes/upload.py b/apps/backend/routes/upload.py similarity index 100% rename from backend/routes/upload.py rename to apps/backend/routes/upload.py diff --git a/backend/tests/README.md b/apps/backend/tests/README.md similarity index 89% rename from backend/tests/README.md rename to apps/backend/tests/README.md index cceb3e77..4a2444fe 100644 --- a/backend/tests/README.md +++ b/apps/backend/tests/README.md @@ -11,8 +11,8 @@ C:\ProgramData\miniconda3\envs\da\python.exe 如果默认临时目录权限异常,可以把 pytest 临时目录放到项目内: ```bash -C:\ProgramData\miniconda3\envs\da\python.exe -m pytest backend\tests\test_web_routes.py::TestMultiUserWorkflowRunIsolation -q --basetemp=backend\runtime_data\pytest_tmp_route -p no:cacheprovider -C:\ProgramData\miniconda3\envs\da\python.exe -m pytest backend\tests\test_crud.py::TestWorkflowRuns -q --basetemp=backend\runtime_data\pytest_tmp_crud -p no:cacheprovider +C:\ProgramData\miniconda3\envs\da\python.exe -m pytest apps\backend\tests\test_web_routes.py::TestMultiUserWorkflowRunIsolation -q --basetemp=apps\backend\runtime_data\pytest_tmp_route -p no:cacheprovider +C:\ProgramData\miniconda3\envs\da\python.exe -m pytest apps\backend\tests\test_crud.py::TestWorkflowRuns -q --basetemp=apps\backend\runtime_data\pytest_tmp_crud -p no:cacheprovider ``` 新增的多用户相关测试: @@ -24,8 +24,8 @@ C:\ProgramData\miniconda3\envs\da\python.exe -m pytest backend\tests\test_crud.p ## 运行测试 ```bash -# 在 backend/ 目录下执行 -cd backend +# 在 apps/backend/ 目录下执行 +cd apps/backend # 运行全部单元测试 python -m pytest tests/ -v @@ -47,7 +47,7 @@ python -m pytest tests/ -v -k "dedup" ## 测试文件说明 ``` -backend/tests/ +apps/backend/tests/ ├── conftest.py # pytest 配置,公共 fixture(db / make_question) ├── fixtures/ # 测试数据 │ └── sample_ocr_data.json # OCR 测试数据(split_integration 使用) @@ -70,7 +70,7 @@ backend/tests/ ### test_utils.py -测试 `backend/pipeline/utils.py` 中的通用工具函数: +测试 `apps/backend/pipeline/utils.py` 中的通用工具函数: | 测试类 | 被测函数 | 用例数 | 说明 | |--------|----------|--------|------| @@ -78,7 +78,7 @@ backend/tests/ ### test_workflow_helpers.py -测试 `backend/pipeline/workflow.py` 和 `backend/pipeline/utils.py` 中不依赖外部服务的纯函数: +测试 `apps/backend/pipeline/workflow.py` 和 `apps/backend/pipeline/utils.py` 中不依赖外部服务的纯函数: | 测试类 | 被测函数 | 用例数 | 说明 | |--------|----------|--------|------| @@ -93,7 +93,7 @@ backend/tests/ ### test_export.py -测试 `backend/pipeline/utils.py` 中的 `export_wrongbook` 函数: +测试 `apps/backend/pipeline/utils.py` 中的 `export_wrongbook` 函数: | 测试方法 | 说明 | |----------|------| @@ -110,7 +110,7 @@ backend/tests/ ### test_web_helpers.py -测试 `backend/web_app.py` 中不依赖 Flask 请求上下文的纯函数: +测试 `apps/backend/web_app.py` 中不依赖 Flask 请求上下文的纯函数: | 测试类 | 被测函数 | 用例数 | 说明 | |--------|----------|--------|------| @@ -120,7 +120,7 @@ backend/tests/ ### test_crud.py -测试 `backend/db/crud.py` 中所有 CRUD 函数,使用 **SQLite 内存数据库**(每个测试用例独立数据库): +测试 `apps/backend/db/crud/` 中所有 CRUD 函数,使用 **SQLite 内存数据库**(每个测试用例独立数据库): | 测试类 | 被测函数 | 用例数 | 说明 | |--------|----------|--------|------| @@ -137,7 +137,7 @@ backend/tests/ ### test_schemas.py -测试 `backend/error_correction_agent/schemas.py` 中 Pydantic 模型的校验逻辑: +测试 `apps/backend/agents/error_correction/schemas.py` 中 Pydantic 模型的校验逻辑: | 测试类 | 被测模型 | 用例数 | 说明 | |--------|----------|--------|------| @@ -149,7 +149,7 @@ backend/tests/ ### test_question_tools.py -测试 `backend/error_correction_agent/tools/question_tools.py` 中的文件 I/O 工具(使用 `tmp_path`): +测试 `apps/backend/agents/error_correction/tools/question_tools.py` 中的文件 I/O 工具(使用 `tmp_path`): | 测试类 | 被测函数 | 用例数 | 说明 | |--------|----------|--------|------| @@ -158,7 +158,7 @@ backend/tests/ ### test_correct_node.py -测试 `backend/pipeline/workflow.py` 中 `correct_questions_node` 的合并逻辑(mock 纠错工具): +测试 `apps/backend/pipeline/workflow.py` 中 `correct_questions_node` 的合并逻辑(mock 纠错工具): | 测试方法 | 说明 | |----------|------| @@ -169,7 +169,7 @@ backend/tests/ ### test_solve_schemas.py -测试 `backend/solve_agent/schemas.py` 中解题结果 Pydantic 模型: +测试 `apps/backend/agents/solve/schemas.py` 中解题结果 Pydantic 模型: | 测试类 | 被测模型 | 用例数 | 说明 | |--------|----------|--------|------| @@ -178,7 +178,7 @@ backend/tests/ ### test_ocr_api.py -**集成测试**:验证 PaddleOCR V2 异步任务 API 的连通性与结果格式兼容性。需要配置 `PADDLEOCR_API_URL`、`PADDLEOCR_API_TOKEN`。测试文件使用 `example_uploads/` 下的 `test.jpg`(图片)和 `test4.pdf`(PDF)。 +**集成测试**:验证 PaddleOCR V2 异步任务 API 的连通性与结果格式兼容性。需要配置 `PADDLEOCR_API_URL`、`PADDLEOCR_API_TOKEN`。测试文件使用 `examples/` 下的 `test.jpg`(图片)和 `test4.pdf`(PDF)。 ```bash pytest tests/test_ocr_api.py -v -s diff --git a/backend/tests/__init__.py b/apps/backend/tests/__init__.py similarity index 100% rename from backend/tests/__init__.py rename to apps/backend/tests/__init__.py diff --git a/backend/tests/conftest.py b/apps/backend/tests/conftest.py similarity index 90% rename from backend/tests/conftest.py rename to apps/backend/tests/conftest.py index 4745b24f..797f4b3c 100644 --- a/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,5 +1,5 @@ """ -pytest 配置 — 确保 backend/ 在 sys.path 中,以便 import config 等模块。 +pytest 配置 — 确保 apps/backend/ 在 sys.path 中,以便 import config 等模块。 """ import sys @@ -9,13 +9,13 @@ from sqlalchemy import create_engine, event from sqlalchemy.orm import sessionmaker -from db.models import Base - -# 将 backend/ 目录加入 sys.path +# 将 apps/backend/ 目录加入 sys.path,保证从仓库根目录运行 pytest 时也能解析顶层包。 BACKEND_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if BACKEND_DIR not in sys.path: sys.path.insert(0, BACKEND_DIR) +from db.models import Base + def pytest_addoption(parser): parser.addoption( diff --git a/backend/tests/fixtures/sample_ocr_data.json b/apps/backend/tests/fixtures/sample_ocr_data.json similarity index 100% rename from backend/tests/fixtures/sample_ocr_data.json rename to apps/backend/tests/fixtures/sample_ocr_data.json diff --git a/backend/tests/test_chat_crud.py b/apps/backend/tests/test_chat_crud.py similarity index 100% rename from backend/tests/test_chat_crud.py rename to apps/backend/tests/test_chat_crud.py diff --git a/backend/tests/test_chat_routes.py b/apps/backend/tests/test_chat_routes.py similarity index 100% rename from backend/tests/test_chat_routes.py rename to apps/backend/tests/test_chat_routes.py diff --git a/backend/tests/test_correct_node.py b/apps/backend/tests/test_correct_node.py similarity index 100% rename from backend/tests/test_correct_node.py rename to apps/backend/tests/test_correct_node.py diff --git a/backend/tests/test_crud.py b/apps/backend/tests/test_crud.py similarity index 100% rename from backend/tests/test_crud.py rename to apps/backend/tests/test_crud.py diff --git a/backend/tests/test_eraser_inference.py b/apps/backend/tests/test_eraser_inference.py similarity index 100% rename from backend/tests/test_eraser_inference.py rename to apps/backend/tests/test_eraser_inference.py diff --git a/backend/tests/test_export.py b/apps/backend/tests/test_export.py similarity index 100% rename from backend/tests/test_export.py rename to apps/backend/tests/test_export.py diff --git a/backend/tests/test_migrate_and_delete_question.py b/apps/backend/tests/test_migrate_and_delete_question.py similarity index 100% rename from backend/tests/test_migrate_and_delete_question.py rename to apps/backend/tests/test_migrate_and_delete_question.py diff --git a/backend/tests/test_note_agent.py b/apps/backend/tests/test_note_agent.py similarity index 100% rename from backend/tests/test_note_agent.py rename to apps/backend/tests/test_note_agent.py diff --git a/backend/tests/test_ocr_api.py b/apps/backend/tests/test_ocr_api.py similarity index 98% rename from backend/tests/test_ocr_api.py rename to apps/backend/tests/test_ocr_api.py index 2ee43c7a..b3d88ade 100644 --- a/backend/tests/test_ocr_api.py +++ b/apps/backend/tests/test_ocr_api.py @@ -5,7 +5,7 @@ pytest tests/test_ocr_api.py -v -s 需要配置环境变量:PADDLEOCR_API_URL、PADDLEOCR_API_TOKEN。 -测试图片使用 example_uploads/notes/test.jpg,测试 PDF 使用 example_uploads/exams/test4.pdf。 +测试图片使用 examples/notes/test.jpg,测试 PDF 使用 examples/exams/test4.pdf。 """ import os @@ -54,9 +54,10 @@ def _load_ocr_creds_from_db() -> dict: TOKEN = os.getenv("PADDLEOCR_API_TOKEN") or _DB_CREDS.get("token", "") MODEL = os.getenv("PADDLEOCR_MODEL") or _DB_CREDS.get("model", "PaddleOCR-VL-1.6") -EXAMPLE_DIR = os.path.abspath( - os.path.join(os.path.dirname(__file__), "..", "..", "example_uploads") +PROJECT_ROOT = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..") ) +EXAMPLE_DIR = os.path.join(PROJECT_ROOT, "examples") TEST_IMAGE = os.path.join(EXAMPLE_DIR, "notes", "test.jpg") TEST_PDF = os.path.join(EXAMPLE_DIR, "exams", "test4.pdf") diff --git a/backend/tests/test_project_crud.py b/apps/backend/tests/test_project_crud.py similarity index 100% rename from backend/tests/test_project_crud.py rename to apps/backend/tests/test_project_crud.py diff --git a/backend/tests/test_question_tools.py b/apps/backend/tests/test_question_tools.py similarity index 100% rename from backend/tests/test_question_tools.py rename to apps/backend/tests/test_question_tools.py diff --git a/backend/tests/test_schemas.py b/apps/backend/tests/test_schemas.py similarity index 100% rename from backend/tests/test_schemas.py rename to apps/backend/tests/test_schemas.py diff --git a/backend/tests/test_solve_integration.py b/apps/backend/tests/test_solve_integration.py similarity index 100% rename from backend/tests/test_solve_integration.py rename to apps/backend/tests/test_solve_integration.py diff --git a/backend/tests/test_solve_schemas.py b/apps/backend/tests/test_solve_schemas.py similarity index 100% rename from backend/tests/test_solve_schemas.py rename to apps/backend/tests/test_solve_schemas.py diff --git a/backend/tests/test_split_integration.py b/apps/backend/tests/test_split_integration.py similarity index 100% rename from backend/tests/test_split_integration.py rename to apps/backend/tests/test_split_integration.py diff --git a/backend/tests/test_structured_output_modes.py b/apps/backend/tests/test_structured_output_modes.py similarity index 100% rename from backend/tests/test_structured_output_modes.py rename to apps/backend/tests/test_structured_output_modes.py diff --git a/backend/tests/test_teach_agent.py b/apps/backend/tests/test_teach_agent.py similarity index 100% rename from backend/tests/test_teach_agent.py rename to apps/backend/tests/test_teach_agent.py diff --git a/backend/tests/test_utils.py b/apps/backend/tests/test_utils.py similarity index 100% rename from backend/tests/test_utils.py rename to apps/backend/tests/test_utils.py diff --git a/backend/tests/test_web_helpers.py b/apps/backend/tests/test_web_helpers.py similarity index 100% rename from backend/tests/test_web_helpers.py rename to apps/backend/tests/test_web_helpers.py diff --git a/backend/tests/test_web_routes.py b/apps/backend/tests/test_web_routes.py similarity index 100% rename from backend/tests/test_web_routes.py rename to apps/backend/tests/test_web_routes.py diff --git a/backend/tests/test_workflow_helpers.py b/apps/backend/tests/test_workflow_helpers.py similarity index 100% rename from backend/tests/test_workflow_helpers.py rename to apps/backend/tests/test_workflow_helpers.py diff --git a/backend/text_eraser_model/__init__.py b/apps/backend/text_eraser_model/__init__.py similarity index 100% rename from backend/text_eraser_model/__init__.py rename to apps/backend/text_eraser_model/__init__.py diff --git a/backend/text_eraser_model/blocks.py b/apps/backend/text_eraser_model/blocks.py similarity index 100% rename from backend/text_eraser_model/blocks.py rename to apps/backend/text_eraser_model/blocks.py diff --git a/backend/text_eraser_model/inference.py b/apps/backend/text_eraser_model/inference.py similarity index 100% rename from backend/text_eraser_model/inference.py rename to apps/backend/text_eraser_model/inference.py diff --git a/backend/text_eraser_model/model.py b/apps/backend/text_eraser_model/model.py similarity index 100% rename from backend/text_eraser_model/model.py rename to apps/backend/text_eraser_model/model.py diff --git a/backend/text_eraser_model/test_erase.py b/apps/backend/text_eraser_model/test_erase.py similarity index 97% rename from backend/text_eraser_model/test_erase.py rename to apps/backend/text_eraser_model/test_erase.py index f40bab34..c0cf5fb5 100644 --- a/backend/text_eraser_model/test_erase.py +++ b/apps/backend/text_eraser_model/test_erase.py @@ -1,11 +1,11 @@ """ 文字擦除推理测试脚本 -运行方式:cd backend/text_eraser_model && python test_erase.py <图片路径> +运行方式:cd apps/backend/text_eraser_model && python test_erase.py <图片路径> 示例:python test_erase.py E:/code/python/error_correction/dataset/初中数学/图片/xxx.jpg """ import sys import os -# backend/text_eraser_model/ → backend/ +# apps/backend/text_eraser_model/ → apps/backend/ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) import torch diff --git a/backend/text_eraser_model/weight/best.pth b/apps/backend/text_eraser_model/weight/best.pth similarity index 100% rename from backend/text_eraser_model/weight/best.pth rename to apps/backend/text_eraser_model/weight/best.pth diff --git a/backend/web_app.py b/apps/backend/web_app.py similarity index 97% rename from backend/web_app.py rename to apps/backend/web_app.py index 23f6194e..e007bcef 100644 --- a/backend/web_app.py +++ b/apps/backend/web_app.py @@ -15,9 +15,10 @@ import sys import logging -# 无论从项目根目录执行 `python backend/web_app.py` 还是在 `backend` 下执行 `python web_app.py`, +# 无论从项目根目录执行 `python apps/backend/web_app.py` 还是在 `apps/backend` 下执行 `python web_app.py`, # 都把 backend 目录加入 sys.path,保证 `core`、`routes`、`db` 等包解析一致。 _BACKEND_ROOT = os.path.dirname(os.path.abspath(__file__)) +_PROJECT_ROOT = os.path.dirname(os.path.dirname(_BACKEND_ROOT)) if _BACKEND_ROOT not in sys.path: sys.path.insert(0, _BACKEND_ROOT) @@ -31,8 +32,8 @@ from db import crud from routes import register_routes -# 加载 backend/.env(无论从哪个目录启动都指向同一文件) -load_dotenv(os.path.join(_BACKEND_ROOT, ".env")) +# 加载项目根目录 .env(无论从哪个目录启动都指向同一文件) +load_dotenv(os.path.join(_PROJECT_ROOT, ".env")) # 模块级日志记录器,日志名称为 'web_app' logger = logging.getLogger(__name__) diff --git a/backend/wsgi.py b/apps/backend/wsgi.py similarity index 100% rename from backend/wsgi.py rename to apps/backend/wsgi.py diff --git a/frontend/.gitignore b/apps/frontend/.gitignore similarity index 100% rename from frontend/.gitignore rename to apps/frontend/.gitignore diff --git a/frontend/.vscode/extensions.json b/apps/frontend/.vscode/extensions.json similarity index 100% rename from frontend/.vscode/extensions.json rename to apps/frontend/.vscode/extensions.json diff --git a/frontend/app.html b/apps/frontend/app.html similarity index 100% rename from frontend/app.html rename to apps/frontend/app.html diff --git a/frontend/package-lock.json b/apps/frontend/package-lock.json similarity index 100% rename from frontend/package-lock.json rename to apps/frontend/package-lock.json diff --git a/frontend/package.json b/apps/frontend/package.json similarity index 100% rename from frontend/package.json rename to apps/frontend/package.json diff --git a/frontend/public/favicon.svg b/apps/frontend/public/favicon.svg similarity index 100% rename from frontend/public/favicon.svg rename to apps/frontend/public/favicon.svg diff --git a/frontend/public/logo.svg b/apps/frontend/public/logo.svg similarity index 100% rename from frontend/public/logo.svg rename to apps/frontend/public/logo.svg diff --git a/frontend/src/App.vue b/apps/frontend/src/App.vue similarity index 100% rename from frontend/src/App.vue rename to apps/frontend/src/App.vue diff --git a/frontend/src/__tests__/README.md b/apps/frontend/src/__tests__/README.md similarity index 100% rename from frontend/src/__tests__/README.md rename to apps/frontend/src/__tests__/README.md diff --git a/frontend/src/__tests__/api.test.ts b/apps/frontend/src/__tests__/api.test.ts similarity index 100% rename from frontend/src/__tests__/api.test.ts rename to apps/frontend/src/__tests__/api.test.ts diff --git a/frontend/src/__tests__/chat-page-model-selector.test.ts b/apps/frontend/src/__tests__/chat-page-model-selector.test.ts similarity index 100% rename from frontend/src/__tests__/chat-page-model-selector.test.ts rename to apps/frontend/src/__tests__/chat-page-model-selector.test.ts diff --git a/frontend/src/__tests__/state.test.ts b/apps/frontend/src/__tests__/state.test.ts similarity index 100% rename from frontend/src/__tests__/state.test.ts rename to apps/frontend/src/__tests__/state.test.ts diff --git a/frontend/src/__tests__/utils.test.ts b/apps/frontend/src/__tests__/utils.test.ts similarity index 100% rename from frontend/src/__tests__/utils.test.ts rename to apps/frontend/src/__tests__/utils.test.ts diff --git a/frontend/src/api/auth.ts b/apps/frontend/src/api/auth.ts similarity index 100% rename from frontend/src/api/auth.ts rename to apps/frontend/src/api/auth.ts diff --git a/frontend/src/api/chat.ts b/apps/frontend/src/api/chat.ts similarity index 100% rename from frontend/src/api/chat.ts rename to apps/frontend/src/api/chat.ts diff --git a/frontend/src/api/client.ts b/apps/frontend/src/api/client.ts similarity index 100% rename from frontend/src/api/client.ts rename to apps/frontend/src/api/client.ts diff --git a/frontend/src/api/config.ts b/apps/frontend/src/api/config.ts similarity index 100% rename from frontend/src/api/config.ts rename to apps/frontend/src/api/config.ts diff --git a/frontend/src/api/index.ts b/apps/frontend/src/api/index.ts similarity index 100% rename from frontend/src/api/index.ts rename to apps/frontend/src/api/index.ts diff --git a/frontend/src/api/notes.ts b/apps/frontend/src/api/notes.ts similarity index 100% rename from frontend/src/api/notes.ts rename to apps/frontend/src/api/notes.ts diff --git a/frontend/src/api/projects.ts b/apps/frontend/src/api/projects.ts similarity index 100% rename from frontend/src/api/projects.ts rename to apps/frontend/src/api/projects.ts diff --git a/frontend/src/api/questions.ts b/apps/frontend/src/api/questions.ts similarity index 100% rename from frontend/src/api/questions.ts rename to apps/frontend/src/api/questions.ts diff --git a/frontend/src/api/splitRecords.ts b/apps/frontend/src/api/splitRecords.ts similarity index 100% rename from frontend/src/api/splitRecords.ts rename to apps/frontend/src/api/splitRecords.ts diff --git a/frontend/src/api/upload.ts b/apps/frontend/src/api/upload.ts similarity index 100% rename from frontend/src/api/upload.ts rename to apps/frontend/src/api/upload.ts diff --git a/frontend/src/assets/deepseek.svg b/apps/frontend/src/assets/deepseek.svg similarity index 100% rename from frontend/src/assets/deepseek.svg rename to apps/frontend/src/assets/deepseek.svg diff --git a/frontend/src/assets/ernie.svg b/apps/frontend/src/assets/ernie.svg similarity index 100% rename from frontend/src/assets/ernie.svg rename to apps/frontend/src/assets/ernie.svg diff --git a/frontend/src/assets/provider-anthropic.svg b/apps/frontend/src/assets/provider-anthropic.svg similarity index 100% rename from frontend/src/assets/provider-anthropic.svg rename to apps/frontend/src/assets/provider-anthropic.svg diff --git a/frontend/src/assets/provider-openai.svg b/apps/frontend/src/assets/provider-openai.svg similarity index 100% rename from frontend/src/assets/provider-openai.svg rename to apps/frontend/src/assets/provider-openai.svg diff --git a/frontend/src/assets/provider-paddleocr.svg b/apps/frontend/src/assets/provider-paddleocr.svg similarity index 100% rename from frontend/src/assets/provider-paddleocr.svg rename to apps/frontend/src/assets/provider-paddleocr.svg diff --git a/frontend/src/components/base/BaseAccordion.vue b/apps/frontend/src/components/base/BaseAccordion.vue similarity index 100% rename from frontend/src/components/base/BaseAccordion.vue rename to apps/frontend/src/components/base/BaseAccordion.vue diff --git a/frontend/src/components/base/BaseAffix.vue b/apps/frontend/src/components/base/BaseAffix.vue similarity index 100% rename from frontend/src/components/base/BaseAffix.vue rename to apps/frontend/src/components/base/BaseAffix.vue diff --git a/frontend/src/components/base/BaseAlert.vue b/apps/frontend/src/components/base/BaseAlert.vue similarity index 100% rename from frontend/src/components/base/BaseAlert.vue rename to apps/frontend/src/components/base/BaseAlert.vue diff --git a/frontend/src/components/base/BaseAnchor.vue b/apps/frontend/src/components/base/BaseAnchor.vue similarity index 100% rename from frontend/src/components/base/BaseAnchor.vue rename to apps/frontend/src/components/base/BaseAnchor.vue diff --git a/frontend/src/components/base/BaseAvatar.vue b/apps/frontend/src/components/base/BaseAvatar.vue similarity index 100% rename from frontend/src/components/base/BaseAvatar.vue rename to apps/frontend/src/components/base/BaseAvatar.vue diff --git a/frontend/src/components/base/BaseAvatarGroup.vue b/apps/frontend/src/components/base/BaseAvatarGroup.vue similarity index 100% rename from frontend/src/components/base/BaseAvatarGroup.vue rename to apps/frontend/src/components/base/BaseAvatarGroup.vue diff --git a/frontend/src/components/base/BaseBackTop.vue b/apps/frontend/src/components/base/BaseBackTop.vue similarity index 100% rename from frontend/src/components/base/BaseBackTop.vue rename to apps/frontend/src/components/base/BaseBackTop.vue diff --git a/frontend/src/components/base/BaseBadge.vue b/apps/frontend/src/components/base/BaseBadge.vue similarity index 100% rename from frontend/src/components/base/BaseBadge.vue rename to apps/frontend/src/components/base/BaseBadge.vue diff --git a/frontend/src/components/base/BaseBreadcrumb.vue b/apps/frontend/src/components/base/BaseBreadcrumb.vue similarity index 100% rename from frontend/src/components/base/BaseBreadcrumb.vue rename to apps/frontend/src/components/base/BaseBreadcrumb.vue diff --git a/frontend/src/components/base/BaseBreadcrumbItem.vue b/apps/frontend/src/components/base/BaseBreadcrumbItem.vue similarity index 100% rename from frontend/src/components/base/BaseBreadcrumbItem.vue rename to apps/frontend/src/components/base/BaseBreadcrumbItem.vue diff --git a/frontend/src/components/base/BaseButton.vue b/apps/frontend/src/components/base/BaseButton.vue similarity index 100% rename from frontend/src/components/base/BaseButton.vue rename to apps/frontend/src/components/base/BaseButton.vue diff --git a/frontend/src/components/base/BaseButtonGroup.vue b/apps/frontend/src/components/base/BaseButtonGroup.vue similarity index 100% rename from frontend/src/components/base/BaseButtonGroup.vue rename to apps/frontend/src/components/base/BaseButtonGroup.vue diff --git a/frontend/src/components/base/BaseCalendar.vue b/apps/frontend/src/components/base/BaseCalendar.vue similarity index 100% rename from frontend/src/components/base/BaseCalendar.vue rename to apps/frontend/src/components/base/BaseCalendar.vue diff --git a/frontend/src/components/base/BaseCard.vue b/apps/frontend/src/components/base/BaseCard.vue similarity index 100% rename from frontend/src/components/base/BaseCard.vue rename to apps/frontend/src/components/base/BaseCard.vue diff --git a/frontend/src/components/base/BaseCarousel.vue b/apps/frontend/src/components/base/BaseCarousel.vue similarity index 100% rename from frontend/src/components/base/BaseCarousel.vue rename to apps/frontend/src/components/base/BaseCarousel.vue diff --git a/frontend/src/components/base/BaseCascader.vue b/apps/frontend/src/components/base/BaseCascader.vue similarity index 100% rename from frontend/src/components/base/BaseCascader.vue rename to apps/frontend/src/components/base/BaseCascader.vue diff --git a/frontend/src/components/base/BaseCheckbox.vue b/apps/frontend/src/components/base/BaseCheckbox.vue similarity index 100% rename from frontend/src/components/base/BaseCheckbox.vue rename to apps/frontend/src/components/base/BaseCheckbox.vue diff --git a/frontend/src/components/base/BaseCheckboxGroup.vue b/apps/frontend/src/components/base/BaseCheckboxGroup.vue similarity index 100% rename from frontend/src/components/base/BaseCheckboxGroup.vue rename to apps/frontend/src/components/base/BaseCheckboxGroup.vue diff --git a/frontend/src/components/base/BaseCircleProgress.vue b/apps/frontend/src/components/base/BaseCircleProgress.vue similarity index 100% rename from frontend/src/components/base/BaseCircleProgress.vue rename to apps/frontend/src/components/base/BaseCircleProgress.vue diff --git a/frontend/src/components/base/BaseCodeBlock.vue b/apps/frontend/src/components/base/BaseCodeBlock.vue similarity index 100% rename from frontend/src/components/base/BaseCodeBlock.vue rename to apps/frontend/src/components/base/BaseCodeBlock.vue diff --git a/frontend/src/components/base/BaseColorPicker.vue b/apps/frontend/src/components/base/BaseColorPicker.vue similarity index 100% rename from frontend/src/components/base/BaseColorPicker.vue rename to apps/frontend/src/components/base/BaseColorPicker.vue diff --git a/frontend/src/components/base/BaseCombobox.vue b/apps/frontend/src/components/base/BaseCombobox.vue similarity index 100% rename from frontend/src/components/base/BaseCombobox.vue rename to apps/frontend/src/components/base/BaseCombobox.vue diff --git a/frontend/src/components/base/BaseCommandPalette.vue b/apps/frontend/src/components/base/BaseCommandPalette.vue similarity index 100% rename from frontend/src/components/base/BaseCommandPalette.vue rename to apps/frontend/src/components/base/BaseCommandPalette.vue diff --git a/frontend/src/components/base/BaseCopyButton.vue b/apps/frontend/src/components/base/BaseCopyButton.vue similarity index 100% rename from frontend/src/components/base/BaseCopyButton.vue rename to apps/frontend/src/components/base/BaseCopyButton.vue diff --git a/frontend/src/components/base/BaseCountdown.vue b/apps/frontend/src/components/base/BaseCountdown.vue similarity index 100% rename from frontend/src/components/base/BaseCountdown.vue rename to apps/frontend/src/components/base/BaseCountdown.vue diff --git a/frontend/src/components/base/BaseDataTable.vue b/apps/frontend/src/components/base/BaseDataTable.vue similarity index 100% rename from frontend/src/components/base/BaseDataTable.vue rename to apps/frontend/src/components/base/BaseDataTable.vue diff --git a/frontend/src/components/base/BaseDatePicker.vue b/apps/frontend/src/components/base/BaseDatePicker.vue similarity index 100% rename from frontend/src/components/base/BaseDatePicker.vue rename to apps/frontend/src/components/base/BaseDatePicker.vue diff --git a/frontend/src/components/base/BaseDateRangePicker.vue b/apps/frontend/src/components/base/BaseDateRangePicker.vue similarity index 100% rename from frontend/src/components/base/BaseDateRangePicker.vue rename to apps/frontend/src/components/base/BaseDateRangePicker.vue diff --git a/frontend/src/components/base/BaseDescriptions.vue b/apps/frontend/src/components/base/BaseDescriptions.vue similarity index 100% rename from frontend/src/components/base/BaseDescriptions.vue rename to apps/frontend/src/components/base/BaseDescriptions.vue diff --git a/frontend/src/components/base/BaseDivider.vue b/apps/frontend/src/components/base/BaseDivider.vue similarity index 100% rename from frontend/src/components/base/BaseDivider.vue rename to apps/frontend/src/components/base/BaseDivider.vue diff --git a/frontend/src/components/base/BaseDrawer.vue b/apps/frontend/src/components/base/BaseDrawer.vue similarity index 100% rename from frontend/src/components/base/BaseDrawer.vue rename to apps/frontend/src/components/base/BaseDrawer.vue diff --git a/frontend/src/components/base/BaseDropdown.vue b/apps/frontend/src/components/base/BaseDropdown.vue similarity index 100% rename from frontend/src/components/base/BaseDropdown.vue rename to apps/frontend/src/components/base/BaseDropdown.vue diff --git a/frontend/src/components/base/BaseEllipsis.vue b/apps/frontend/src/components/base/BaseEllipsis.vue similarity index 100% rename from frontend/src/components/base/BaseEllipsis.vue rename to apps/frontend/src/components/base/BaseEllipsis.vue diff --git a/frontend/src/components/base/BaseEmptyState.vue b/apps/frontend/src/components/base/BaseEmptyState.vue similarity index 100% rename from frontend/src/components/base/BaseEmptyState.vue rename to apps/frontend/src/components/base/BaseEmptyState.vue diff --git a/frontend/src/components/base/BaseFieldMessage.vue b/apps/frontend/src/components/base/BaseFieldMessage.vue similarity index 100% rename from frontend/src/components/base/BaseFieldMessage.vue rename to apps/frontend/src/components/base/BaseFieldMessage.vue diff --git a/frontend/src/components/base/BaseFloatButton.vue b/apps/frontend/src/components/base/BaseFloatButton.vue similarity index 100% rename from frontend/src/components/base/BaseFloatButton.vue rename to apps/frontend/src/components/base/BaseFloatButton.vue diff --git a/frontend/src/components/base/BaseForm.vue b/apps/frontend/src/components/base/BaseForm.vue similarity index 100% rename from frontend/src/components/base/BaseForm.vue rename to apps/frontend/src/components/base/BaseForm.vue diff --git a/frontend/src/components/base/BaseFormItem.vue b/apps/frontend/src/components/base/BaseFormItem.vue similarity index 100% rename from frontend/src/components/base/BaseFormItem.vue rename to apps/frontend/src/components/base/BaseFormItem.vue diff --git a/frontend/src/components/base/BaseHighlight.vue b/apps/frontend/src/components/base/BaseHighlight.vue similarity index 100% rename from frontend/src/components/base/BaseHighlight.vue rename to apps/frontend/src/components/base/BaseHighlight.vue diff --git a/frontend/src/components/base/BaseImage.vue b/apps/frontend/src/components/base/BaseImage.vue similarity index 100% rename from frontend/src/components/base/BaseImage.vue rename to apps/frontend/src/components/base/BaseImage.vue diff --git a/frontend/src/components/base/BaseInfiniteScroll.vue b/apps/frontend/src/components/base/BaseInfiniteScroll.vue similarity index 100% rename from frontend/src/components/base/BaseInfiniteScroll.vue rename to apps/frontend/src/components/base/BaseInfiniteScroll.vue diff --git a/frontend/src/components/base/BaseInput.vue b/apps/frontend/src/components/base/BaseInput.vue similarity index 100% rename from frontend/src/components/base/BaseInput.vue rename to apps/frontend/src/components/base/BaseInput.vue diff --git a/frontend/src/components/base/BaseKbd.vue b/apps/frontend/src/components/base/BaseKbd.vue similarity index 100% rename from frontend/src/components/base/BaseKbd.vue rename to apps/frontend/src/components/base/BaseKbd.vue diff --git a/frontend/src/components/base/BaseLink.vue b/apps/frontend/src/components/base/BaseLink.vue similarity index 100% rename from frontend/src/components/base/BaseLink.vue rename to apps/frontend/src/components/base/BaseLink.vue diff --git a/frontend/src/components/base/BaseListGroup.vue b/apps/frontend/src/components/base/BaseListGroup.vue similarity index 100% rename from frontend/src/components/base/BaseListGroup.vue rename to apps/frontend/src/components/base/BaseListGroup.vue diff --git a/frontend/src/components/base/BaseListItem.vue b/apps/frontend/src/components/base/BaseListItem.vue similarity index 100% rename from frontend/src/components/base/BaseListItem.vue rename to apps/frontend/src/components/base/BaseListItem.vue diff --git a/frontend/src/components/base/BaseLoading.vue b/apps/frontend/src/components/base/BaseLoading.vue similarity index 100% rename from frontend/src/components/base/BaseLoading.vue rename to apps/frontend/src/components/base/BaseLoading.vue diff --git a/frontend/src/components/base/BaseLoadingBar.vue b/apps/frontend/src/components/base/BaseLoadingBar.vue similarity index 100% rename from frontend/src/components/base/BaseLoadingBar.vue rename to apps/frontend/src/components/base/BaseLoadingBar.vue diff --git a/frontend/src/components/base/BaseLogo.vue b/apps/frontend/src/components/base/BaseLogo.vue similarity index 100% rename from frontend/src/components/base/BaseLogo.vue rename to apps/frontend/src/components/base/BaseLogo.vue diff --git a/frontend/src/components/base/BaseMarquee.vue b/apps/frontend/src/components/base/BaseMarquee.vue similarity index 100% rename from frontend/src/components/base/BaseMarquee.vue rename to apps/frontend/src/components/base/BaseMarquee.vue diff --git a/frontend/src/components/base/BaseMention.vue b/apps/frontend/src/components/base/BaseMention.vue similarity index 100% rename from frontend/src/components/base/BaseMention.vue rename to apps/frontend/src/components/base/BaseMention.vue diff --git a/frontend/src/components/base/BaseMenu.vue b/apps/frontend/src/components/base/BaseMenu.vue similarity index 100% rename from frontend/src/components/base/BaseMenu.vue rename to apps/frontend/src/components/base/BaseMenu.vue diff --git a/frontend/src/components/base/BaseModal.vue b/apps/frontend/src/components/base/BaseModal.vue similarity index 100% rename from frontend/src/components/base/BaseModal.vue rename to apps/frontend/src/components/base/BaseModal.vue diff --git a/frontend/src/components/base/BaseNumberAnimation.vue b/apps/frontend/src/components/base/BaseNumberAnimation.vue similarity index 100% rename from frontend/src/components/base/BaseNumberAnimation.vue rename to apps/frontend/src/components/base/BaseNumberAnimation.vue diff --git a/frontend/src/components/base/BaseNumberInput.vue b/apps/frontend/src/components/base/BaseNumberInput.vue similarity index 100% rename from frontend/src/components/base/BaseNumberInput.vue rename to apps/frontend/src/components/base/BaseNumberInput.vue diff --git a/frontend/src/components/base/BasePagination.vue b/apps/frontend/src/components/base/BasePagination.vue similarity index 100% rename from frontend/src/components/base/BasePagination.vue rename to apps/frontend/src/components/base/BasePagination.vue diff --git a/frontend/src/components/base/BasePanel.vue b/apps/frontend/src/components/base/BasePanel.vue similarity index 100% rename from frontend/src/components/base/BasePanel.vue rename to apps/frontend/src/components/base/BasePanel.vue diff --git a/frontend/src/components/base/BasePanelTitle.vue b/apps/frontend/src/components/base/BasePanelTitle.vue similarity index 100% rename from frontend/src/components/base/BasePanelTitle.vue rename to apps/frontend/src/components/base/BasePanelTitle.vue diff --git a/frontend/src/components/base/BasePinInput.vue b/apps/frontend/src/components/base/BasePinInput.vue similarity index 100% rename from frontend/src/components/base/BasePinInput.vue rename to apps/frontend/src/components/base/BasePinInput.vue diff --git a/frontend/src/components/base/BasePopconfirm.vue b/apps/frontend/src/components/base/BasePopconfirm.vue similarity index 100% rename from frontend/src/components/base/BasePopconfirm.vue rename to apps/frontend/src/components/base/BasePopconfirm.vue diff --git a/frontend/src/components/base/BasePopover.vue b/apps/frontend/src/components/base/BasePopover.vue similarity index 100% rename from frontend/src/components/base/BasePopover.vue rename to apps/frontend/src/components/base/BasePopover.vue diff --git a/frontend/src/components/base/BaseProgress.vue b/apps/frontend/src/components/base/BaseProgress.vue similarity index 100% rename from frontend/src/components/base/BaseProgress.vue rename to apps/frontend/src/components/base/BaseProgress.vue diff --git a/frontend/src/components/base/BaseRadio.vue b/apps/frontend/src/components/base/BaseRadio.vue similarity index 100% rename from frontend/src/components/base/BaseRadio.vue rename to apps/frontend/src/components/base/BaseRadio.vue diff --git a/frontend/src/components/base/BaseRadioGroup.vue b/apps/frontend/src/components/base/BaseRadioGroup.vue similarity index 100% rename from frontend/src/components/base/BaseRadioGroup.vue rename to apps/frontend/src/components/base/BaseRadioGroup.vue diff --git a/frontend/src/components/base/BaseRate.vue b/apps/frontend/src/components/base/BaseRate.vue similarity index 100% rename from frontend/src/components/base/BaseRate.vue rename to apps/frontend/src/components/base/BaseRate.vue diff --git a/frontend/src/components/base/BaseResizablePanels.vue b/apps/frontend/src/components/base/BaseResizablePanels.vue similarity index 100% rename from frontend/src/components/base/BaseResizablePanels.vue rename to apps/frontend/src/components/base/BaseResizablePanels.vue diff --git a/frontend/src/components/base/BaseResult.vue b/apps/frontend/src/components/base/BaseResult.vue similarity index 100% rename from frontend/src/components/base/BaseResult.vue rename to apps/frontend/src/components/base/BaseResult.vue diff --git a/frontend/src/components/base/BaseRibbon.vue b/apps/frontend/src/components/base/BaseRibbon.vue similarity index 100% rename from frontend/src/components/base/BaseRibbon.vue rename to apps/frontend/src/components/base/BaseRibbon.vue diff --git a/frontend/src/components/base/BaseScrollbar.vue b/apps/frontend/src/components/base/BaseScrollbar.vue similarity index 100% rename from frontend/src/components/base/BaseScrollbar.vue rename to apps/frontend/src/components/base/BaseScrollbar.vue diff --git a/frontend/src/components/base/BaseSearchInput.vue b/apps/frontend/src/components/base/BaseSearchInput.vue similarity index 100% rename from frontend/src/components/base/BaseSearchInput.vue rename to apps/frontend/src/components/base/BaseSearchInput.vue diff --git a/frontend/src/components/base/BaseSearchableSelect.vue b/apps/frontend/src/components/base/BaseSearchableSelect.vue similarity index 100% rename from frontend/src/components/base/BaseSearchableSelect.vue rename to apps/frontend/src/components/base/BaseSearchableSelect.vue diff --git a/frontend/src/components/base/BaseSegmented.vue b/apps/frontend/src/components/base/BaseSegmented.vue similarity index 100% rename from frontend/src/components/base/BaseSegmented.vue rename to apps/frontend/src/components/base/BaseSegmented.vue diff --git a/frontend/src/components/base/BaseSelect.vue b/apps/frontend/src/components/base/BaseSelect.vue similarity index 100% rename from frontend/src/components/base/BaseSelect.vue rename to apps/frontend/src/components/base/BaseSelect.vue diff --git a/frontend/src/components/base/BaseSkeleton.vue b/apps/frontend/src/components/base/BaseSkeleton.vue similarity index 100% rename from frontend/src/components/base/BaseSkeleton.vue rename to apps/frontend/src/components/base/BaseSkeleton.vue diff --git a/frontend/src/components/base/BaseSlider.vue b/apps/frontend/src/components/base/BaseSlider.vue similarity index 100% rename from frontend/src/components/base/BaseSlider.vue rename to apps/frontend/src/components/base/BaseSlider.vue diff --git a/frontend/src/components/base/BaseSpin.vue b/apps/frontend/src/components/base/BaseSpin.vue similarity index 100% rename from frontend/src/components/base/BaseSpin.vue rename to apps/frontend/src/components/base/BaseSpin.vue diff --git a/frontend/src/components/base/BaseStat.vue b/apps/frontend/src/components/base/BaseStat.vue similarity index 100% rename from frontend/src/components/base/BaseStat.vue rename to apps/frontend/src/components/base/BaseStat.vue diff --git a/frontend/src/components/base/BaseStatusPill.vue b/apps/frontend/src/components/base/BaseStatusPill.vue similarity index 100% rename from frontend/src/components/base/BaseStatusPill.vue rename to apps/frontend/src/components/base/BaseStatusPill.vue diff --git a/frontend/src/components/base/BaseStepper.vue b/apps/frontend/src/components/base/BaseStepper.vue similarity index 100% rename from frontend/src/components/base/BaseStepper.vue rename to apps/frontend/src/components/base/BaseStepper.vue diff --git a/frontend/src/components/base/BaseSurface.vue b/apps/frontend/src/components/base/BaseSurface.vue similarity index 100% rename from frontend/src/components/base/BaseSurface.vue rename to apps/frontend/src/components/base/BaseSurface.vue diff --git a/frontend/src/components/base/BaseSwitch.vue b/apps/frontend/src/components/base/BaseSwitch.vue similarity index 100% rename from frontend/src/components/base/BaseSwitch.vue rename to apps/frontend/src/components/base/BaseSwitch.vue diff --git a/frontend/src/components/base/BaseTable.vue b/apps/frontend/src/components/base/BaseTable.vue similarity index 100% rename from frontend/src/components/base/BaseTable.vue rename to apps/frontend/src/components/base/BaseTable.vue diff --git a/frontend/src/components/base/BaseTabs.vue b/apps/frontend/src/components/base/BaseTabs.vue similarity index 100% rename from frontend/src/components/base/BaseTabs.vue rename to apps/frontend/src/components/base/BaseTabs.vue diff --git a/frontend/src/components/base/BaseTag.vue b/apps/frontend/src/components/base/BaseTag.vue similarity index 100% rename from frontend/src/components/base/BaseTag.vue rename to apps/frontend/src/components/base/BaseTag.vue diff --git a/frontend/src/components/base/BaseTagsInput.vue b/apps/frontend/src/components/base/BaseTagsInput.vue similarity index 100% rename from frontend/src/components/base/BaseTagsInput.vue rename to apps/frontend/src/components/base/BaseTagsInput.vue diff --git a/frontend/src/components/base/BaseTextarea.vue b/apps/frontend/src/components/base/BaseTextarea.vue similarity index 100% rename from frontend/src/components/base/BaseTextarea.vue rename to apps/frontend/src/components/base/BaseTextarea.vue diff --git a/frontend/src/components/base/BaseTimePicker.vue b/apps/frontend/src/components/base/BaseTimePicker.vue similarity index 100% rename from frontend/src/components/base/BaseTimePicker.vue rename to apps/frontend/src/components/base/BaseTimePicker.vue diff --git a/frontend/src/components/base/BaseTimeline.vue b/apps/frontend/src/components/base/BaseTimeline.vue similarity index 100% rename from frontend/src/components/base/BaseTimeline.vue rename to apps/frontend/src/components/base/BaseTimeline.vue diff --git a/frontend/src/components/base/BaseToastContainer.vue b/apps/frontend/src/components/base/BaseToastContainer.vue similarity index 100% rename from frontend/src/components/base/BaseToastContainer.vue rename to apps/frontend/src/components/base/BaseToastContainer.vue diff --git a/frontend/src/components/base/BaseToolbarButton.vue b/apps/frontend/src/components/base/BaseToolbarButton.vue similarity index 100% rename from frontend/src/components/base/BaseToolbarButton.vue rename to apps/frontend/src/components/base/BaseToolbarButton.vue diff --git a/frontend/src/components/base/BaseTooltip.vue b/apps/frontend/src/components/base/BaseTooltip.vue similarity index 100% rename from frontend/src/components/base/BaseTooltip.vue rename to apps/frontend/src/components/base/BaseTooltip.vue diff --git a/frontend/src/components/base/BaseTooltipProvider.vue b/apps/frontend/src/components/base/BaseTooltipProvider.vue similarity index 100% rename from frontend/src/components/base/BaseTooltipProvider.vue rename to apps/frontend/src/components/base/BaseTooltipProvider.vue diff --git a/frontend/src/components/base/BaseTour.vue b/apps/frontend/src/components/base/BaseTour.vue similarity index 100% rename from frontend/src/components/base/BaseTour.vue rename to apps/frontend/src/components/base/BaseTour.vue diff --git a/frontend/src/components/base/BaseTransfer.vue b/apps/frontend/src/components/base/BaseTransfer.vue similarity index 100% rename from frontend/src/components/base/BaseTransfer.vue rename to apps/frontend/src/components/base/BaseTransfer.vue diff --git a/frontend/src/components/base/BaseTree.vue b/apps/frontend/src/components/base/BaseTree.vue similarity index 100% rename from frontend/src/components/base/BaseTree.vue rename to apps/frontend/src/components/base/BaseTree.vue diff --git a/frontend/src/components/base/BaseTreeSelect.vue b/apps/frontend/src/components/base/BaseTreeSelect.vue similarity index 100% rename from frontend/src/components/base/BaseTreeSelect.vue rename to apps/frontend/src/components/base/BaseTreeSelect.vue diff --git a/frontend/src/components/base/BaseUpload.vue b/apps/frontend/src/components/base/BaseUpload.vue similarity index 100% rename from frontend/src/components/base/BaseUpload.vue rename to apps/frontend/src/components/base/BaseUpload.vue diff --git a/frontend/src/components/base/BaseVirtualList.vue b/apps/frontend/src/components/base/BaseVirtualList.vue similarity index 100% rename from frontend/src/components/base/BaseVirtualList.vue rename to apps/frontend/src/components/base/BaseVirtualList.vue diff --git a/frontend/src/components/base/BaseWatermark.vue b/apps/frontend/src/components/base/BaseWatermark.vue similarity index 100% rename from frontend/src/components/base/BaseWatermark.vue rename to apps/frontend/src/components/base/BaseWatermark.vue diff --git a/frontend/src/components/base/ImageModal.vue b/apps/frontend/src/components/base/ImageModal.vue similarity index 100% rename from frontend/src/components/base/ImageModal.vue rename to apps/frontend/src/components/base/ImageModal.vue diff --git a/frontend/src/components/base/index.ts b/apps/frontend/src/components/base/index.ts similarity index 100% rename from frontend/src/components/base/index.ts rename to apps/frontend/src/components/base/index.ts diff --git a/frontend/src/components/base/registry.ts b/apps/frontend/src/components/base/registry.ts similarity index 100% rename from frontend/src/components/base/registry.ts rename to apps/frontend/src/components/base/registry.ts diff --git a/frontend/src/components/features/app/dashboard/ErrorDistributionCard.vue b/apps/frontend/src/components/features/app/dashboard/ErrorDistributionCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/ErrorDistributionCard.vue rename to apps/frontend/src/components/features/app/dashboard/ErrorDistributionCard.vue diff --git a/frontend/src/components/features/app/dashboard/KnowledgeCompareCard.vue b/apps/frontend/src/components/features/app/dashboard/KnowledgeCompareCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/KnowledgeCompareCard.vue rename to apps/frontend/src/components/features/app/dashboard/KnowledgeCompareCard.vue diff --git a/frontend/src/components/features/app/dashboard/KnowledgeRadarCard.vue b/apps/frontend/src/components/features/app/dashboard/KnowledgeRadarCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/KnowledgeRadarCard.vue rename to apps/frontend/src/components/features/app/dashboard/KnowledgeRadarCard.vue diff --git a/frontend/src/components/features/app/dashboard/LearningTrendCard.vue b/apps/frontend/src/components/features/app/dashboard/LearningTrendCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/LearningTrendCard.vue rename to apps/frontend/src/components/features/app/dashboard/LearningTrendCard.vue diff --git a/frontend/src/components/features/app/dashboard/PriorityBarCard.vue b/apps/frontend/src/components/features/app/dashboard/PriorityBarCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/PriorityBarCard.vue rename to apps/frontend/src/components/features/app/dashboard/PriorityBarCard.vue diff --git a/frontend/src/components/features/app/dashboard/StatCard.vue b/apps/frontend/src/components/features/app/dashboard/StatCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/StatCard.vue rename to apps/frontend/src/components/features/app/dashboard/StatCard.vue diff --git a/frontend/src/components/features/app/dashboard/SummaryStatsChartCard.vue b/apps/frontend/src/components/features/app/dashboard/SummaryStatsChartCard.vue similarity index 100% rename from frontend/src/components/features/app/dashboard/SummaryStatsChartCard.vue rename to apps/frontend/src/components/features/app/dashboard/SummaryStatsChartCard.vue diff --git a/frontend/src/components/features/app/error-bank/ErrorBankToolbar.vue b/apps/frontend/src/components/features/app/error-bank/ErrorBankToolbar.vue similarity index 100% rename from frontend/src/components/features/app/error-bank/ErrorBankToolbar.vue rename to apps/frontend/src/components/features/app/error-bank/ErrorBankToolbar.vue diff --git a/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue b/apps/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue similarity index 100% rename from frontend/src/components/features/app/error-bank/ErrorLearningAside.vue rename to apps/frontend/src/components/features/app/error-bank/ErrorLearningAside.vue diff --git a/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue b/apps/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue similarity index 100% rename from frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue rename to apps/frontend/src/components/features/app/error-bank/ErrorQuestionDetailPanel.vue diff --git a/frontend/src/components/features/app/error-bank/ErrorQuestionFinderAside.vue b/apps/frontend/src/components/features/app/error-bank/ErrorQuestionFinderAside.vue similarity index 100% rename from frontend/src/components/features/app/error-bank/ErrorQuestionFinderAside.vue rename to apps/frontend/src/components/features/app/error-bank/ErrorQuestionFinderAside.vue diff --git a/frontend/src/components/features/app/error-bank/ErrorQuestionListItem.vue b/apps/frontend/src/components/features/app/error-bank/ErrorQuestionListItem.vue similarity index 100% rename from frontend/src/components/features/app/error-bank/ErrorQuestionListItem.vue rename to apps/frontend/src/components/features/app/error-bank/ErrorQuestionListItem.vue diff --git a/frontend/src/components/features/app/error-bank/ErrorQuestionListPanel.vue b/apps/frontend/src/components/features/app/error-bank/ErrorQuestionListPanel.vue similarity index 100% rename from frontend/src/components/features/app/error-bank/ErrorQuestionListPanel.vue rename to apps/frontend/src/components/features/app/error-bank/ErrorQuestionListPanel.vue diff --git a/frontend/src/components/features/app/layout/ChatSearchDialog.vue b/apps/frontend/src/components/features/app/layout/ChatSearchDialog.vue similarity index 100% rename from frontend/src/components/features/app/layout/ChatSearchDialog.vue rename to apps/frontend/src/components/features/app/layout/ChatSearchDialog.vue diff --git a/frontend/src/components/features/app/layout/ContentPanel.vue b/apps/frontend/src/components/features/app/layout/ContentPanel.vue similarity index 100% rename from frontend/src/components/features/app/layout/ContentPanel.vue rename to apps/frontend/src/components/features/app/layout/ContentPanel.vue diff --git a/frontend/src/components/features/app/layout/PanelStepTabs.vue b/apps/frontend/src/components/features/app/layout/PanelStepTabs.vue similarity index 100% rename from frontend/src/components/features/app/layout/PanelStepTabs.vue rename to apps/frontend/src/components/features/app/layout/PanelStepTabs.vue diff --git a/frontend/src/components/features/app/layout/SidebarNav.vue b/apps/frontend/src/components/features/app/layout/SidebarNav.vue similarity index 100% rename from frontend/src/components/features/app/layout/SidebarNav.vue rename to apps/frontend/src/components/features/app/layout/SidebarNav.vue diff --git a/frontend/src/components/features/app/layout/WorkspaceBackground.vue b/apps/frontend/src/components/features/app/layout/WorkspaceBackground.vue similarity index 100% rename from frontend/src/components/features/app/layout/WorkspaceBackground.vue rename to apps/frontend/src/components/features/app/layout/WorkspaceBackground.vue diff --git a/frontend/src/components/features/app/notes/NoteDetailPanel.vue b/apps/frontend/src/components/features/app/notes/NoteDetailPanel.vue similarity index 100% rename from frontend/src/components/features/app/notes/NoteDetailPanel.vue rename to apps/frontend/src/components/features/app/notes/NoteDetailPanel.vue diff --git a/frontend/src/components/features/app/notes/NoteInsightAside.vue b/apps/frontend/src/components/features/app/notes/NoteInsightAside.vue similarity index 100% rename from frontend/src/components/features/app/notes/NoteInsightAside.vue rename to apps/frontend/src/components/features/app/notes/NoteInsightAside.vue diff --git a/frontend/src/components/features/app/notes/NoteListPanel.vue b/apps/frontend/src/components/features/app/notes/NoteListPanel.vue similarity index 100% rename from frontend/src/components/features/app/notes/NoteListPanel.vue rename to apps/frontend/src/components/features/app/notes/NoteListPanel.vue diff --git a/frontend/src/components/features/app/notes/NoteToolbar.vue b/apps/frontend/src/components/features/app/notes/NoteToolbar.vue similarity index 100% rename from frontend/src/components/features/app/notes/NoteToolbar.vue rename to apps/frontend/src/components/features/app/notes/NoteToolbar.vue diff --git a/frontend/src/components/features/app/question/EditNoteDialog.vue b/apps/frontend/src/components/features/app/question/EditNoteDialog.vue similarity index 100% rename from frontend/src/components/features/app/question/EditNoteDialog.vue rename to apps/frontend/src/components/features/app/question/EditNoteDialog.vue diff --git a/frontend/src/components/features/app/question/QuestionCard.vue b/apps/frontend/src/components/features/app/question/QuestionCard.vue similarity index 100% rename from frontend/src/components/features/app/question/QuestionCard.vue rename to apps/frontend/src/components/features/app/question/QuestionCard.vue diff --git a/frontend/src/components/features/app/question/QuestionDetailModal.vue b/apps/frontend/src/components/features/app/question/QuestionDetailModal.vue similarity index 100% rename from frontend/src/components/features/app/question/QuestionDetailModal.vue rename to apps/frontend/src/components/features/app/question/QuestionDetailModal.vue diff --git a/frontend/src/components/features/app/question/QuestionItem.vue b/apps/frontend/src/components/features/app/question/QuestionItem.vue similarity index 100% rename from frontend/src/components/features/app/question/QuestionItem.vue rename to apps/frontend/src/components/features/app/question/QuestionItem.vue diff --git a/frontend/src/components/features/app/question/QuestionItemSkeleton.vue b/apps/frontend/src/components/features/app/question/QuestionItemSkeleton.vue similarity index 100% rename from frontend/src/components/features/app/question/QuestionItemSkeleton.vue rename to apps/frontend/src/components/features/app/question/QuestionItemSkeleton.vue diff --git a/frontend/src/components/features/app/question/QuestionList.vue b/apps/frontend/src/components/features/app/question/QuestionList.vue similarity index 100% rename from frontend/src/components/features/app/question/QuestionList.vue rename to apps/frontend/src/components/features/app/question/QuestionList.vue diff --git a/frontend/src/components/features/app/review/AiAnalysisModal.vue b/apps/frontend/src/components/features/app/review/AiAnalysisModal.vue similarity index 100% rename from frontend/src/components/features/app/review/AiAnalysisModal.vue rename to apps/frontend/src/components/features/app/review/AiAnalysisModal.vue diff --git a/frontend/src/components/features/app/settings/ProviderDialog.vue b/apps/frontend/src/components/features/app/settings/ProviderDialog.vue similarity index 100% rename from frontend/src/components/features/app/settings/ProviderDialog.vue rename to apps/frontend/src/components/features/app/settings/ProviderDialog.vue diff --git a/frontend/src/components/features/app/settings/ProviderSection.vue b/apps/frontend/src/components/features/app/settings/ProviderSection.vue similarity index 100% rename from frontend/src/components/features/app/settings/ProviderSection.vue rename to apps/frontend/src/components/features/app/settings/ProviderSection.vue diff --git a/frontend/src/components/features/app/shared/ViewSettingsPopover.vue b/apps/frontend/src/components/features/app/shared/ViewSettingsPopover.vue similarity index 100% rename from frontend/src/components/features/app/shared/ViewSettingsPopover.vue rename to apps/frontend/src/components/features/app/shared/ViewSettingsPopover.vue diff --git a/frontend/src/components/features/app/workspace/ActionBar.vue b/apps/frontend/src/components/features/app/workspace/ActionBar.vue similarity index 100% rename from frontend/src/components/features/app/workspace/ActionBar.vue rename to apps/frontend/src/components/features/app/workspace/ActionBar.vue diff --git a/frontend/src/components/features/app/workspace/AnswerInputModal.vue b/apps/frontend/src/components/features/app/workspace/AnswerInputModal.vue similarity index 100% rename from frontend/src/components/features/app/workspace/AnswerInputModal.vue rename to apps/frontend/src/components/features/app/workspace/AnswerInputModal.vue diff --git a/frontend/src/components/features/app/workspace/ErasePreview.vue b/apps/frontend/src/components/features/app/workspace/ErasePreview.vue similarity index 100% rename from frontend/src/components/features/app/workspace/ErasePreview.vue rename to apps/frontend/src/components/features/app/workspace/ErasePreview.vue diff --git a/frontend/src/components/features/app/workspace/FileList.vue b/apps/frontend/src/components/features/app/workspace/FileList.vue similarity index 100% rename from frontend/src/components/features/app/workspace/FileList.vue rename to apps/frontend/src/components/features/app/workspace/FileList.vue diff --git a/frontend/src/components/features/app/workspace/FileUploader.vue b/apps/frontend/src/components/features/app/workspace/FileUploader.vue similarity index 100% rename from frontend/src/components/features/app/workspace/FileUploader.vue rename to apps/frontend/src/components/features/app/workspace/FileUploader.vue diff --git a/frontend/src/components/features/app/workspace/ModelProviderSelect.vue b/apps/frontend/src/components/features/app/workspace/ModelProviderSelect.vue similarity index 100% rename from frontend/src/components/features/app/workspace/ModelProviderSelect.vue rename to apps/frontend/src/components/features/app/workspace/ModelProviderSelect.vue diff --git a/frontend/src/components/features/app/workspace/OcrPreview.vue b/apps/frontend/src/components/features/app/workspace/OcrPreview.vue similarity index 100% rename from frontend/src/components/features/app/workspace/OcrPreview.vue rename to apps/frontend/src/components/features/app/workspace/OcrPreview.vue diff --git a/frontend/src/components/features/app/workspace/ReviewStage.vue b/apps/frontend/src/components/features/app/workspace/ReviewStage.vue similarity index 100% rename from frontend/src/components/features/app/workspace/ReviewStage.vue rename to apps/frontend/src/components/features/app/workspace/ReviewStage.vue diff --git a/frontend/src/components/features/app/workspace/SelectionPanel.vue b/apps/frontend/src/components/features/app/workspace/SelectionPanel.vue similarity index 100% rename from frontend/src/components/features/app/workspace/SelectionPanel.vue rename to apps/frontend/src/components/features/app/workspace/SelectionPanel.vue diff --git a/frontend/src/components/features/app/workspace/SplitLoading.vue b/apps/frontend/src/components/features/app/workspace/SplitLoading.vue similarity index 100% rename from frontend/src/components/features/app/workspace/SplitLoading.vue rename to apps/frontend/src/components/features/app/workspace/SplitLoading.vue diff --git a/frontend/src/components/features/app/workspace/StatusBar.vue b/apps/frontend/src/components/features/app/workspace/StatusBar.vue similarity index 100% rename from frontend/src/components/features/app/workspace/StatusBar.vue rename to apps/frontend/src/components/features/app/workspace/StatusBar.vue diff --git a/frontend/src/components/features/app/workspace/StepIndicator.vue b/apps/frontend/src/components/features/app/workspace/StepIndicator.vue similarity index 100% rename from frontend/src/components/features/app/workspace/StepIndicator.vue rename to apps/frontend/src/components/features/app/workspace/StepIndicator.vue diff --git a/frontend/src/components/features/app/workspace/UploadStage.vue b/apps/frontend/src/components/features/app/workspace/UploadStage.vue similarity index 100% rename from frontend/src/components/features/app/workspace/UploadStage.vue rename to apps/frontend/src/components/features/app/workspace/UploadStage.vue diff --git a/frontend/src/components/features/auth/ForgotPasswordModal.vue b/apps/frontend/src/components/features/auth/ForgotPasswordModal.vue similarity index 100% rename from frontend/src/components/features/auth/ForgotPasswordModal.vue rename to apps/frontend/src/components/features/auth/ForgotPasswordModal.vue diff --git a/frontend/src/components/features/home/FeatureCard.vue b/apps/frontend/src/components/features/home/FeatureCard.vue similarity index 100% rename from frontend/src/components/features/home/FeatureCard.vue rename to apps/frontend/src/components/features/home/FeatureCard.vue diff --git a/frontend/src/components/features/home/HomeFooter.vue b/apps/frontend/src/components/features/home/HomeFooter.vue similarity index 100% rename from frontend/src/components/features/home/HomeFooter.vue rename to apps/frontend/src/components/features/home/HomeFooter.vue diff --git a/frontend/src/components/features/home/HomeHeader.vue b/apps/frontend/src/components/features/home/HomeHeader.vue similarity index 100% rename from frontend/src/components/features/home/HomeHeader.vue rename to apps/frontend/src/components/features/home/HomeHeader.vue diff --git a/frontend/src/components/features/home/HomePill.vue b/apps/frontend/src/components/features/home/HomePill.vue similarity index 100% rename from frontend/src/components/features/home/HomePill.vue rename to apps/frontend/src/components/features/home/HomePill.vue diff --git a/frontend/src/components/features/home/HomeSideNav.vue b/apps/frontend/src/components/features/home/HomeSideNav.vue similarity index 100% rename from frontend/src/components/features/home/HomeSideNav.vue rename to apps/frontend/src/components/features/home/HomeSideNav.vue diff --git a/frontend/src/components/features/home/WorkflowStep.vue b/apps/frontend/src/components/features/home/WorkflowStep.vue similarity index 100% rename from frontend/src/components/features/home/WorkflowStep.vue rename to apps/frontend/src/components/features/home/WorkflowStep.vue diff --git a/frontend/src/composables/useAiChatSessions.ts b/apps/frontend/src/composables/useAiChatSessions.ts similarity index 100% rename from frontend/src/composables/useAiChatSessions.ts rename to apps/frontend/src/composables/useAiChatSessions.ts diff --git a/frontend/src/composables/useAuth.ts b/apps/frontend/src/composables/useAuth.ts similarity index 100% rename from frontend/src/composables/useAuth.ts rename to apps/frontend/src/composables/useAuth.ts diff --git a/frontend/src/composables/useChatSession.ts b/apps/frontend/src/composables/useChatSession.ts similarity index 100% rename from frontend/src/composables/useChatSession.ts rename to apps/frontend/src/composables/useChatSession.ts diff --git a/frontend/src/composables/useClickOutside.ts b/apps/frontend/src/composables/useClickOutside.ts similarity index 100% rename from frontend/src/composables/useClickOutside.ts rename to apps/frontend/src/composables/useClickOutside.ts diff --git a/frontend/src/composables/useDropdownPosition.ts b/apps/frontend/src/composables/useDropdownPosition.ts similarity index 100% rename from frontend/src/composables/useDropdownPosition.ts rename to apps/frontend/src/composables/useDropdownPosition.ts diff --git a/frontend/src/composables/useErrorBankActions.ts b/apps/frontend/src/composables/useErrorBankActions.ts similarity index 100% rename from frontend/src/composables/useErrorBankActions.ts rename to apps/frontend/src/composables/useErrorBankActions.ts diff --git a/frontend/src/composables/useErrorBankQuery.ts b/apps/frontend/src/composables/useErrorBankQuery.ts similarity index 100% rename from frontend/src/composables/useErrorBankQuery.ts rename to apps/frontend/src/composables/useErrorBankQuery.ts diff --git a/frontend/src/composables/useErrorBankStats.ts b/apps/frontend/src/composables/useErrorBankStats.ts similarity index 100% rename from frontend/src/composables/useErrorBankStats.ts rename to apps/frontend/src/composables/useErrorBankStats.ts diff --git a/frontend/src/composables/useFileUpload.ts b/apps/frontend/src/composables/useFileUpload.ts similarity index 100% rename from frontend/src/composables/useFileUpload.ts rename to apps/frontend/src/composables/useFileUpload.ts diff --git a/frontend/src/composables/useImageModal.ts b/apps/frontend/src/composables/useImageModal.ts similarity index 100% rename from frontend/src/composables/useImageModal.ts rename to apps/frontend/src/composables/useImageModal.ts diff --git a/frontend/src/composables/useOverlay.ts b/apps/frontend/src/composables/useOverlay.ts similarity index 100% rename from frontend/src/composables/useOverlay.ts rename to apps/frontend/src/composables/useOverlay.ts diff --git a/frontend/src/composables/usePageTransition.ts b/apps/frontend/src/composables/usePageTransition.ts similarity index 100% rename from frontend/src/composables/usePageTransition.ts rename to apps/frontend/src/composables/usePageTransition.ts diff --git a/frontend/src/composables/usePaginatedList.ts b/apps/frontend/src/composables/usePaginatedList.ts similarity index 100% rename from frontend/src/composables/usePaginatedList.ts rename to apps/frontend/src/composables/usePaginatedList.ts diff --git a/frontend/src/composables/useProjects.ts b/apps/frontend/src/composables/useProjects.ts similarity index 100% rename from frontend/src/composables/useProjects.ts rename to apps/frontend/src/composables/useProjects.ts diff --git a/frontend/src/composables/useQuestionList.ts b/apps/frontend/src/composables/useQuestionList.ts similarity index 100% rename from frontend/src/composables/useQuestionList.ts rename to apps/frontend/src/composables/useQuestionList.ts diff --git a/frontend/src/composables/useSelectableList.ts b/apps/frontend/src/composables/useSelectableList.ts similarity index 100% rename from frontend/src/composables/useSelectableList.ts rename to apps/frontend/src/composables/useSelectableList.ts diff --git a/frontend/src/composables/useSidebarIndicator.ts b/apps/frontend/src/composables/useSidebarIndicator.ts similarity index 100% rename from frontend/src/composables/useSidebarIndicator.ts rename to apps/frontend/src/composables/useSidebarIndicator.ts diff --git a/frontend/src/composables/useSplitPipeline.ts b/apps/frontend/src/composables/useSplitPipeline.ts similarity index 100% rename from frontend/src/composables/useSplitPipeline.ts rename to apps/frontend/src/composables/useSplitPipeline.ts diff --git a/frontend/src/composables/useSystemStatus.ts b/apps/frontend/src/composables/useSystemStatus.ts similarity index 100% rename from frontend/src/composables/useSystemStatus.ts rename to apps/frontend/src/composables/useSystemStatus.ts diff --git a/frontend/src/composables/useTheme.ts b/apps/frontend/src/composables/useTheme.ts similarity index 100% rename from frontend/src/composables/useTheme.ts rename to apps/frontend/src/composables/useTheme.ts diff --git a/frontend/src/composables/useToast.ts b/apps/frontend/src/composables/useToast.ts similarity index 100% rename from frontend/src/composables/useToast.ts rename to apps/frontend/src/composables/useToast.ts diff --git a/frontend/src/composables/useWorkspaceNav.ts b/apps/frontend/src/composables/useWorkspaceNav.ts similarity index 100% rename from frontend/src/composables/useWorkspaceNav.ts rename to apps/frontend/src/composables/useWorkspaceNav.ts diff --git a/frontend/src/composables/useWorkspaceToast.ts b/apps/frontend/src/composables/useWorkspaceToast.ts similarity index 100% rename from frontend/src/composables/useWorkspaceToast.ts rename to apps/frontend/src/composables/useWorkspaceToast.ts diff --git a/frontend/src/main.ts b/apps/frontend/src/main.ts similarity index 100% rename from frontend/src/main.ts rename to apps/frontend/src/main.ts diff --git a/frontend/src/router/index.ts b/apps/frontend/src/router/index.ts similarity index 100% rename from frontend/src/router/index.ts rename to apps/frontend/src/router/index.ts diff --git a/frontend/src/shims-vue.d.ts b/apps/frontend/src/shims-vue.d.ts similarity index 100% rename from frontend/src/shims-vue.d.ts rename to apps/frontend/src/shims-vue.d.ts diff --git a/frontend/src/style.css b/apps/frontend/src/style.css similarity index 100% rename from frontend/src/style.css rename to apps/frontend/src/style.css diff --git a/frontend/src/types/domain.ts b/apps/frontend/src/types/domain.ts similarity index 100% rename from frontend/src/types/domain.ts rename to apps/frontend/src/types/domain.ts diff --git a/frontend/src/utils/file.ts b/apps/frontend/src/utils/file.ts similarity index 100% rename from frontend/src/utils/file.ts rename to apps/frontend/src/utils/file.ts diff --git a/frontend/src/utils/format.ts b/apps/frontend/src/utils/format.ts similarity index 100% rename from frontend/src/utils/format.ts rename to apps/frontend/src/utils/format.ts diff --git a/frontend/src/utils/html.ts b/apps/frontend/src/utils/html.ts similarity index 100% rename from frontend/src/utils/html.ts rename to apps/frontend/src/utils/html.ts diff --git a/frontend/src/utils/id.ts b/apps/frontend/src/utils/id.ts similarity index 100% rename from frontend/src/utils/id.ts rename to apps/frontend/src/utils/id.ts diff --git a/frontend/src/utils/index.ts b/apps/frontend/src/utils/index.ts similarity index 100% rename from frontend/src/utils/index.ts rename to apps/frontend/src/utils/index.ts diff --git a/frontend/src/utils/markdown.ts b/apps/frontend/src/utils/markdown.ts similarity index 100% rename from frontend/src/utils/markdown.ts rename to apps/frontend/src/utils/markdown.ts diff --git a/frontend/src/utils/mathjax.ts b/apps/frontend/src/utils/mathjax.ts similarity index 100% rename from frontend/src/utils/mathjax.ts rename to apps/frontend/src/utils/mathjax.ts diff --git a/frontend/src/utils/note.ts b/apps/frontend/src/utils/note.ts similarity index 100% rename from frontend/src/utils/note.ts rename to apps/frontend/src/utils/note.ts diff --git a/frontend/src/utils/question.ts b/apps/frontend/src/utils/question.ts similarity index 100% rename from frontend/src/utils/question.ts rename to apps/frontend/src/utils/question.ts diff --git a/frontend/src/utils/scale.ts b/apps/frontend/src/utils/scale.ts similarity index 100% rename from frontend/src/utils/scale.ts rename to apps/frontend/src/utils/scale.ts diff --git a/frontend/src/views/HomeView.vue b/apps/frontend/src/views/HomeView.vue similarity index 100% rename from frontend/src/views/HomeView.vue rename to apps/frontend/src/views/HomeView.vue diff --git a/frontend/src/views/app/AppLayout.vue b/apps/frontend/src/views/app/AppLayout.vue similarity index 100% rename from frontend/src/views/app/AppLayout.vue rename to apps/frontend/src/views/app/AppLayout.vue diff --git a/frontend/src/views/app/ChatPageView.vue b/apps/frontend/src/views/app/ChatPageView.vue similarity index 100% rename from frontend/src/views/app/ChatPageView.vue rename to apps/frontend/src/views/app/ChatPageView.vue diff --git a/frontend/src/views/app/ChatView.vue b/apps/frontend/src/views/app/ChatView.vue similarity index 100% rename from frontend/src/views/app/ChatView.vue rename to apps/frontend/src/views/app/ChatView.vue diff --git a/frontend/src/views/app/ComponentPreviewView.vue b/apps/frontend/src/views/app/ComponentPreviewView.vue similarity index 100% rename from frontend/src/views/app/ComponentPreviewView.vue rename to apps/frontend/src/views/app/ComponentPreviewView.vue diff --git a/frontend/src/views/app/DashboardView.vue b/apps/frontend/src/views/app/DashboardView.vue similarity index 100% rename from frontend/src/views/app/DashboardView.vue rename to apps/frontend/src/views/app/DashboardView.vue diff --git a/frontend/src/views/app/ErrorBankView.vue b/apps/frontend/src/views/app/ErrorBankView.vue similarity index 100% rename from frontend/src/views/app/ErrorBankView.vue rename to apps/frontend/src/views/app/ErrorBankView.vue diff --git a/frontend/src/views/app/NoteView.vue b/apps/frontend/src/views/app/NoteView.vue similarity index 100% rename from frontend/src/views/app/NoteView.vue rename to apps/frontend/src/views/app/NoteView.vue diff --git a/frontend/src/views/app/ReviewView.vue b/apps/frontend/src/views/app/ReviewView.vue similarity index 100% rename from frontend/src/views/app/ReviewView.vue rename to apps/frontend/src/views/app/ReviewView.vue diff --git a/frontend/src/views/app/SearchHubView.vue b/apps/frontend/src/views/app/SearchHubView.vue similarity index 100% rename from frontend/src/views/app/SearchHubView.vue rename to apps/frontend/src/views/app/SearchHubView.vue diff --git a/frontend/src/views/app/SettingsView.vue b/apps/frontend/src/views/app/SettingsView.vue similarity index 100% rename from frontend/src/views/app/SettingsView.vue rename to apps/frontend/src/views/app/SettingsView.vue diff --git a/frontend/src/views/app/SplitHistoryView.vue b/apps/frontend/src/views/app/SplitHistoryView.vue similarity index 100% rename from frontend/src/views/app/SplitHistoryView.vue rename to apps/frontend/src/views/app/SplitHistoryView.vue diff --git a/frontend/src/views/app/WorkspaceView.vue b/apps/frontend/src/views/app/WorkspaceView.vue similarity index 100% rename from frontend/src/views/app/WorkspaceView.vue rename to apps/frontend/src/views/app/WorkspaceView.vue diff --git a/frontend/src/views/auth/AuthLayout.vue b/apps/frontend/src/views/auth/AuthLayout.vue similarity index 100% rename from frontend/src/views/auth/AuthLayout.vue rename to apps/frontend/src/views/auth/AuthLayout.vue diff --git a/frontend/src/views/auth/LoginView.vue b/apps/frontend/src/views/auth/LoginView.vue similarity index 100% rename from frontend/src/views/auth/LoginView.vue rename to apps/frontend/src/views/auth/LoginView.vue diff --git a/frontend/src/views/auth/RegisterView.vue b/apps/frontend/src/views/auth/RegisterView.vue similarity index 100% rename from frontend/src/views/auth/RegisterView.vue rename to apps/frontend/src/views/auth/RegisterView.vue diff --git a/frontend/src/views/home/HomeDemo.vue b/apps/frontend/src/views/home/HomeDemo.vue similarity index 100% rename from frontend/src/views/home/HomeDemo.vue rename to apps/frontend/src/views/home/HomeDemo.vue diff --git a/frontend/src/views/home/HomeFeatures.vue b/apps/frontend/src/views/home/HomeFeatures.vue similarity index 100% rename from frontend/src/views/home/HomeFeatures.vue rename to apps/frontend/src/views/home/HomeFeatures.vue diff --git a/frontend/src/views/home/HomeHero.vue b/apps/frontend/src/views/home/HomeHero.vue similarity index 100% rename from frontend/src/views/home/HomeHero.vue rename to apps/frontend/src/views/home/HomeHero.vue diff --git a/frontend/src/views/home/HomeWorkflow.vue b/apps/frontend/src/views/home/HomeWorkflow.vue similarity index 100% rename from frontend/src/views/home/HomeWorkflow.vue rename to apps/frontend/src/views/home/HomeWorkflow.vue diff --git a/frontend/src/vite-env.d.ts b/apps/frontend/src/vite-env.d.ts similarity index 100% rename from frontend/src/vite-env.d.ts rename to apps/frontend/src/vite-env.d.ts diff --git a/frontend/tailwind.config.ts b/apps/frontend/tailwind.config.ts similarity index 100% rename from frontend/tailwind.config.ts rename to apps/frontend/tailwind.config.ts diff --git a/frontend/tsconfig.json b/apps/frontend/tsconfig.json similarity index 100% rename from frontend/tsconfig.json rename to apps/frontend/tsconfig.json diff --git a/frontend/vite.config.ts b/apps/frontend/vite.config.ts similarity index 100% rename from frontend/vite.config.ts rename to apps/frontend/vite.config.ts diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 00000000..9913a960 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ +.playwright-mcp/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/mobile/.metadata b/apps/mobile/.metadata new file mode 100644 index 00000000..c85d80ab --- /dev/null +++ b/apps/mobile/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "20f82749394e68bcfbbeee96bad384abaae09c13" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: android + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: ios + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: linux + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: macos + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: web + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + - platform: windows + create_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + base_revision: 20f82749394e68bcfbbeee96bad384abaae09c13 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 00000000..26867b33 --- /dev/null +++ b/apps/mobile/README.md @@ -0,0 +1,60 @@ +# 错题本 Flutter 客户端 + +`apps/mobile` 是错题本的 Flutter 客户端,提供登录、错题整理、AI 对话、错题库以及 ESP32 设备配网功能。 + +## 开发环境 + +- Flutter 3.44.x stable +- Dart 3.12.x +- Android Studio(Android debug 构建) +- Xcode(iOS 构建,仅 macOS) + +依赖版本和传递依赖由 `pubspec.lock` 固定。进入本目录后安装依赖: + +```bash +flutter pub get +``` + +## API 地址 + +默认 API 地址为 `https://lamp.dianchuang.club`。本地或测试环境请使用 Dart define 覆盖,不要把本地地址提交到源码: + +```bash +flutter run --dart-define=API_BASE_URL=https://example.com +``` + +客户端只会向 API 同源地址发送 Session Cookie;跨域图片下载不会携带认证信息。 + +## 启动和权限 + +Android debug: + +```bash +flutter run -d android +``` + +iOS 需要在 macOS 上使用 Xcode 配置签名后运行。ESP32 配网需要用户授予蓝牙权限;旧版 Android 还需要使用期间的位置权限,Android 12 及以上使用蓝牙扫描和连接权限。应用不会记录 Wi-Fi 密码或 Session Cookie。 + +当前主要验证目标是 Android 和 iOS 手机端。Web、Windows、Linux、macOS 的 Flutter scaffold 保留在仓库中,但尚未作为正式发布平台完成验证。 + +## 测试和检查 + +```bash +dart format --output=none --set-exit-if-changed lib test +flutter analyze +flutter test +flutter build apk --debug +``` + +## Android release 签名 + +复制 `android/key.properties.example` 为 `android/key.properties`,填入本地 keystore 信息。`key.properties`、keystore 和 JKS 文件已被忽略,不能提交到仓库。没有该文件时不影响 debug 构建;发布构建不会静默使用 debug 密钥。 + +## 不应提交的文件 + +不要提交 `.dart_tool/`、`build/`、`coverage/`、`.playwright-mcp/`、Gradle 报告、`key.properties`、任何 keystore、`.env` 或本地日志。 + +## 已知限制 + +- BLE 配网和硬件图片上传需要真实 ESP32 设备,单元测试只覆盖协议和数据转换逻辑。 +- 移动端 CI 验证依赖 pub.dev 网络可用;网络或证书问题应作为环境阻塞单独记录。 diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml new file mode 100644 index 00000000..0d290213 --- /dev/null +++ b/apps/mobile/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore new file mode 100644 index 00000000..be3943c9 --- /dev/null +++ b/apps/mobile/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/mobile/android/app/build.gradle.kts b/apps/mobile/android/app/build.gradle.kts new file mode 100644 index 00000000..3bd7dd0a --- /dev/null +++ b/apps/mobile/android/app/build.gradle.kts @@ -0,0 +1,71 @@ +import java.util.Properties +import java.io.FileInputStream + +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") + +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + +val releaseSigningReady = keystorePropertiesFile.exists() && listOf( + "keyAlias", + "keyPassword", + "storeFile", + "storePassword", +).all { key -> + !keystoreProperties.getProperty(key).isNullOrBlank() +} + +android { + namespace = "com.example.error_log_app" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + applicationId = "com.example.error_log_app" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + signingConfigs { + if (releaseSigningReady) { + create("release") { + keyAlias = keystoreProperties.getProperty("keyAlias") + keyPassword = keystoreProperties.getProperty("keyPassword") + storeFile = file(keystoreProperties.getProperty("storeFile")!!) + storePassword = keystoreProperties.getProperty("storePassword") + } + } + } + + buildTypes { + release { + if (releaseSigningReady) { + signingConfig = signingConfigs.getByName("release") + } + } + } +} + +flutter { + source = "../.." +} diff --git a/apps/mobile/android/app/src/debug/AndroidManifest.xml b/apps/mobile/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/apps/mobile/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..662c0a81 --- /dev/null +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/kotlin/com/example/error_log_app/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/example/error_log_app/MainActivity.kt new file mode 100644 index 00000000..682a0f51 --- /dev/null +++ b/apps/mobile/android/app/src/main/kotlin/com/example/error_log_app/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.error_log_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 00000000..ff72df8a --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/drawable/launch_background.xml b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..26ca43c9 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..b3dd6867 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/logo.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/logo.png new file mode 100644 index 00000000..d6190157 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/logo.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-hdpi/splash_brand.png b/apps/mobile/android/app/src/main/res/mipmap-hdpi/splash_brand.png new file mode 100644 index 00000000..011aa3d8 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-hdpi/splash_brand.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..28128e45 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/logo.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/logo.png new file mode 100644 index 00000000..c0719636 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/logo.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-mdpi/splash_brand.png b/apps/mobile/android/app/src/main/res/mipmap-mdpi/splash_brand.png new file mode 100644 index 00000000..9dc13b45 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-mdpi/splash_brand.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..d973ad0d Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/logo.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/logo.png new file mode 100644 index 00000000..997f9506 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/logo.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xhdpi/splash_brand.png b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/splash_brand.png new file mode 100644 index 00000000..b94145b9 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xhdpi/splash_brand.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..5bc861b9 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/logo.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/logo.png new file mode 100644 index 00000000..ead9e94f Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/logo.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/splash_brand.png b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/splash_brand.png new file mode 100644 index 00000000..b6d7e706 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxhdpi/splash_brand.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..03b846de Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/logo.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/logo.png new file mode 100644 index 00000000..0cbef925 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/logo.png differ diff --git a/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/splash_brand.png b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/splash_brand.png new file mode 100644 index 00000000..8f296f48 Binary files /dev/null and b/apps/mobile/android/app/src/main/res/mipmap-xxxhdpi/splash_brand.png differ diff --git a/apps/mobile/android/app/src/main/res/values-night/styles.xml b/apps/mobile/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 00000000..06952be7 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/main/res/values/styles.xml b/apps/mobile/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..cb1ef880 --- /dev/null +++ b/apps/mobile/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/mobile/android/app/src/profile/AndroidManifest.xml b/apps/mobile/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..399f6981 --- /dev/null +++ b/apps/mobile/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/mobile/android/build.gradle.kts b/apps/mobile/android/build.gradle.kts new file mode 100644 index 00000000..dbee657b --- /dev/null +++ b/apps/mobile/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/mobile/android/gradle.properties b/apps/mobile/android/gradle.properties new file mode 100644 index 00000000..f018a618 --- /dev/null +++ b/apps/mobile/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..ac3b4792 --- /dev/null +++ b/apps/mobile/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/apps/mobile/android/key.properties.example b/apps/mobile/android/key.properties.example new file mode 100644 index 00000000..b996eb49 --- /dev/null +++ b/apps/mobile/android/key.properties.example @@ -0,0 +1,4 @@ +storeFile=path/to/release-keystore.jks +keyAlias=error-correction +keyPassword=change-me +storePassword=change-me diff --git a/apps/mobile/android/settings.gradle.kts b/apps/mobile/android/settings.gradle.kts new file mode 100644 index 00000000..cd0048ad --- /dev/null +++ b/apps/mobile/android/settings.gradle.kts @@ -0,0 +1,41 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + maven("https://maven.aliyun.com/repository/gradle-plugin") + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/public") + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) + repositories { + maven("https://maven.aliyun.com/repository/google") + maven("https://maven.aliyun.com/repository/public") + maven("https://storage.googleapis.com/download.flutter.io") + google() + mavenCentral() + } +} + +include(":app") diff --git a/ui-showcase/public/logo.svg b/apps/mobile/assets/logo.svg similarity index 100% rename from ui-showcase/public/logo.svg rename to apps/mobile/assets/logo.svg diff --git a/apps/mobile/devtools_options.yaml b/apps/mobile/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/apps/mobile/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/apps/mobile/ios/.gitignore b/apps/mobile/ios/.gitignore new file mode 100644 index 00000000..7a7f9873 --- /dev/null +++ b/apps/mobile/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/apps/mobile/ios/Flutter/AppFrameworkInfo.plist b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..1dc6cf76 --- /dev/null +++ b/apps/mobile/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/apps/mobile/ios/Flutter/Debug.xcconfig b/apps/mobile/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/apps/mobile/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Flutter/Release.xcconfig b/apps/mobile/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..592ceee8 --- /dev/null +++ b/apps/mobile/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..74f321d9 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..919434a6 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..e3773d42 --- /dev/null +++ b/apps/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/apps/mobile/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/apps/mobile/ios/Runner/AppDelegate.swift b/apps/mobile/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..62666446 --- /dev/null +++ b/apps/mobile/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..75322537 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..6a1c011e Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..887fa91e Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..e81a6ded Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..86faf1dc Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..0f50bf41 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..9cadc074 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..887fa91e Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..635f6b0f Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..b8b993b1 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..b8b993b1 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..25d4e5da Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..cdc36e08 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..f65d88de Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..44b4c84f Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9dc13b45 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..b94145b9 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..b6d7e706 Binary files /dev/null and b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/apps/mobile/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..196e7ed9 --- /dev/null +++ b/apps/mobile/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner/Base.lproj/Main.storyboard b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/apps/mobile/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist new file mode 100644 index 00000000..3b90b2b8 --- /dev/null +++ b/apps/mobile/ios/Runner/Info.plist @@ -0,0 +1,53 @@ + + + + + NSBluetoothAlwaysUsageDescription + 需要使用蓝牙 + NSBluetoothPeripheralUsageDescription + 需要使用蓝牙 + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Error Log App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + error_log_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/apps/mobile/ios/Runner/Runner-Bridging-Header.h b/apps/mobile/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/apps/mobile/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/apps/mobile/ios/RunnerTests/RunnerTests.swift b/apps/mobile/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..86a7c3b1 --- /dev/null +++ b/apps/mobile/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/mobile/lib/app/app.dart b/apps/mobile/lib/app/app.dart new file mode 100644 index 00000000..ea89e8d7 --- /dev/null +++ b/apps/mobile/lib/app/app.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../features/auth/data/auth_api.dart'; +import 'router/app_router.dart'; +import 'theme/app_theme.dart'; + +class MyApp extends StatefulWidget { + const MyApp({super.key, this.authApi}); + + final AuthApi? authApi; + + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + static const String _themeModeCacheKey = 'theme_mode'; + + late final ValueNotifier _themeModeNotifier = + ValueNotifier(ThemeMode.dark); + late final AuthApi _authApi; + + @override + void initState() { + super.initState(); + _authApi = widget.authApi ?? AuthApi(); + _loadThemeMode(); + } + + Future _loadThemeMode() async { + final prefs = await SharedPreferences.getInstance(); + final cachedThemeMode = prefs.getString(_themeModeCacheKey); + + if (!mounted || cachedThemeMode == null) { + return; + } + + _themeModeNotifier.value = + cachedThemeMode == 'light' ? ThemeMode.light : ThemeMode.dark; + } + + Future _toggleThemeMode() async { + final nextThemeMode = _themeModeNotifier.value == ThemeMode.dark + ? ThemeMode.light + : ThemeMode.dark; + + _themeModeNotifier.value = nextThemeMode; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _themeModeCacheKey, + nextThemeMode == ThemeMode.light ? 'light' : 'dark', + ); + } + + @override + void dispose() { + _themeModeNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _themeModeNotifier, + builder: (context, themeMode, _) { + return MaterialApp( + title: '智卷错题本', + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: themeMode, + initialRoute: AppRoutes.home, + onGenerateRoute: (settings) => AppRouter.onGenerateRoute( + settings, + authApi: _authApi, + themeModeListenable: _themeModeNotifier, + onToggleThemeMode: _toggleThemeMode, + ), + debugShowCheckedModeBanner: false, + ); + }, + ); + } +} diff --git a/apps/mobile/lib/app/router/app_router.dart b/apps/mobile/lib/app/router/app_router.dart new file mode 100644 index 00000000..abd6df12 --- /dev/null +++ b/apps/mobile/lib/app/router/app_router.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../features/auth/data/auth_api.dart'; +import '../../features/home/presentation/pages/home_page.dart'; +import '../../features/login/presentation/pages/login_page.dart'; +import '../../features/workspace/presentation/pages/workspace_page.dart'; + +class AppRoutes { + static const String home = '/'; + static const String login = '/login'; + static const String workspace = '/workspace'; +} + +class AppRouter { + static Route onGenerateRoute( + RouteSettings settings, { + required AuthApi authApi, + required ValueListenable themeModeListenable, + required VoidCallback onToggleThemeMode, + }) { + final Widget page = switch (settings.name) { + AppRoutes.login => LoginPage(authApi: authApi), + AppRoutes.workspace => WorkspacePage( + authApi: authApi, + themeModeListenable: themeModeListenable, + onToggleThemeMode: onToggleThemeMode, + ), + AppRoutes.home || null => HomePage( + authApi: authApi, + themeModeListenable: themeModeListenable, + onToggleThemeMode: onToggleThemeMode, + ), + _ => HomePage( + authApi: authApi, + themeModeListenable: themeModeListenable, + onToggleThemeMode: onToggleThemeMode, + ), + }; + + return MaterialPageRoute(settings: settings, builder: (_) => page); + } +} diff --git a/apps/mobile/lib/app/theme/app_theme.dart b/apps/mobile/lib/app/theme/app_theme.dart new file mode 100644 index 00000000..96ba7492 --- /dev/null +++ b/apps/mobile/lib/app/theme/app_theme.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const Color background = Color(0xFF080910); + static const Color backgroundAlt = Color(0xFF242426); + static const Color lightBackground = Color(0xFFF7F5FF); + static const Color lightBackgroundAlt = Color(0xFFFFFFFF); + static const Color primary = Color(0xFF8A73F5); + static const Color primaryLight = Color(0xFFA796FF); + static const Color textPrimary = Color(0xFFFFFFFF); + static const Color textSecondary = Color(0xFF9A98A8); + static const Color lightTextPrimary = Color(0xFF101323); + static const Color lightTextSecondary = Color(0xFF5F6373); + static const Color border = Color(0x33FFFFFF); + static const Color lightBorder = Color(0x1FA39CBC); + + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: background, + colorScheme: const ColorScheme.dark( + primary: primary, + secondary: primaryLight, + surface: backgroundAlt, + ), + fontFamily: 'Roboto', + ); + } + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: lightBackground, + colorScheme: const ColorScheme.light( + primary: primary, + secondary: primaryLight, + surface: lightBackgroundAlt, + onSurface: lightTextPrimary, + ), + fontFamily: 'Roboto', + ); + } +} + +class AppThemePalette { + const AppThemePalette({required this.isLight}); + + factory AppThemePalette.of(BuildContext context) { + return AppThemePalette( + isLight: Theme.of(context).brightness == Brightness.light, + ); + } + + final bool isLight; + + Color get pageBg => isLight ? AppTheme.lightBackground : AppTheme.background; + Color get cardBg => isLight + ? AppTheme.lightBackgroundAlt.withOpacity(0.78) + : AppTheme.background.withOpacity(0.5); + Color get panelBg => cardBg; + Color get panel => panelBg; + Color get panelBorder => isLight + ? AppTheme.lightBorder.withOpacity(0.15) + : AppTheme.border.withOpacity(0.08); + Color get border => panelBorder; + Color get panelBorderStrong => + isLight ? Colors.black.withOpacity(0.18) : const Color(0xFF2B2C34); + Color get primary => AppTheme.primary; + Color get primaryLight => AppTheme.primaryLight; + Color get primaryDeep => AppTheme.primary.withOpacity(0.84); + Color get textMain => + isLight ? AppTheme.lightTextPrimary : AppTheme.textPrimary; + Color get textSub => + isLight ? AppTheme.lightTextSecondary : AppTheme.textSecondary; + Color get chip => + isLight ? const Color(0xFFECE9FF) : Colors.white.withOpacity(0.08); + Color get subtleOverlay => + isLight ? Colors.black.withOpacity(0.08) : Colors.white.withOpacity(0.08); + Color get controlInactiveBg => + isLight ? Colors.black.withOpacity(0.16) : Colors.white.withOpacity(0.16); + Color get progressTrack => + isLight ? Colors.black.withOpacity(0.08) : const Color(0x20FFFFFF); + Color get menuBg => isLight ? Colors.white : const Color(0xFF202022); + Color get selectedBg => + isLight ? primary.withOpacity(0.08) : Colors.white.withOpacity(0.08); + Color get badgeBg => + isLight ? Colors.black.withOpacity(0.05) : Colors.white.withOpacity(0.08); + Color get divider => + isLight ? Colors.black.withOpacity(0.06) : Colors.white.withOpacity(0.08); + Color get imageBg => + isLight ? const Color(0xFFF5F6FA) : const Color(0xFF202126); + Color get emptyPaper => + isLight ? Colors.white : Colors.white.withOpacity(0.92); + Color get emptyPaperLine => + isLight ? Colors.black.withOpacity(0.08) : Colors.black.withOpacity(0.10); + Color get compareLine => isLight ? AppTheme.primary : const Color(0xFF8C78FF); + Color get errorText => const Color(0xFFFF6B6B); +} diff --git a/apps/mobile/lib/core/constants/app_assets.dart b/apps/mobile/lib/core/constants/app_assets.dart new file mode 100644 index 00000000..4a2455df --- /dev/null +++ b/apps/mobile/lib/core/constants/app_assets.dart @@ -0,0 +1,3 @@ +class AppAssets { + static const String logo = 'assets/logo.svg'; +} diff --git a/apps/mobile/lib/core/network/api_client.dart b/apps/mobile/lib/core/network/api_client.dart new file mode 100644 index 00000000..a7cbb032 --- /dev/null +++ b/apps/mobile/lib/core/network/api_client.dart @@ -0,0 +1,423 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +abstract interface class SessionStore { + Future read(); + + Future write(String value); + + Future delete(); +} + +class SecureSessionStore implements SessionStore { + SecureSessionStore({FlutterSecureStorage? storage}) + : _storage = storage ?? FlutterSecureStorage(); + + static const storageKey = 'auth_session_cookie'; + + final FlutterSecureStorage _storage; + + @override + Future read() => _storage.read(key: storageKey); + + @override + Future write(String value) => + _storage.write(key: storageKey, value: value); + + @override + Future delete() => _storage.delete(key: storageKey); +} + +class ApiClient { + ApiClient({ + String? baseUrl, + http.Client? httpClient, + SessionStore? sessionStore, + }) : baseUrl = _normalizeBaseUrl(baseUrl ?? defaultBaseUrl), + _httpClient = httpClient ?? http.Client(), + _sessionStore = sessionStore ?? SecureSessionStore(); + + static const defaultBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'https://lamp.dianchuang.club', + ); + static const sessionCookieKey = SecureSessionStore.storageKey; + static const requestTimeout = Duration(seconds: 30); + + final String baseUrl; + final http.Client _httpClient; + final SessionStore _sessionStore; + + Future> getJson( + String path, { + Set successCodes = const {200}, + }) { + return _sendJson(method: 'GET', path: path, successCodes: successCodes); + } + + Future> postJson( + String path, + Map body, { + Set successCodes = const {200}, + }) { + return _sendJson( + method: 'POST', + path: path, + body: body, + successCodes: successCodes, + ); + } + + Future> patchJson( + String path, + Map body, { + Set successCodes = const {200}, + }) { + return _sendJson( + method: 'PATCH', + path: path, + body: body, + successCodes: successCodes, + ); + } + + Future> deleteJson( + String path, { + Map? body, + Set successCodes = const {200}, + }) { + return _sendJson( + method: 'DELETE', + path: path, + body: body, + successCodes: successCodes, + ); + } + + Future> postMultipart( + String path, { + required Map fields, + required List files, + Set successCodes = const {200}, + }) async { + final uri = _apiUri(path); + final sessionCookie = await _readSessionCookie(); + final request = http.MultipartRequest('POST', uri) + ..fields.addAll(fields) + ..files.addAll(files); + request.headers.addAll({ + 'Accept': 'application/json', + if (sessionCookie != null && sessionCookie.isNotEmpty) + 'Cookie': sessionCookie, + }); + + final streamed = await _httpClient.send(request).timeout(requestTimeout); + final response = await http.Response.fromStream(streamed); + + await _handleSessionResponse(response, sameOrigin: true); + final payload = _decodeBody(response); + + if (!successCodes.contains(response.statusCode)) { + throw ApiException( + statusCode: response.statusCode, + message: _errorMessage(payload, response.statusCode), + payload: payload, + ); + } + + return payload; + } + + Stream postEventStream( + String path, + Map body, { + Set successCodes = const {200}, + }) async* { + final uri = _apiUri(path); + final sessionCookie = await _readSessionCookie(); + final request = http.Request('POST', uri) + ..headers.addAll({ + 'Accept': 'text/event-stream', + 'Content-Type': 'application/json', + if (sessionCookie != null && sessionCookie.isNotEmpty) + 'Cookie': sessionCookie, + }) + ..body = jsonEncode(body); + + final response = await _httpClient.send(request).timeout(requestTimeout); + + await _saveSessionCookie(response.headers['set-cookie'], sameOrigin: true); + if (response.statusCode == 401) { + await clearSession(); + } + + if (!successCodes.contains(response.statusCode)) { + final bodyBytes = await response.stream.toBytes(); + final payload = _decodeBytesPayload(bodyBytes); + throw ApiException( + statusCode: response.statusCode, + message: _errorMessage(payload, response.statusCode), + payload: payload, + ); + } + + yield* response.stream.transform(utf8.decoder); + } + + Future getBytes( + String pathOrUrl, { + Set successCodes = const {200}, + }) async { + final uri = _resolveUri(pathOrUrl); + final sameOrigin = _isSameOrigin(uri); + final sessionCookie = sameOrigin ? await _readSessionCookie() : null; + final response = await _httpClient.get( + uri, + headers: { + 'Accept': 'image/*,*/*', + if (sessionCookie != null && sessionCookie.isNotEmpty) + 'Cookie': sessionCookie, + }, + ).timeout(requestTimeout); + + await _handleSessionResponse(response, sameOrigin: sameOrigin); + + if (!successCodes.contains(response.statusCode)) { + throw ApiException( + statusCode: response.statusCode, + message: _bytesErrorMessage(response), + ); + } + + return response.bodyBytes; + } + + Future clearSession() async { + try { + await _sessionStore.delete(); + } finally { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(sessionCookieKey); + } + } + + Future hasSession() async { + final sessionCookie = await _readSessionCookie(); + return sessionCookie != null && sessionCookie.isNotEmpty; + } + + Future> _sendJson({ + required String method, + required String path, + required Set successCodes, + Map? body, + }) async { + final uri = _apiUri(path); + final sessionCookie = await _readSessionCookie(); + final headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + if (sessionCookie != null && sessionCookie.isNotEmpty) + 'Cookie': sessionCookie, + }; + + final requestBody = body == null ? null : jsonEncode(body); + final response = switch (method) { + 'GET' => + await _httpClient.get(uri, headers: headers).timeout(requestTimeout), + 'POST' => await _httpClient + .post(uri, headers: headers, body: requestBody) + .timeout(requestTimeout), + 'PATCH' => await _httpClient + .patch(uri, headers: headers, body: requestBody) + .timeout(requestTimeout), + 'DELETE' => await _httpClient + .delete(uri, headers: headers, body: requestBody) + .timeout(requestTimeout), + _ => throw ArgumentError.value(method, 'method', 'Unsupported method'), + }; + + await _handleSessionResponse(response, sameOrigin: true); + final payload = _decodeBody(response); + + if (!successCodes.contains(response.statusCode)) { + throw ApiException( + statusCode: response.statusCode, + message: _errorMessage(payload, response.statusCode), + payload: payload, + ); + } + + return payload; + } + + Future _handleSessionResponse( + http.Response response, { + required bool sameOrigin, + }) async { + await _saveSessionCookie( + response.headers['set-cookie'], + sameOrigin: sameOrigin, + ); + if (sameOrigin && response.statusCode == 401) { + await clearSession(); + } + } + + Future _readSessionCookie() async { + final secureCookie = await _sessionStore.read(); + if (secureCookie != null && secureCookie.isNotEmpty) { + return secureCookie; + } + + // Migrate sessions created by older builds, then remove the plaintext copy. + final prefs = await SharedPreferences.getInstance(); + final legacyCookie = prefs.getString(sessionCookieKey); + if (legacyCookie == null || legacyCookie.isEmpty) { + return null; + } + + await _sessionStore.write(legacyCookie); + await prefs.remove(sessionCookieKey); + return legacyCookie; + } + + Future _saveSessionCookie( + String? setCookie, { + required bool sameOrigin, + }) async { + if (!sameOrigin || setCookie == null || setCookie.isEmpty) { + return; + } + + final match = RegExp(r'session=[^;,]+').firstMatch(setCookie); + if (match == null) { + return; + } + + await _sessionStore.write(match.group(0)!); + } + + Uri _apiUri(String path) { + final uri = _resolveUri(path); + if (!_isSameOrigin(uri)) { + throw const ApiException(statusCode: 0, message: '请求地址必须与 API 服务同源'); + } + return uri; + } + + Uri _resolveUri(String pathOrUrl) { + final parsed = Uri.tryParse(pathOrUrl); + if (parsed == null) { + throw const ApiException(statusCode: 0, message: '请求地址无效'); + } + + final uri = parsed.hasScheme + ? parsed + : Uri.parse( + '$baseUrl${pathOrUrl.startsWith('/') ? pathOrUrl : '/$pathOrUrl'}', + ); + if (!_isHttpScheme(uri.scheme) || uri.host.isEmpty) { + throw const ApiException(statusCode: 0, message: '仅支持 HTTP(S) 请求地址'); + } + return uri; + } + + bool _isSameOrigin(Uri uri) { + final base = Uri.parse(baseUrl); + return uri.scheme.toLowerCase() == base.scheme.toLowerCase() && + uri.host.toLowerCase() == base.host.toLowerCase() && + _effectivePort(uri) == _effectivePort(base); + } + + static int _effectivePort(Uri uri) { + if (uri.hasPort) { + return uri.port; + } + return uri.scheme.toLowerCase() == 'https' ? 443 : 80; + } + + static bool _isHttpScheme(String scheme) { + final normalized = scheme.toLowerCase(); + return normalized == 'http' || normalized == 'https'; + } + + static String _normalizeBaseUrl(String raw) { + final normalized = raw.trim().replaceFirst(RegExp(r'/+$'), ''); + final uri = Uri.tryParse(normalized); + if (uri == null || uri.host.isEmpty || !_isHttpScheme(uri.scheme)) { + throw ArgumentError.value(raw, 'baseUrl', '必须是有效的 HTTP(S) 地址'); + } + return normalized; + } + + Map _decodeBody(http.Response response) { + final rawBody = utf8.decode(response.bodyBytes); + if (rawBody.trim().isEmpty) { + return {}; + } + + try { + final decoded = jsonDecode(rawBody); + if (decoded is Map) { + return decoded; + } + return {'data': decoded}; + } on FormatException { + return {}; + } + } + + Map _decodeBytesPayload(List bodyBytes) { + final rawBody = utf8.decode(bodyBytes); + if (rawBody.trim().isEmpty) { + return {}; + } + + try { + final decoded = jsonDecode(rawBody); + if (decoded is Map) { + return decoded; + } + return {'data': decoded}; + } on FormatException { + return {}; + } + } + + String _errorMessage(Map payload, int statusCode) { + final error = payload['error'] ?? payload['message']; + if (error is String && error.trim().isNotEmpty) { + return error; + } + + return '请求失败,请稍后再试 ($statusCode)'; + } + + String _bytesErrorMessage(http.Response response) { + final payload = _decodeBytesPayload(response.bodyBytes); + return _errorMessage( + payload, + response.statusCode, + ).replaceFirst('请求失败', '资源加载失败'); + } +} + +class ApiException implements Exception { + const ApiException({ + required this.statusCode, + required this.message, + this.payload = const {}, + }); + + final int statusCode; + final String message; + final Map payload; + + @override + String toString() => 'ApiException($statusCode): $message'; +} diff --git a/apps/mobile/lib/core/utils/time_format.dart b/apps/mobile/lib/core/utils/time_format.dart new file mode 100644 index 00000000..ad8057a8 --- /dev/null +++ b/apps/mobile/lib/core/utils/time_format.dart @@ -0,0 +1,42 @@ +DateTime? parseBackendDateTime(Object? value) { + final text = value?.toString().trim(); + if (text == null || text.isEmpty || text == 'null') { + return null; + } + + final normalized = text.contains(' ') && !text.contains('T') + ? text.replaceFirst(' ', 'T') + : text; + final hasClock = RegExp( + r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}', + ).hasMatch(normalized); + final hasTimeZone = RegExp( + r'(?:[zZ]|[+-]\d{2}:?\d{2})$', + ).hasMatch(normalized); + final parseText = hasClock && !hasTimeZone ? '${normalized}Z' : normalized; + + return DateTime.tryParse(parseText)?.toLocal(); +} + +String formatRelativeTime(DateTime? time) { + if (time == null) { + return '暂无更新时间'; + } + + final local = time.toLocal(); + final diff = DateTime.now().difference(local); + if (diff.inMinutes < 1) { + return '刚刚更新'; + } + if (diff.inHours < 1) { + return '${diff.inMinutes} 分钟前'; + } + if (diff.inDays < 1) { + return '${diff.inHours} 小时前'; + } + if (diff.inDays < 30) { + return '${diff.inDays} 天前'; + } + + return '${local.month}月${local.day}日'; +} diff --git a/apps/mobile/lib/core/widgets/app_snack_bar.dart b/apps/mobile/lib/core/widgets/app_snack_bar.dart new file mode 100644 index 00000000..debb4eb2 --- /dev/null +++ b/apps/mobile/lib/core/widgets/app_snack_bar.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme/app_theme.dart'; + +SnackBar buildAppSnackBar(String message) { + return SnackBar( + content: Text(message, style: const TextStyle(color: Colors.white)), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: AppTheme.primary, + ); +} + +void showAppSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar(buildAppSnackBar(message)); +} diff --git a/apps/mobile/lib/core/widgets/gradient_action_button.dart b/apps/mobile/lib/core/widgets/gradient_action_button.dart new file mode 100644 index 00000000..23f0bc1d --- /dev/null +++ b/apps/mobile/lib/core/widgets/gradient_action_button.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme/app_theme.dart'; + +enum GradientActionButtonVariant { primary, secondary } + +class GradientActionButton extends StatelessWidget { + const GradientActionButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + this.variant = GradientActionButtonVariant.primary, + }); + + final String label; + final VoidCallback onPressed; + final IconData? icon; + final GradientActionButtonVariant variant; + + @override + Widget build(BuildContext context) { + final isPrimary = variant == GradientActionButtonVariant.primary; + + return Semantics( + button: true, + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onPressed, + child: Ink( + width: 160, + height: 50, + decoration: BoxDecoration( + gradient: isPrimary + ? const LinearGradient( + colors: [AppTheme.primary, AppTheme.primaryLight], + ) + : null, + color: isPrimary ? null : const Color(0x242B2B36), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppTheme.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18, color: AppTheme.textPrimary), + const SizedBox(width: 8), + ], + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + style: TextStyle( + color: isPrimary + ? AppTheme.textPrimary + : AppTheme.textSecondary, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/core/widgets/markdown_math_text.dart b/apps/mobile/lib/core/widgets/markdown_math_text.dart new file mode 100644 index 00000000..3513ad34 --- /dev/null +++ b/apps/mobile/lib/core/widgets/markdown_math_text.dart @@ -0,0 +1,963 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; + +import '../../app/theme/app_theme.dart'; + +typedef MarkdownImageBuilder = Widget Function( + BuildContext context, String alt, String url); + +class MarkdownMathText extends StatelessWidget { + const MarkdownMathText({ + super.key, + required this.text, + required this.palette, + this.style, + this.imageBuilder, + }); + + final String text; + final AppThemePalette palette; + final TextStyle? style; + final MarkdownImageBuilder? imageBuilder; + + @override + Widget build(BuildContext context) { + final effectiveStyle = DefaultTextStyle.of(context).style.merge(style); + final renderer = _MarkdownRenderer( + text: text, + palette: palette, + style: effectiveStyle, + imageBuilder: imageBuilder, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: renderer.buildBlocks(context), + ); + } +} + +class _MarkdownRenderer { + _MarkdownRenderer({ + required this.text, + required this.palette, + required this.style, + required this.imageBuilder, + }); + + final String text; + final AppThemePalette palette; + final TextStyle style; + final MarkdownImageBuilder? imageBuilder; + + List buildBlocks(BuildContext context) { + final normalized = text.replaceAll('\r\n', '\n'); + final lines = normalized.split('\n'); + final children = []; + var index = 0; + + while (index < lines.length) { + final rawLine = lines[index].trimRight(); + final trimmed = rawLine.trim(); + + if (trimmed.isEmpty) { + children.add(const SizedBox(height: 8)); + index += 1; + continue; + } + + if (trimmed.startsWith('```')) { + final codeLines = []; + index += 1; + while (index < lines.length && + !lines[index].trimLeft().startsWith('```')) { + codeLines.add(lines[index]); + index += 1; + } + if (index < lines.length) { + index += 1; + } + children.add(_buildCodeBlock(codeLines.join('\n'))); + continue; + } + + if (_startsDisplayMath(trimmed)) { + final result = _collectDisplayMath(lines, index); + children.add(_buildDisplayMath(result.expression)); + index = result.nextIndex; + continue; + } + + if (_isHorizontalRule(trimmed)) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Divider(height: 1, color: palette.divider), + ), + ); + index += 1; + continue; + } + + if (_isHtmlTableStart(trimmed)) { + final result = _collectHtmlTable(lines, index); + children.add(_buildTable(context, result.rows)); + index = result.nextIndex; + continue; + } + + if (_isTableStart(lines, index)) { + final result = _collectTable(lines, index); + children.add(_buildTable(context, result.rows)); + index = result.nextIndex; + continue; + } + + final imageMatch = _imageMatch(trimmed); + if (imageMatch != null) { + children.add(_buildImage(context, imageMatch.alt, imageMatch.url)); + index += 1; + continue; + } + + if (trimmed.startsWith('>')) { + final quoteLines = []; + while ( + index < lines.length && lines[index].trimLeft().startsWith('>')) { + quoteLines.add( + lines[index].trimLeft().replaceFirst(RegExp(r'^>\s?'), ''), + ); + index += 1; + } + children.add(_buildQuote(quoteLines.join('\n'))); + continue; + } + + final headingLevel = _headingLevel(trimmed); + if (headingLevel > 0) { + children.add( + _buildHeading(trimmed.substring(headingLevel).trim(), headingLevel), + ); + index += 1; + continue; + } + + final listMatch = _listMatch(trimmed); + if (listMatch != null) { + children.add(_buildListItem(listMatch.marker, listMatch.content)); + index += 1; + continue; + } + + final paragraphLines = [rawLine.trim()]; + index += 1; + while (index < lines.length && !_isBlockBoundary(lines, index)) { + paragraphLines.add(lines[index].trim()); + index += 1; + } + children.add(_buildParagraph(paragraphLines.join(' '))); + } + + return children; + } + + Widget _buildParagraph(String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _InlineMarkdownMathText( + text: value, + style: style, + palette: palette, + ), + ); + } + + Widget _buildHeading(String value, int level) { + final size = switch (level) { + 1 => 22.0, + 2 => 19.0, + 3 => 17.0, + _ => 15.5, + }; + return Padding( + padding: const EdgeInsets.only(top: 12, bottom: 6), + child: _InlineMarkdownMathText( + text: value, + style: style.copyWith( + fontSize: size, + fontWeight: FontWeight.w900, + height: 1.35, + ), + palette: palette, + ), + ); + } + + Widget _buildListItem(String marker, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 28, + child: Text( + marker, + style: style.copyWith( + color: palette.textSub, + fontWeight: FontWeight.w900, + ), + ), + ), + Expanded( + child: _InlineMarkdownMathText( + text: value, + style: style, + palette: palette, + ), + ), + ], + ), + ); + } + + Widget _buildQuote(String value) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.08), + border: Border(left: BorderSide(color: palette.primary, width: 3)), + ), + child: MarkdownMathText( + text: value, + style: style.copyWith( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + palette: palette, + imageBuilder: imageBuilder, + ), + ); + } + + Widget _buildImage(BuildContext context, String alt, String url) { + final builder = imageBuilder; + if (builder != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: builder(context, alt, url), + ); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + alt.trim().isEmpty ? url : alt, + style: style.copyWith( + color: palette.primaryLight, + decoration: TextDecoration.underline, + decorationColor: palette.primaryLight, + fontWeight: FontWeight.w800, + ), + ), + ); + } + + Widget _buildCodeBlock(String value) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.panelBorder), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SelectableText( + value, + style: style.copyWith( + fontFamily: 'monospace', + color: palette.textMain, + fontWeight: FontWeight.w600, + height: 1.55, + ), + ), + ), + ); + } + + Widget _buildDisplayMath(String expression) { + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 8), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.panelBorder), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Math.tex( + expression.trim(), + mathStyle: MathStyle.display, + textStyle: style, + onErrorFallback: (_) => + Text('\$\$${expression.trim()}\$\$', style: style), + ), + ), + ); + } + + Widget _buildTable(BuildContext context, List> rows) { + if (rows.isEmpty) { + return const SizedBox.shrink(); + } + + final maxColumns = rows.fold( + 0, + (max, row) => row.length > max ? row.length : max, + ); + + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: palette.panelBorder), + borderRadius: BorderRadius.circular(10), + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Table( + defaultColumnWidth: const IntrinsicColumnWidth(), + border: TableBorder( + horizontalInside: BorderSide(color: palette.panelBorder), + verticalInside: BorderSide(color: palette.panelBorder), + ), + children: rows.asMap().entries.map((entry) { + final rowIndex = entry.key; + final row = entry.value; + return TableRow( + decoration: BoxDecoration( + color: rowIndex == 0 ? palette.subtleOverlay : null, + ), + children: List.generate(maxColumns, (columnIndex) { + final value = columnIndex < row.length + ? row[columnIndex] + : const _TableCell(''); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 180), + child: _buildTableCell( + context, + value, + style.copyWith( + fontWeight: + rowIndex == 0 ? FontWeight.w900 : style.fontWeight, + ), + ), + ), + ); + }), + ); + }).toList(), + ), + ), + ); + } + + Widget _buildTableCell( + BuildContext context, + _TableCell cell, + TextStyle cellStyle, + ) { + if (!cell.isHtml) { + return _InlineMarkdownMathText( + text: cell.value, + style: cellStyle, + palette: palette, + ); + } + + final segments = _parseHtmlCellSegments(cell.value); + if (segments.length == 1 && segments.single.imageUrl == null) { + return _InlineMarkdownMathText( + text: segments.single.text ?? '', + style: cellStyle, + palette: palette, + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: segments.map((segment) { + final imageUrl = segment.imageUrl; + if (imageUrl != null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: _buildImage(context, segment.alt ?? 'Image', imageUrl), + ); + } + + final text = segment.text?.trim(); + if (text == null || text.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(bottom: 4), + child: _InlineMarkdownMathText( + text: text, + style: cellStyle, + palette: palette, + ), + ); + }).toList(growable: false), + ); + } + + bool _isBlockBoundary(List lines, int index) { + final trimmed = lines[index].trim(); + return trimmed.isEmpty || + trimmed.startsWith('```') || + _startsDisplayMath(trimmed) || + _isHorizontalRule(trimmed) || + _isHtmlTableStart(trimmed) || + _isTableStart(lines, index) || + _imageMatch(trimmed) != null || + trimmed.startsWith('>') || + _headingLevel(trimmed) > 0 || + _listMatch(trimmed) != null; + } + + bool _isHorizontalRule(String line) { + return RegExp(r'^ {0,3}([-*_])(?:\s*\1){2,}\s*$').hasMatch(line); + } + + bool _startsDisplayMath(String line) { + return line.startsWith(r'$$') || line.startsWith(r'\['); + } + + bool _isHtmlTableStart(String line) { + return RegExp(r']', caseSensitive: false).hasMatch(line); + } + + _ImageMatch? _imageMatch(String line) { + final match = RegExp( + r'^!\[([^\]]*)\]\(([^)]+)\)\s*$', + ).firstMatch(line.trim()); + if (match == null) { + return null; + } + return _ImageMatch(match.group(1) ?? '', match.group(2) ?? ''); + } + + _DisplayMathResult _collectDisplayMath(List lines, int startIndex) { + final first = lines[startIndex].trim(); + + if (first.startsWith(r'\[')) { + final sameLineEnd = first.indexOf(r'\]', 2); + if (sameLineEnd != -1) { + return _DisplayMathResult( + first.substring(2, sameLineEnd), + startIndex + 1, + ); + } + final buffer = StringBuffer(first.substring(2)); + var index = startIndex + 1; + while (index < lines.length) { + final line = lines[index]; + final closeIndex = line.indexOf(r'\]'); + if (closeIndex != -1) { + buffer + ..write('\n') + ..write(line.substring(0, closeIndex)); + return _DisplayMathResult(buffer.toString(), index + 1); + } + buffer + ..write('\n') + ..write(line); + index += 1; + } + return _DisplayMathResult(buffer.toString(), index); + } + + final sameLineClose = first.indexOf(r'$$', 2); + if (sameLineClose != -1) { + return _DisplayMathResult( + first.substring(2, sameLineClose), + startIndex + 1, + ); + } + + final buffer = StringBuffer(first.substring(2)); + var index = startIndex + 1; + while (index < lines.length) { + final line = lines[index]; + final closeIndex = line.indexOf(r'$$'); + if (closeIndex != -1) { + buffer + ..write('\n') + ..write(line.substring(0, closeIndex)); + return _DisplayMathResult(buffer.toString(), index + 1); + } + buffer + ..write('\n') + ..write(line); + index += 1; + } + return _DisplayMathResult(buffer.toString(), index); + } + + bool _isTableStart(List lines, int index) { + if (index + 1 >= lines.length) { + return false; + } + final current = lines[index].trim(); + final next = lines[index + 1].trim(); + return current.contains('|') && + RegExp(r'^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$').hasMatch(next); + } + + _TableResult _collectTable(List lines, int startIndex) { + final rows = >[_splitTableRow(lines[startIndex])]; + var index = startIndex + 2; + while (index < lines.length && lines[index].trim().contains('|')) { + rows.add(_splitTableRow(lines[index])); + index += 1; + } + return _TableResult(rows, index); + } + + _TableResult _collectHtmlTable(List lines, int startIndex) { + final buffer = StringBuffer(lines[startIndex].trim()); + var index = startIndex + 1; + while (index < lines.length && + !RegExp( + r'', + caseSensitive: false, + ).hasMatch(buffer.toString())) { + buffer.write(lines[index].trim()); + index += 1; + } + return _TableResult(_parseHtmlTableRows(buffer.toString()), index); + } + + List> _parseHtmlTableRows(String html) { + final rows = >[]; + final rowPattern = RegExp( + r']*>(.*?)', + caseSensitive: false, + dotAll: true, + ); + final cellPattern = RegExp( + r']*>(.*?)', + caseSensitive: false, + dotAll: true, + ); + + for (final rowMatch in rowPattern.allMatches(html)) { + final rowHtml = rowMatch.group(1) ?? ''; + final cells = cellPattern + .allMatches(rowHtml) + .map((cell) => _TableCell(cell.group(1) ?? '', isHtml: true)) + .toList(); + if (cells.isNotEmpty) { + rows.add(cells); + } + } + + return rows; + } + + List<_TableCell> _splitTableRow(String line) { + var value = line.trim(); + if (value.startsWith('|')) { + value = value.substring(1); + } + if (value.endsWith('|')) { + value = value.substring(0, value.length - 1); + } + return value.split('|').map((cell) => _TableCell(cell.trim())).toList(); + } + + int _headingLevel(String line) { + var count = 0; + while (count < line.length && line[count] == '#') { + count += 1; + } + if (count > 0 && count <= 6 && count < line.length && line[count] == ' ') { + return count; + } + return 0; + } + + _ListMatch? _listMatch(String line) { + final task = RegExp(r'^[-*+]\s+\[([ xX])\]\s+(.+)$').firstMatch(line); + if (task != null) { + final checked = task.group(1)!.trim().isNotEmpty; + return _ListMatch(checked ? '☑' : '☐', task.group(2)!.trim()); + } + + final unordered = RegExp(r'^[-*+]\s+(.+)$').firstMatch(line); + if (unordered != null) { + return _ListMatch('•', unordered.group(1)!.trim()); + } + + final ordered = RegExp(r'^(\d+)[.)]\s+(.+)$').firstMatch(line); + if (ordered != null) { + return _ListMatch('${ordered.group(1)!}.', ordered.group(2)!.trim()); + } + + return null; + } +} + +class _InlineMarkdownMathText extends StatelessWidget { + const _InlineMarkdownMathText({ + required this.text, + required this.style, + required this.palette, + }); + + final String text; + final TextStyle style; + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return RichText( + text: TextSpan( + style: style, + children: _parseInline(text, style, palette), + ), + ); + } +} + +List _parseInline( + String input, + TextStyle style, + AppThemePalette palette, +) { + final spans = []; + final buffer = StringBuffer(); + var index = 0; + + void flushText() { + if (buffer.isEmpty) { + return; + } + spans.add(TextSpan(text: _normalizeInlineText(buffer.toString()))); + buffer.clear(); + } + + while (index < input.length) { + if (_startsWithUnescaped(input, index, r'$')) { + final closeIndex = _findClosingDelimiter(input, index + 1, r'$'); + if (closeIndex != -1) { + final expression = input.substring(index + 1, closeIndex).trim(); + if (expression.isNotEmpty) { + flushText(); + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Math.tex( + expression, + mathStyle: MathStyle.text, + textStyle: style, + onErrorFallback: (_) => Text('\$$expression\$', style: style), + ), + ), + ); + index = closeIndex + 1; + continue; + } + } + } + + if (_startsWithUnescaped(input, index, '`')) { + final closeIndex = _findClosingDelimiter(input, index + 1, '`'); + if (closeIndex != -1) { + flushText(); + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: palette.subtleOverlay, + borderRadius: BorderRadius.circular(5), + ), + child: Text( + input.substring(index + 1, closeIndex), + style: style.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + index = closeIndex + 1; + continue; + } + } + + final boldDelimiter = _startsWithUnescaped(input, index, '**') + ? '**' + : _startsWithUnescaped(input, index, '__') + ? '__' + : null; + if (boldDelimiter != null) { + final closeIndex = _findClosingDelimiter( + input, + index + boldDelimiter.length, + boldDelimiter, + ); + if (closeIndex != -1) { + flushText(); + spans.add( + TextSpan( + children: _parseInline( + input.substring(index + boldDelimiter.length, closeIndex), + style.copyWith(fontWeight: FontWeight.w900), + palette, + ), + ), + ); + index = closeIndex + boldDelimiter.length; + continue; + } + } + + final italicDelimiter = _startsWithUnescaped(input, index, '*') + ? '*' + : _startsWithUnescaped(input, index, '_') + ? '_' + : null; + if (italicDelimiter != null) { + final closeIndex = _findClosingDelimiter( + input, + index + 1, + italicDelimiter, + ); + if (closeIndex != -1) { + flushText(); + spans.add( + TextSpan( + style: const TextStyle(fontStyle: FontStyle.italic), + children: _parseInline( + input.substring(index + 1, closeIndex), + style.copyWith(fontStyle: FontStyle.italic), + palette, + ), + ), + ); + index = closeIndex + 1; + continue; + } + } + + if (_startsWithUnescaped(input, index, '[')) { + final labelEnd = _findClosingDelimiter(input, index + 1, ']'); + if (labelEnd != -1 && + labelEnd + 1 < input.length && + input[labelEnd + 1] == '(') { + final urlEnd = _findClosingDelimiter(input, labelEnd + 2, ')'); + if (urlEnd != -1) { + flushText(); + spans.add( + TextSpan( + style: TextStyle( + color: palette.primaryLight, + decoration: TextDecoration.underline, + decorationColor: palette.primaryLight, + fontWeight: FontWeight.w800, + ), + children: _parseInline( + input.substring(index + 1, labelEnd), + style.copyWith(color: palette.primaryLight), + palette, + ), + ), + ); + index = urlEnd + 1; + continue; + } + } + } + + if (_startsWithUnescaped(input, index, r'\')) { + if (index + 1 < input.length) { + buffer.write(input[index + 1]); + index += 2; + continue; + } + } + + buffer.write(input[index]); + index += 1; + } + + flushText(); + return spans; +} + +bool _startsWithUnescaped(String input, int index, String value) { + return input.startsWith(value, index) && !_isEscaped(input, index); +} + +int _findClosingDelimiter(String input, int start, String delimiter) { + var index = start; + while (index < input.length) { + if (_startsWithUnescaped(input, index, delimiter)) { + return index; + } + index += 1; + } + return -1; +} + +bool _isEscaped(String input, int index) { + var slashCount = 0; + var cursor = index - 1; + while (cursor >= 0 && input[cursor] == r'\') { + slashCount += 1; + cursor -= 1; + } + return slashCount.isOdd; +} + +String _normalizeInlineText(String value) { + return value.replaceAll(r'\$', r'$'); +} + +class _DisplayMathResult { + const _DisplayMathResult(this.expression, this.nextIndex); + + final String expression; + final int nextIndex; +} + +class _TableResult { + const _TableResult(this.rows, this.nextIndex); + + final List> rows; + final int nextIndex; +} + +class _TableCell { + const _TableCell(this.value, {this.isHtml = false}); + + final String value; + final bool isHtml; +} + +class _TableCellSegment { + const _TableCellSegment.text(this.text) + : imageUrl = null, + alt = null; + + const _TableCellSegment.image(this.imageUrl, this.alt) : text = null; + + final String? text; + final String? imageUrl; + final String? alt; +} + +List<_TableCellSegment> _parseHtmlCellSegments(String value) { + final segments = <_TableCellSegment>[]; + final imagePattern = RegExp( + r']*>', + caseSensitive: false, + dotAll: true, + ); + var cursor = 0; + + void addText(String raw) { + final text = _htmlCellText(raw); + if (text.isNotEmpty) { + segments.add(_TableCellSegment.text(text)); + } + } + + for (final match in imagePattern.allMatches(value)) { + addText(value.substring(cursor, match.start)); + final imageTag = match.group(0) ?? ''; + final src = _readHtmlAttribute(imageTag, 'src'); + if (src != null && src.trim().isNotEmpty) { + segments.add( + _TableCellSegment.image( + _decodeHtmlEntities(src.trim()), + _decodeHtmlEntities(_readHtmlAttribute(imageTag, 'alt') ?? 'Image'), + ), + ); + } + cursor = match.end; + } + + addText(value.substring(cursor)); + return segments.isEmpty ? const [_TableCellSegment.text('')] : segments; +} + +String? _readHtmlAttribute(String tag, String name) { + final quoted = RegExp( + "$name\\s*=\\s*(['\"])(.*?)\\1", + caseSensitive: false, + dotAll: true, + ).firstMatch(tag); + if (quoted != null) { + return quoted.group(2); + } + + final unquoted = RegExp( + '$name\\s*=\\s*([^\\s>]+)', + caseSensitive: false, + dotAll: true, + ).firstMatch(tag); + return unquoted?.group(1); +} + +String _htmlCellText(String value) { + return _decodeHtmlEntities( + value + .replaceAll(RegExp(r'', caseSensitive: false), '\n') + .replaceAll(RegExp(r'<[^>]+>'), '') + .trim(), + ); +} + +String _decodeHtmlEntities(String value) { + return value + .replaceAll(' ', ' ') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll(''', "'"); +} + +class _ListMatch { + const _ListMatch(this.marker, this.content); + + final String marker; + final String content; +} + +class _ImageMatch { + const _ImageMatch(this.alt, this.url); + + final String alt; + final String url; +} diff --git a/apps/mobile/lib/core/widgets/math_rich_text.dart b/apps/mobile/lib/core/widgets/math_rich_text.dart new file mode 100644 index 00000000..de0391f7 --- /dev/null +++ b/apps/mobile/lib/core/widgets/math_rich_text.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_math_fork/flutter_math.dart'; + +/// Renders plain text mixed with TeX fragments. +/// +/// Supported delimiters: +/// - `$...$` for inline math +/// - `$$...$$` for display math +class MathRichText extends StatelessWidget { + const MathRichText({ + super.key, + required this.text, + this.style, + this.textAlign = TextAlign.start, + this.maxLines, + this.overflow = TextOverflow.clip, + this.softWrap = true, + }); + + final String text; + final TextStyle? style; + final TextAlign textAlign; + final int? maxLines; + final TextOverflow overflow; + final bool softWrap; + + @override + Widget build(BuildContext context) { + final effectiveStyle = DefaultTextStyle.of(context).style.merge(style); + final segments = _parseMathSegments(text); + + if (!segments.any((segment) => segment.isDisplayMath)) { + return _InlineMathText( + segments: segments, + style: effectiveStyle, + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + softWrap: softWrap, + ); + } + + final children = []; + final inlineBuffer = <_MathSegment>[]; + + void flushInline() { + if (inlineBuffer.isEmpty) { + return; + } + children.add( + _InlineMathText( + segments: List<_MathSegment>.of(inlineBuffer), + style: effectiveStyle, + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + softWrap: softWrap, + ), + ); + inlineBuffer.clear(); + } + + for (final segment in segments) { + if (!segment.isDisplayMath) { + inlineBuffer.add(segment); + continue; + } + + flushInline(); + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Math.tex( + segment.value, + mathStyle: MathStyle.display, + textStyle: effectiveStyle, + onErrorFallback: (_) => + Text('\$\$${segment.value}\$\$', style: effectiveStyle), + ), + ), + ), + ); + } + + flushInline(); + + return Column( + crossAxisAlignment: _crossAxisAlignmentFor(textAlign), + children: children, + ); + } +} + +class _InlineMathText extends StatelessWidget { + const _InlineMathText({ + required this.segments, + required this.style, + required this.textAlign, + required this.maxLines, + required this.overflow, + required this.softWrap, + }); + + final List<_MathSegment> segments; + final TextStyle style; + final TextAlign textAlign; + final int? maxLines; + final TextOverflow overflow; + final bool softWrap; + + @override + Widget build(BuildContext context) { + return RichText( + textAlign: textAlign, + maxLines: maxLines, + overflow: overflow, + softWrap: softWrap, + text: TextSpan( + style: style, + children: segments.map((segment) { + if (!segment.isMath) { + return TextSpan(text: segment.value); + } + + final raw = '\$${segment.value}\$'; + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Math.tex( + segment.value, + mathStyle: MathStyle.text, + textStyle: style, + onErrorFallback: (_) => Text(raw, style: style), + ), + ); + }).toList(), + ), + ); + } +} + +class _MathSegment { + const _MathSegment.text(this.value) + : isMath = false, + isDisplayMath = false; + + const _MathSegment.math(this.value, {required this.isDisplayMath}) + : isMath = true; + + final String value; + final bool isMath; + final bool isDisplayMath; +} + +List<_MathSegment> _parseMathSegments(String input) { + final segments = <_MathSegment>[]; + final textBuffer = StringBuffer(); + var index = 0; + + void flushText() { + if (textBuffer.isEmpty) { + return; + } + segments.add(_MathSegment.text(_normalizePlainText(textBuffer.toString()))); + textBuffer.clear(); + } + + while (index < input.length) { + final char = input[index]; + if (char != r'$' || _isEscaped(input, index)) { + textBuffer.write(char); + index += 1; + continue; + } + + final isDisplay = index + 1 < input.length && input[index + 1] == r'$'; + final delimiterLength = isDisplay ? 2 : 1; + final closeIndex = _findClosingDelimiter( + input, + index + delimiterLength, + delimiterLength, + ); + + if (closeIndex == -1) { + textBuffer.write(char); + index += 1; + continue; + } + + final expression = input.substring(index + delimiterLength, closeIndex); + if (expression.trim().isEmpty) { + textBuffer.write(input.substring(index, closeIndex + delimiterLength)); + index = closeIndex + delimiterLength; + continue; + } + + flushText(); + segments.add( + _MathSegment.math(expression.trim(), isDisplayMath: isDisplay), + ); + index = closeIndex + delimiterLength; + } + + flushText(); + return segments.isEmpty ? [_MathSegment.text(input)] : segments; +} + +int _findClosingDelimiter(String input, int start, int delimiterLength) { + var index = start; + while (index < input.length) { + if (_isEscaped(input, index) || input[index] != r'$') { + index += 1; + continue; + } + + if (delimiterLength == 1) { + return index; + } + + if (index + 1 < input.length && input[index + 1] == r'$') { + return index; + } + index += 1; + } + return -1; +} + +bool _isEscaped(String input, int index) { + var slashCount = 0; + var cursor = index - 1; + while (cursor >= 0 && input[cursor] == r'\') { + slashCount += 1; + cursor -= 1; + } + return slashCount.isOdd; +} + +String _normalizePlainText(String value) { + return value.replaceAll(r'\$', r'$'); +} + +CrossAxisAlignment _crossAxisAlignmentFor(TextAlign textAlign) { + switch (textAlign) { + case TextAlign.center: + return CrossAxisAlignment.center; + case TextAlign.right: + case TextAlign.end: + return CrossAxisAlignment.end; + case TextAlign.left: + case TextAlign.start: + case TextAlign.justify: + return CrossAxisAlignment.start; + } +} diff --git a/apps/mobile/lib/core/widgets/protected_image.dart b/apps/mobile/lib/core/widgets/protected_image.dart new file mode 100644 index 00000000..b8455fcc --- /dev/null +++ b/apps/mobile/lib/core/widgets/protected_image.dart @@ -0,0 +1,62 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +class ProtectedImage extends StatefulWidget { + const ProtectedImage({ + super.key, + required this.url, + required this.loadBytes, + this.fit = BoxFit.cover, + this.loading, + this.error, + }); + + final String url; + final Future Function(String url) loadBytes; + final BoxFit fit; + final Widget? loading; + final Widget? error; + + @override + State createState() => _ProtectedImageState(); +} + +class _ProtectedImageState extends State { + late Future _future; + + @override + void initState() { + super.initState(); + _future = widget.loadBytes(widget.url); + } + + @override + void didUpdateWidget(covariant ProtectedImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.url != widget.url || + oldWidget.loadBytes != widget.loadBytes) { + _future = widget.loadBytes(widget.url); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory( + snapshot.data!, + fit: widget.fit, + gaplessPlayback: true, + ); + } + if (snapshot.hasError) { + return widget.error ?? const SizedBox.shrink(); + } + return widget.loading ?? const SizedBox.shrink(); + }, + ); + } +} diff --git a/apps/mobile/lib/core/widgets/starry_background.dart b/apps/mobile/lib/core/widgets/starry_background.dart new file mode 100644 index 00000000..a0aaf3de --- /dev/null +++ b/apps/mobile/lib/core/widgets/starry_background.dart @@ -0,0 +1,262 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../app/theme/app_theme.dart'; + +class StarryBackground extends StatelessWidget { + const StarryBackground({ + super.key, + required this.child, + this.showHomeOrnaments = false, + this.showStars = true, + }); + + final Widget child; + final bool showHomeOrnaments; + final bool showStars; + + @override + Widget build(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isLight + ? const [AppTheme.lightBackground, AppTheme.lightBackgroundAlt] + : const [AppTheme.background, AppTheme.backgroundAlt], + ), + ), + child: Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: _CosmicBackgroundPainter( + isLight: isLight, + showHomeOrnaments: showHomeOrnaments, + ), + ), + if (showStars) + AnimatedStarField( + key: const Key('global-star-field'), + isLight: isLight, + ), + child, + ], + ), + ); + } +} + +class AnimatedStarField extends StatelessWidget { + const AnimatedStarField({ + super.key, + this.starCount = 38, + this.isLight = false, + }); + + final int starCount; + final bool isLight; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: List.generate(starCount, (index) { + final width = math.max(constraints.maxWidth, 1); + final height = math.max(constraints.maxHeight, 1); + final left = ((index * 37) % 100) / 100 * width; + final top = ((index * 53) % 100) / 100 * height; + final size = 1.2 + (index % 3) * 0.7; + + return Positioned( + left: left, + top: top, + child: _TwinklingStar( + index: index, + size: size, + isLight: isLight, + ), + ); + }), + ); + }, + ), + ); + } +} + +class _TwinklingStar extends StatefulWidget { + const _TwinklingStar({ + required this.index, + required this.size, + required this.isLight, + }); + + final int index; + final double size; + final bool isLight; + + @override + State<_TwinklingStar> createState() => _TwinklingStarState(); +} + +class _TwinklingStarState extends State<_TwinklingStar> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: 1200 + widget.index % 8 * 220), + value: (widget.index % 10) / 10, + )..repeat(reverse: true); + _opacity = Tween( + begin: 0.16, + end: 0.9, + ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: _opacity, + child: Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + color: widget.isLight ? AppTheme.primary : AppTheme.textPrimary, + borderRadius: BorderRadius.circular(widget.size), + boxShadow: [ + BoxShadow( + color: AppTheme.primaryLight.withOpacity( + widget.isLight ? 0.22 : 0.45, + ), + blurRadius: widget.size * 4, + ), + ], + ), + ), + ); + } +} + +class _CosmicBackgroundPainter extends CustomPainter { + const _CosmicBackgroundPainter({ + required this.isLight, + required this.showHomeOrnaments, + }); + + final bool isLight; + final bool showHomeOrnaments; + + @override + void paint(Canvas canvas, Size size) { + _drawGlow( + canvas, + center: Offset(size.width * 0.2, size.height * 0.08), + radius: size.shortestSide * (isLight ? 0.7 : 0.45), + color: isLight + ? AppTheme.primary.withOpacity(0.22) + : AppTheme.primary.withOpacity(0.28), + ); + + if (!showHomeOrnaments) { + return; + } + + _drawGlow( + canvas, + center: Offset(size.width * 0.82, size.height * 0.58), + radius: size.shortestSide * (isLight ? 0.62 : 0.4), + color: isLight + ? AppTheme.primary.withOpacity(0.3) + : AppTheme.primaryLight.withOpacity(0.3), + ); + if (isLight) { + _drawGlow( + canvas, + center: Offset(size.width * 0.38, size.height * 0.42), + radius: size.shortestSide * 0.48, + color: AppTheme.primaryLight.withOpacity(0.14), + ); + } + + final linePaint = Paint() + ..color = (isLight ? AppTheme.lightBorder : AppTheme.border).withOpacity( + isLight ? 0.12 : 0.08, + ) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + + final topRibbonPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: isLight + ? [ + AppTheme.primary.withOpacity(0.36), + AppTheme.primaryLight.withOpacity(0.14), + Colors.transparent, + ] + : [ + AppTheme.primary.withOpacity(0.22), + AppTheme.primaryLight.withOpacity(0.08), + Colors.transparent, + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height * 0.24)); + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height * 0.24), + topRibbonPaint, + ); + + for (var i = 0; i < 5; i++) { + final y = size.height * (0.2 + i * 0.15); + final path = Path() + ..moveTo(-40, y) + ..cubicTo( + size.width * 0.25, + y - 70, + size.width * 0.62, + y + 78, + size.width + 40, + y - 30, + ); + canvas.drawPath(path, linePaint); + } + } + + void _drawGlow( + Canvas canvas, { + required Offset center, + required double radius, + required Color color, + }) { + final rect = Rect.fromCircle(center: center, radius: radius); + final paint = Paint() + ..shader = RadialGradient( + colors: [color, Colors.transparent], + ).createShader(rect); + canvas.drawCircle(center, radius, paint); + } + + @override + bool shouldRepaint(covariant _CosmicBackgroundPainter oldDelegate) { + return oldDelegate.isLight != isLight || + oldDelegate.showHomeOrnaments != showHomeOrnaments; + } +} diff --git a/apps/mobile/lib/features/auth/data/auth_api.dart b/apps/mobile/lib/features/auth/data/auth_api.dart new file mode 100644 index 00000000..244587a9 --- /dev/null +++ b/apps/mobile/lib/features/auth/data/auth_api.dart @@ -0,0 +1,171 @@ +import 'dart:typed_data'; + +import '../../../core/network/api_client.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; + +class AuthApi { + AuthApi({ApiClient? client}) : _client = client ?? ApiClient(); + + final ApiClient _client; + + Future hasStoredSession() async { + try { + return await _client.hasSession(); + } on MissingPluginException { + // Widget tests and unsupported targets have no secure-storage plugin. + return false; + } + } + + Future sendCode({ + required String email, + String type = 'register', + }) async { + await _client.postJson('/api/auth/send-code', { + 'email': email, + 'type': type, + }); + } + + Future register({ + required String email, + required String username, + required String password, + required String code, + }) async { + final payload = await _client.postJson( + '/api/auth/register', + { + 'email': email, + 'username': username, + 'password': password, + 'code': code, + }, + successCodes: const {201}, + ); + + return AuthUser.fromJson(payload['user'] as Map); + } + + Future login({ + required String identifier, + required String password, + }) async { + final payload = await _client.postJson('/api/auth/login', { + 'identifier': identifier, + 'password': password, + }); + + return AuthUser.fromJson(payload['user'] as Map); + } + + Future me() async { + final payload = await _client.getJson('/api/auth/me'); + + return AuthUser.fromJson(payload['user'] as Map); + } + + Future logout() async { + try { + await _client.postJson('/api/auth/logout', {}); + } finally { + await _client.clearSession(); + } + } + + Future updateProfile({ + String? displayName, + String? nickname, + String? email, + String? code, + }) async { + final body = { + if (displayName != null) 'display_name': displayName, + if (nickname != null) 'nickname': nickname, + if (email != null) 'email': email, + if (code != null) 'code': code, + }; + + final payload = await _client.patchJson('/api/auth/profile', body); + return AuthActionResponse.fromJson(payload, fallbackMessage: '资料已更新'); + } + + Future uploadAvatar({ + required String filename, + required List bytes, + }) async { + final payload = await _client.postMultipart( + '/api/auth/profile/avatar', + fields: const {}, + files: [http.MultipartFile.fromBytes('file', bytes, filename: filename)], + ); + + return AuthActionResponse.fromJson(payload, fallbackMessage: '头像已上传'); + } + + Future deleteAvatar() async { + final payload = await _client.deleteJson('/api/auth/profile/avatar'); + return AuthActionResponse.fromJson(payload, fallbackMessage: '头像已删除'); + } + + Future loadProtectedImage(String url) { + return _client.getBytes(url); + } + + Future clearStoredSession() { + return _client.clearSession(); + } +} + +class AuthActionResponse { + const AuthActionResponse({required this.success, required this.message}); + + final bool success; + final String message; + + factory AuthActionResponse.fromJson( + Map json, { + required String fallbackMessage, + }) { + return AuthActionResponse( + success: json['success'] as bool? ?? false, + message: json['message']?.toString() ?? fallbackMessage, + ); + } +} + +class AuthUser { + const AuthUser({ + required this.id, + required this.email, + required this.username, + required this.isAdmin, + this.displayName, + this.nickname, + this.avatarUrl, + this.quota = const {}, + }); + + final int id; + final String email; + final String username; + final bool isAdmin; + final String? displayName; + final String? nickname; + final String? avatarUrl; + final Map quota; + + factory AuthUser.fromJson(Map json) { + return AuthUser( + id: json['id'] as int, + email: json['email'] as String, + username: json['username'] as String, + isAdmin: json['is_admin'] as bool? ?? false, + displayName: json['display_name'] as String?, + nickname: json['nickname'] as String?, + avatarUrl: json['avatar_url'] as String?, + quota: (json['quota'] as Map?)?.cast() ?? const {}, + ); + } +} diff --git a/apps/mobile/lib/features/chat/data/chat_api.dart b/apps/mobile/lib/features/chat/data/chat_api.dart new file mode 100644 index 00000000..f765e093 --- /dev/null +++ b/apps/mobile/lib/features/chat/data/chat_api.dart @@ -0,0 +1,544 @@ +import 'dart:convert'; + +import '../../../core/network/api_client.dart'; +import '../../../core/utils/time_format.dart'; + +class ChatApi { + ChatApi({ApiClient? client}) : _client = client ?? ApiClient(); + + final ApiClient _client; + + Future getMySessions({ + int page = 1, + int limit = 20, + }) async { + final path = Uri( + path: '/api/chat/my-sessions', + queryParameters: {'page': '$page', 'limit': '$limit'}, + ).toString(); + final payload = await _client.getJson(path); + return ChatSessionsResponse.fromJson(payload); + } + + Future createSession({ + String title = '新对话', + int? questionId, + }) async { + final payload = await _client.postJson('/api/chat', { + 'title': title, + 'question_id': questionId, + }); + return CreateChatSessionResponse.fromJson(payload); + } + + Future getMessages({ + required String sessionId, + int limit = 30, + int? beforeId, + }) async { + final path = Uri( + path: '/api/chat/${Uri.encodeComponent(sessionId)}/messages', + queryParameters: { + 'limit': '$limit', + if (beforeId != null) 'before_id': '$beforeId', + }, + ).toString(); + final payload = await _client.getJson(path); + return ChatMessagesResponse.fromJson(payload); + } + + Future queryErrorBank({ + int page = 1, + int pageSize = 20, + String? subject, + String? knowledgeTag, + String? questionType, + String? keyword, + int? projectId, + String? reviewStatus, + String? startDate, + String? endDate, + }) async { + final path = Uri( + path: '/api/error-bank', + queryParameters: { + 'page': '$page', + 'page_size': '$pageSize', + if (_hasValue(subject)) 'subject': subject!, + if (_hasValue(knowledgeTag)) 'knowledge_tag': knowledgeTag!, + if (_hasValue(questionType)) 'question_type': questionType!, + if (_hasValue(keyword)) 'keyword': keyword!, + if (projectId != null) 'project_id': '$projectId', + if (_hasValue(reviewStatus)) 'review_status': reviewStatus!, + if (_hasValue(startDate)) 'start_date': startDate!, + if (_hasValue(endDate)) 'end_date': endDate!, + }, + ).toString(); + final payload = await _client.getJson(path); + return ErrorBankResponse.fromJson(payload); + } + + Stream streamMessage({ + required String sessionId, + required ChatStreamRequest request, + }) { + final chunks = _client.postEventStream( + '/api/chat/${Uri.encodeComponent(sessionId)}/stream', + request.toJson(), + ); + return _parseSseEvents(chunks); + } + + Future renameSession({ + required String sessionId, + required String title, + }) async { + final payload = await _client.patchJson( + '/api/chat/${Uri.encodeComponent(sessionId)}', + {'title': title}, + ); + return ChatActionResponse.fromJson(payload, fallbackMessage: '标题已更新'); + } + + Future deleteSession({required String sessionId}) async { + final payload = await _client.deleteJson( + '/api/chat/${Uri.encodeComponent(sessionId)}', + ); + return ChatActionResponse.fromJson(payload, fallbackMessage: '对话已删除'); + } +} + +class ChatSessionsResponse { + const ChatSessionsResponse({ + required this.success, + required this.sessions, + required this.total, + }); + + final bool success; + final List sessions; + final int total; + + factory ChatSessionsResponse.fromJson(Map json) { + final rawSessions = (json['sessions'] as List?) ?? const []; + return ChatSessionsResponse( + success: json['success'] as bool? ?? false, + sessions: rawSessions + .whereType>() + .map(ChatSession.fromJson) + .toList(), + total: _readInt(json['total']) ?? rawSessions.length, + ); + } +} + +class ChatSession { + const ChatSession({ + required this.id, + required this.title, + required this.questionId, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String title; + final int? questionId; + final DateTime? createdAt; + final DateTime? updatedAt; + + String get displayTitle => title.trim().isEmpty ? '新对话' : title.trim(); + + factory ChatSession.fromJson(Map json) { + return ChatSession( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '新对话', + questionId: _readInt(json['question_id']), + createdAt: _readDateTime(json['created_at']), + updatedAt: _readDateTime(json['updated_at']), + ); + } +} + +class CreateChatSessionResponse { + const CreateChatSessionResponse({ + required this.success, + required this.message, + required this.session, + required this.sessionId, + }); + + final bool success; + final String message; + final ChatSession? session; + final String? sessionId; + + factory CreateChatSessionResponse.fromJson(Map json) { + final rawSession = json['session']; + final session = rawSession is Map + ? ChatSession.fromJson(rawSession.cast()) + : null; + return CreateChatSessionResponse( + success: json['success'] as bool? ?? false, + message: json['message']?.toString() ?? '创建成功', + session: session, + sessionId: session?.id ?? (json['session_id'] ?? json['id'])?.toString(), + ); + } +} + +class ChatMessagesResponse { + const ChatMessagesResponse({ + required this.success, + required this.messages, + required this.hasMore, + required this.nextBeforeId, + }); + + final bool success; + final List messages; + final bool hasMore; + final int? nextBeforeId; + + factory ChatMessagesResponse.fromJson(Map json) { + final rawMessages = + (json['messages'] ?? json['items'] ?? json['data']) as List? ?? + const []; + final messages = rawMessages + .whereType>() + .map(ChatMessage.fromJson) + .toList(); + return ChatMessagesResponse( + success: json['success'] as bool? ?? false, + messages: messages, + hasMore: json['has_more'] as bool? ?? false, + nextBeforeId: _readInt(json['next_before_id']) ?? + (messages.isEmpty ? null : messages.first.id), + ); + } +} + +class ChatMessage { + const ChatMessage({ + required this.id, + required this.role, + required this.content, + required this.reasoning, + required this.createdAt, + }); + + final int? id; + final String role; + final String content; + final String reasoning; + final DateTime? createdAt; + + bool get isUser => role == 'user'; + bool get isAssistant => role == 'assistant'; + + factory ChatMessage.fromJson(Map json) { + return ChatMessage( + id: _readInt(json['id'] ?? json['message_id']), + role: json['role']?.toString() ?? 'assistant', + content: json['content']?.toString() ?? '', + reasoning: json['reasoning']?.toString() ?? '', + createdAt: _readDateTime(json['created_at']), + ); + } +} + +class ErrorBankResponse { + const ErrorBankResponse({ + required this.success, + required this.questions, + required this.total, + required this.page, + required this.pageSize, + }); + + final bool success; + final List questions; + final int total; + final int page; + final int pageSize; + + factory ErrorBankResponse.fromJson(Map json) { + final rawQuestions = (json['questions'] ?? + json['items'] ?? + json['records'] ?? + json['data']) as List? ?? + const []; + final questions = rawQuestions + .whereType>() + .map(ErrorBankQuestion.fromJson) + .toList(); + return ErrorBankResponse( + success: json['success'] as bool? ?? false, + questions: questions, + total: _readInt(json['total']) ?? questions.length, + page: _readInt(json['page']) ?? 1, + pageSize: _readInt(json['page_size'] ?? json['pageSize']) ?? 20, + ); + } +} + +class ErrorBankQuestion { + const ErrorBankQuestion({ + required this.id, + required this.questionType, + required this.subject, + required this.sectionTitle, + required this.contentBlocks, + required this.options, + required this.knowledgeTags, + }); + + final int id; + final String questionType; + final String subject; + final String sectionTitle; + final List contentBlocks; + final List options; + final List knowledgeTags; + + String get previewText { + final blocksText = contentBlocks + .where((block) => !block.isImage) + .map((block) => block.content.trim()) + .where((text) => text.isNotEmpty) + .join('\n'); + if (blocksText.isNotEmpty) { + return blocksText; + } + return sectionTitle; + } + + factory ErrorBankQuestion.fromJson(Map json) { + final rawBlocks = (json['content_blocks'] ?? + json['content_json'] ?? + json['blocks']) as List? ?? + const []; + final rawOptions = + (json['options'] ?? json['options_json']) as List? ?? const []; + final rawTags = json['knowledge_tags'] as List? ?? const []; + return ErrorBankQuestion( + id: _readInt(json['id'] ?? json['question_id']) ?? 0, + questionType: json['question_type']?.toString() ?? '', + subject: json['subject']?.toString() ?? '', + sectionTitle: json['section_title']?.toString() ?? '', + contentBlocks: rawBlocks + .whereType>() + .map(ErrorBankQuestionBlock.fromJson) + .toList(), + options: rawOptions.map((item) => item.toString()).toList(), + knowledgeTags: rawTags.map((item) => item.toString()).toList(), + ); + } +} + +class ErrorBankQuestionBlock { + const ErrorBankQuestionBlock({ + required this.blockType, + required this.content, + }); + + final String blockType; + final String content; + + bool get isImage => blockType.toLowerCase() == 'image'; + + factory ErrorBankQuestionBlock.fromJson(Map json) { + return ErrorBankQuestionBlock( + blockType: json['block_type']?.toString() ?? 'text', + content: json['content']?.toString() ?? '', + ); + } +} + +class ChatStreamRequest { + const ChatStreamRequest({ + required this.message, + this.modelProvider = 'openai', + this.modelName, + this.providerSource, + this.providerId, + this.deepThink = false, + this.contextRefs = const [], + }); + + final String message; + final String modelProvider; + final String? modelName; + final String? providerSource; + final String? providerId; + final bool deepThink; + final List contextRefs; + + Map toJson() { + return { + 'message': message, + 'model_provider': modelProvider, + if (_hasValue(modelName)) 'model_name': modelName, + if (_hasValue(providerSource)) 'provider_source': providerSource, + if (_hasValue(providerId)) 'provider_id': providerId, + 'deep_think': deepThink, + if (contextRefs.isNotEmpty) + 'context_refs': contextRefs.map((item) => item.toJson()).toList(), + }; + } +} + +class ChatContextRef { + const ChatContextRef({ + required this.type, + required this.projectId, + required this.questionIds, + }); + + final String type; + final int projectId; + final List questionIds; + + Map toJson() { + return {'type': type, 'project_id': projectId, 'question_ids': questionIds}; + } +} + +class ChatStreamEvent { + const ChatStreamEvent({ + this.token, + this.reasoning, + this.error, + this.done = false, + this.raw = const {}, + }); + + final String? token; + final String? reasoning; + final String? error; + final bool done; + final Map raw; + + factory ChatStreamEvent.fromJson(Map json) { + return ChatStreamEvent( + token: json['token']?.toString(), + reasoning: json['reasoning']?.toString(), + error: json['error']?.toString(), + done: json['done'] as bool? ?? false, + raw: json, + ); + } +} + +class ChatActionResponse { + const ChatActionResponse({required this.success, required this.message}); + + final bool success; + final String message; + + factory ChatActionResponse.fromJson( + Map json, { + required String fallbackMessage, + }) { + return ChatActionResponse( + success: json['success'] as bool? ?? false, + message: json['message']?.toString() ?? fallbackMessage, + ); + } +} + +int? _readInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse('$value'); +} + +DateTime? _readDateTime(dynamic value) { + return parseBackendDateTime(value); +} + +bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + +Stream _parseSseEvents(Stream chunks) async* { + final buffer = StringBuffer(); + + await for (final chunk in chunks) { + buffer.write(chunk); + var text = buffer.toString(); + var boundary = _findSseBoundary(text); + + while (boundary != -1) { + final frame = text.substring(0, boundary).trim(); + text = text.substring(_boundaryEndIndex(text, boundary)); + + final event = _parseSseFrame(frame); + if (event != null) { + yield event; + } + + boundary = _findSseBoundary(text); + } + + buffer + ..clear() + ..write(text); + } + + final tail = buffer.toString().trim(); + final event = _parseSseFrame(tail); + if (event != null) { + yield event; + } +} + +int _findSseBoundary(String text) { + final lf = text.indexOf('\n\n'); + final crlf = text.indexOf('\r\n\r\n'); + if (lf == -1) { + return crlf; + } + if (crlf == -1) { + return lf; + } + return lf < crlf ? lf : crlf; +} + +int _boundaryEndIndex(String text, int boundary) { + return text.startsWith('\r\n\r\n', boundary) ? boundary + 4 : boundary + 2; +} + +ChatStreamEvent? _parseSseFrame(String frame) { + if (frame.isEmpty) { + return null; + } + + final dataLines = frame + .split(RegExp(r'\r?\n')) + .where((line) => line.startsWith('data:')) + .map((line) => line.substring(5).trim()) + .where((line) => line.isNotEmpty) + .toList(); + + if (dataLines.isEmpty) { + return null; + } + + final data = dataLines.join('\n'); + if (data == '[DONE]') { + return const ChatStreamEvent(done: true); + } + + final decoded = jsonDecode(data); + if (decoded is Map) { + return ChatStreamEvent.fromJson(decoded); + } + if (decoded is Map) { + return ChatStreamEvent.fromJson(decoded.cast()); + } + + return ChatStreamEvent(token: decoded.toString()); +} diff --git a/apps/mobile/lib/features/device/data/device_api.dart b/apps/mobile/lib/features/device/data/device_api.dart new file mode 100644 index 00000000..e093b3da --- /dev/null +++ b/apps/mobile/lib/features/device/data/device_api.dart @@ -0,0 +1,196 @@ +import 'dart:typed_data'; + +import '../../../core/network/api_client.dart'; + +class DeviceApi { + DeviceApi({ApiClient? client}) : _client = client ?? ApiClient(); + + final ApiClient _client; + + Future bindDevice({bool forceNew = false}) async { + final payload = await _client.postJson('/api/device/bind', { + 'force_new': forceNew, + }); + return DeviceBindResponse.fromJson(payload); + } + + Future getBinding() async { + final payload = await _client.getJson('/api/device/binding'); + return DeviceBindingResponse.fromJson(payload); + } + + Future getImages({ + String? deviceUuid, + String? deviceId, + int limit = 50, + }) async { + final query = { + if (deviceUuid != null && deviceUuid.trim().isNotEmpty) + 'device_uuid': deviceUuid.trim(), + if (deviceId != null && deviceId.trim().isNotEmpty) + 'device_id': deviceId.trim(), + 'limit': limit.clamp(1, 200).toString(), + }; + final path = Uri( + path: '/api/device/images', + queryParameters: query, + ).toString(); + final payload = await _client.getJson(path); + return DeviceImagesResponse.fromJson(payload); + } + + Future loadImageBytes(String imageUrl) { + return _client.getBytes(imageUrl); + } + + Future unbindDevice(String deviceUuid) async { + final payload = await _client.postJson('/api/device/unbind', { + 'device_uuid': deviceUuid, + }); + return DeviceActionResponse.fromJson(payload); + } +} + +class DeviceBindResponse { + const DeviceBindResponse({ + required this.success, + required this.deviceUuid, + required this.qrPayload, + }); + + final bool success; + final String deviceUuid; + final String qrPayload; + + factory DeviceBindResponse.fromJson(Map json) { + return DeviceBindResponse( + success: json['success'] as bool? ?? false, + deviceUuid: json['device_uuid']?.toString() ?? '', + qrPayload: json['qr_payload']?.toString() ?? '', + ); + } +} + +class DeviceBindingResponse { + const DeviceBindingResponse({ + required this.success, + required this.bound, + this.deviceUuid, + this.qrPayload, + }); + + final bool success; + final bool bound; + final String? deviceUuid; + final String? qrPayload; + + factory DeviceBindingResponse.fromJson(Map json) { + return DeviceBindingResponse( + success: json['success'] as bool? ?? false, + bound: json['bound'] as bool? ?? false, + deviceUuid: json['device_uuid']?.toString(), + qrPayload: json['qr_payload']?.toString(), + ); + } +} + +class DeviceActionResponse { + const DeviceActionResponse({required this.success, this.message}); + + final bool success; + final String? message; + + factory DeviceActionResponse.fromJson(Map json) { + return DeviceActionResponse( + success: json['success'] as bool? ?? false, + message: json['message']?.toString(), + ); + } +} + +class DeviceImagesResponse { + const DeviceImagesResponse({required this.success, required this.images}); + + final bool success; + final List images; + + factory DeviceImagesResponse.fromJson(Map json) { + final rawImages = json['images']; + return DeviceImagesResponse( + success: json['success'] as bool? ?? false, + images: rawImages is List + ? rawImages + .whereType>() + .map(DeviceCapture.fromJson) + .toList(growable: false) + : const [], + ); + } +} + +class DeviceCapture { + const DeviceCapture({ + this.id, + this.deviceUuid, + this.fileKey, + this.filename, + this.imageUrl, + this.contentType, + this.fileSize, + this.createdAt, + }); + + final int? id; + final String? deviceUuid; + final String? fileKey; + final String? filename; + final String? imageUrl; + final String? contentType; + final int? fileSize; + final DateTime? createdAt; + + factory DeviceCapture.fromJson(Map json) { + return DeviceCapture( + id: _readInt(json['id']), + deviceUuid: json['device_uuid']?.toString(), + fileKey: json['file_key']?.toString(), + filename: json['filename']?.toString(), + imageUrl: json['image_url']?.toString(), + contentType: json['content_type']?.toString(), + fileSize: _readInt(json['file_size']), + createdAt: _readDateTime(json['created_at']), + ); + } + + String get displayName { + final value = filename?.trim(); + if (value != null && value.isNotEmpty) { + return value; + } + + final key = fileKey?.trim(); + if (key != null && key.isNotEmpty) { + return key.split(RegExp(r'[\\/]+')).last; + } + + return 'capture.jpg'; + } +} + +int? _readInt(Object? value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse(value?.toString() ?? ''); +} + +DateTime? _readDateTime(Object? value) { + final text = value?.toString().trim(); + if (text == null || text.isEmpty) { + return null; + } + return DateTime.tryParse(text); +} diff --git a/apps/mobile/lib/features/device/data/esp_ble_device_provisioner.dart b/apps/mobile/lib/features/device/data/esp_ble_device_provisioner.dart new file mode 100644 index 00000000..2a077d07 --- /dev/null +++ b/apps/mobile/lib/features/device/data/esp_ble_device_provisioner.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:esp_provisioning_ble/esp_provisioning_ble.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart' as ble; + +class DeviceProvisioningRequest { + const DeviceProvisioningRequest({ + required this.deviceId, + required this.wifiSsid, + required this.wifiPassword, + required this.uploadUrl, + required this.imageProfile, + }); + + final String deviceId; + final String wifiSsid; + final String wifiPassword; + final String uploadUrl; + final String imageProfile; + + void validateDeviceConfig() { + if (!isValidDeviceConfigId(deviceId)) { + throw const DeviceProvisioningException('设备 ID 无效'); + } + if (!isValidDeviceUploadUrl(uploadUrl)) { + throw const DeviceProvisioningException('上传地址无效'); + } + if (!isValidDeviceImageProfile(imageProfile)) { + throw const DeviceProvisioningException('图片档位无效'); + } + } + + Map toDeviceConfigJson() { + return { + 'op': 'set', + 'device_id': deviceId.trim(), + 'upload_url': uploadUrl.trim(), + 'image_profile': imageProfile.trim(), + }; + } +} + +final RegExp _deviceConfigUuidPattern = RegExp( + r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', +); + +const Set _deviceImageProfiles = {'low', 'medium', 'high'}; + +bool isValidDeviceConfigId(String value) { + return _deviceConfigUuidPattern.hasMatch(value.trim()); +} + +bool isValidDeviceUploadUrl(String value) { + final trimmed = value.trim(); + return trimmed.length < 256 && + !trimmed.contains(RegExp(r'\s')) && + (trimmed.startsWith('http://') || trimmed.startsWith('https://')); +} + +bool isValidDeviceImageProfile(String value) { + return _deviceImageProfiles.contains(value.trim()); +} + +class DeviceProvisioningResult { + const DeviceProvisioningResult({this.deviceIp}); + + final String? deviceIp; +} + +class DeviceProvisioningException implements Exception { + const DeviceProvisioningException(this.message); + + final String message; + + @override + String toString() => message; +} + +class EspBleDeviceProvisioner { + EspBleDeviceProvisioner({ + required ble.FlutterReactiveBle ble, + required String bleDeviceId, + required ble.Uuid provisioningServiceUuid, + required bool Function() isConnected, + this.onProgress, + this.statusTimeout = const Duration(seconds: 45), + this.statusPollInterval = const Duration(seconds: 2), + }) : _transport = ReactiveBleProvTransport( + ble: ble, + deviceId: bleDeviceId, + serviceUuid: provisioningServiceUuid, + isConnected: isConnected, + ); + + final ReactiveBleProvTransport _transport; + final void Function(String message)? onProgress; + final Duration statusTimeout; + final Duration statusPollInterval; + + Future provision( + DeviceProvisioningRequest request, + ) async { + request.validateDeviceConfig(); + + final security = Security1(); + final prov = EspProv(transport: _transport, security: security); + + onProgress?.call('正在建立安全会话'); + final connected = await _transport.connect(); + _debugProvisioningLog('transport.connect => $connected'); + if (!connected) { + throw const DeviceProvisioningException('设备连接已断开'); + } + + late final EstablishSessionStatus sessionStatus; + try { + sessionStatus = await prov.establishSession().timeout( + const Duration(seconds: 20), + ); + } catch (error) { + _debugProvisioningLog('establishSession error => $error'); + rethrow; + } + _debugProvisioningLog('establishSession => $sessionStatus'); + if (sessionStatus != EstablishSessionStatus.connected) { + throw DeviceProvisioningException( + sessionStatus == EstablishSessionStatus.keymismatch + ? '安全会话校验失败' + : '安全会话建立失败', + ); + } + + onProgress?.call('正在发送 Wi-Fi 凭据'); + late final bool configSent; + try { + configSent = await prov + .sendWifiConfig( + ssid: request.wifiSsid, + password: request.wifiPassword, + ) + .timeout(const Duration(seconds: 20)); + } catch (error) { + _debugProvisioningLog('sendWifiConfig error => $error'); + rethrow; + } + _debugProvisioningLog('sendWifiConfig => $configSent'); + if (!configSent) { + throw const DeviceProvisioningException('Wi-Fi 凭据写入失败'); + } + + onProgress?.call('正在写入上传参数'); + await _sendDeviceConfig(security, request); + + onProgress?.call('正在应用 Wi-Fi 配置'); + late final bool configApplied; + try { + configApplied = await prov.applyWifiConfig().timeout( + const Duration(seconds: 100), + ); + } catch (error) { + _debugProvisioningLog('applyWifiConfig error => $error'); + rethrow; + } + _debugProvisioningLog('😢applyWifiConfig => $configApplied'); + if (!configApplied) { + throw const DeviceProvisioningException('Wi-Fi 配置应用失败'); + } + + onProgress?.call('等待设备连接 Wi-Fi'); + final status = await _waitForWifiConnected(prov); + return DeviceProvisioningResult(deviceIp: status.deviceIp); + } + + Future _sendDeviceConfig( + ProvSecurity security, + DeviceProvisioningRequest request, + ) async { + _debugProvisioningLog( + 'device-config request => ${jsonEncode(request.toDeviceConfigJson())}', + ); + final requestBytes = Uint8List.fromList( + utf8.encode(jsonEncode(request.toDeviceConfigJson())), + ); + final encrypted = await security.encrypt(requestBytes); + late final Uint8List rawResponse; + try { + rawResponse = await _transport + .sendReceive('device-config', encrypted) + .timeout(const Duration(seconds: 20)); + } catch (error) { + _debugProvisioningLog('device-config error => $error'); + rethrow; + } + if (rawResponse.isEmpty) { + throw const DeviceProvisioningException('设备配置没有返回结果'); + } + + final decrypted = await security.decrypt(rawResponse); + _debugProvisioningLog( + 'device-config raw response => ${_printableDeviceConfigResponse(utf8.decode(decrypted, allowMalformed: true))}', + ); + final decoded = decodeDeviceConfigResponse(decrypted); + _debugProvisioningLog('device-config decoded response => $decoded'); + + if (decoded['ok'] == true) { + return; + } + + final error = decoded['error']?.toString(); + throw DeviceProvisioningException(_deviceConfigErrorMessage(error)); + } + + Future _waitForWifiConnected(EspProv prov) async { + final deadline = DateTime.now().add(statusTimeout); + + while (DateTime.now().isBefore(deadline)) { + late final ConnectionStatus status; + try { + status = await prov.getStatus().timeout(const Duration(seconds: 10)); + _debugProvisioningLog( + 'getStatus => state=${status.state}, failedReason=${status.failedReason}, deviceIp=${status.deviceIp}', + ); + } catch (error) { + _debugProvisioningLog('getStatus error => $error'); + if (!await _transport.checkConnect()) { + onProgress?.call('设备已退出配网模式'); + return ConnectionStatus(state: WifiConnectionState.Connected); + } + rethrow; + } + + switch (status.state) { + case WifiConnectionState.Connected: + return status; + case WifiConnectionState.ConnectionFailed: + throw DeviceProvisioningException( + _wifiFailureMessage(status.failedReason), + ); + case WifiConnectionState.Connecting: + case WifiConnectionState.Disconnected: + await Future.delayed(statusPollInterval); + } + } + + throw const DeviceProvisioningException('等待 Wi-Fi 连接超时'); + } + + String _wifiFailureMessage(WifiConnectFailedReason? reason) { + return switch (reason) { + WifiConnectFailedReason.AuthError => 'Wi-Fi 密码错误', + WifiConnectFailedReason.NetworkNotFound => '没有找到 Wi-Fi 网络', + _ => 'Wi-Fi 连接失败', + }; + } + + String _deviceConfigErrorMessage(String? error) { + return switch (error) { + 'invalid_json' => '上传参数格式错误', + 'invalid_op' => '设备配置操作不支持', + 'invalid_device_id' => '设备 ID 无效', + 'invalid_upload_url' => '上传地址无效', + 'invalid_image_profile' => '图片档位无效', + 'storage_failed' => '设备保存配置失败', + 'not_ready' => '设备尚未准备好', + _ => error == null || error.isEmpty ? '上传参数写入失败' : error, + }; + } +} + +void _debugProvisioningLog(String message) { + if (!kDebugMode) { + return; + } + // ignore: avoid_print + print('[DeviceProvisioning] $message'); +} + +Map decodeDeviceConfigResponse(Uint8List data) { + final rawText = utf8.decode(data, allowMalformed: true); + final normalized = _normalizeDeviceConfigJson(rawText); + + try { + final decoded = jsonDecode(normalized); + if (decoded is Map) { + return decoded; + } + } on FormatException { + // Fall through to the contextual error below. + } + + throw DeviceProvisioningException( + '设备配置返回不是有效 JSON:${_printableDeviceConfigResponse(rawText)}', + ); +} + +String _normalizeDeviceConfigJson(String rawText) { + final text = rawText.replaceAll('\u0000', '').trim(); + final objectStart = text.indexOf('{'); + final objectEnd = text.lastIndexOf('}'); + + if (objectStart >= 0 && objectEnd >= objectStart) { + return text.substring(objectStart, objectEnd + 1); + } + + if (text.startsWith('(') && text.endsWith('}')) { + return '{${text.substring(1)}'; + } + + return text; +} + +String _printableDeviceConfigResponse(String rawText) { + return rawText + .replaceAll('\u0000', r'\0') + .replaceAll('\r', r'\r') + .replaceAll('\n', r'\n'); +} + +class ReactiveBleProvTransport implements ProvTransport { + ReactiveBleProvTransport({ + required ble.FlutterReactiveBle ble, + required String deviceId, + required ble.Uuid serviceUuid, + required bool Function() isConnected, + this.timeout = const Duration(seconds: 12), + Map? endpointIds, + }) : _ble = ble, + _deviceId = deviceId, + _serviceUuid = serviceUuid.expanded, + _isConnected = isConnected, + _endpointIds = { + ...defaultEndpointIds, + if (endpointIds != null) ...endpointIds, + }; + + static const Map defaultEndpointIds = { + 'prov-scan': 0xff50, + 'prov-session': 0xff51, + 'prov-config': 0xff52, + 'proto-ver': 0xff53, + 'custom-data': 0xff54, + 'device-config': 0xff54, + }; + + final ble.FlutterReactiveBle _ble; + final String _deviceId; + final ble.Uuid _serviceUuid; + final bool Function() _isConnected; + final Duration timeout; + final Map _endpointIds; + + bool _prepared = false; + + @override + Future connect() async { + if (!await checkConnect()) { + return false; + } + + try { + await _ble.requestMtu(deviceId: _deviceId, mtu: 256).timeout(timeout); + } catch (_) { + // MTU negotiation is best-effort; provisioning still works with smaller packets. + } + + await _ble.discoverAllServices(_deviceId).timeout(timeout); + _prepared = true; + return true; + } + + @override + Future checkConnect() async => _isConnected(); + + @override + Future disconnect() async { + _prepared = false; + return true; + } + + @override + Future sendReceive(String epName, Uint8List data) async { + if (!await checkConnect()) { + throw const DeviceProvisioningException('设备连接已断开'); + } + + if (!_prepared) { + final connected = await connect(); + if (!connected) { + throw const DeviceProvisioningException('设备连接已断开'); + } + } + + final characteristic = ble.QualifiedCharacteristic( + characteristicId: characteristicUuidForEndpoint(epName), + serviceId: _serviceUuid, + deviceId: _deviceId, + ); + + if (data.isNotEmpty) { + await _ble + .writeCharacteristicWithResponse(characteristic, value: data) + .timeout(timeout); + } + + final response = + await _ble.readCharacteristic(characteristic).timeout(timeout); + return Uint8List.fromList(response); + } + + ble.Uuid characteristicUuidForEndpoint(String endpointName) { + final endpointId = _endpointIds[endpointName]; + if (endpointId == null) { + throw DeviceProvisioningException('未知设备端点:$endpointName'); + } + + return endpointUuid(_serviceUuid, endpointId); + } + + static ble.Uuid endpointUuid(ble.Uuid serviceUuid, int endpointId) { + final bytes = List.from(serviceUuid.expanded.data); + if (bytes.length != 16) { + throw const DeviceProvisioningException('Service UUID 格式错误'); + } + + bytes[2] = (endpointId >> 8) & 0xff; + bytes[3] = endpointId & 0xff; + return ble.Uuid(bytes); + } +} diff --git a/apps/mobile/lib/features/home/presentation/pages/home_page.dart b/apps/mobile/lib/features/home/presentation/pages/home_page.dart new file mode 100644 index 00000000..6a94fe3d --- /dev/null +++ b/apps/mobile/lib/features/home/presentation/pages/home_page.dart @@ -0,0 +1,98 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../app/router/app_router.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../../auth/data/auth_api.dart'; +import '../widgets/home_hero.dart'; +import '../widgets/home_top_bar.dart'; + +class HomePage extends StatelessWidget { + const HomePage({ + super.key, + required this.authApi, + required this.themeModeListenable, + required this.onToggleThemeMode, + }); + + final AuthApi authApi; + final ValueListenable themeModeListenable; + final VoidCallback onToggleThemeMode; + + @override + Widget build(BuildContext context) { + Future openEntry() async { + final navigator = Navigator.of(context); + final hasSession = await authApi.hasStoredSession(); + if (!context.mounted) { + return; + } + + if (!hasSession) { + navigator.pushNamed(AppRoutes.login); + return; + } + + try { + await authApi.me(); + if (!context.mounted) { + return; + } + navigator.pushNamed(AppRoutes.workspace); + } catch (_) { + await authApi.clearStoredSession(); + if (!context.mounted) { + return; + } + navigator.pushNamed(AppRoutes.login); + } + } + + return Scaffold( + body: StarryBackground( + showHomeOrnaments: true, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = + constraints.maxWidth < 620 || constraints.maxHeight < 680; + final horizontal = constraints.maxWidth < 600 ? 20.0 : 32.0; + + final content = Padding( + padding: EdgeInsets.symmetric( + horizontal: horizontal, + vertical: 20, + ), + child: Column( + children: [ + HomeTopBar( + onEnterWorkspace: openEntry, + themeModeListenable: themeModeListenable, + onToggleThemeMode: onToggleThemeMode, + ), + SizedBox(height: compact ? 88 : 0), + if (!compact) const Spacer(), + HomeHero(onStart: openEntry), + if (!compact) const Spacer(flex: 2), + if (compact) const SizedBox(height: 96), + ], + ), + ); + + if (!compact) { + return content; + } + + return SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: content, + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/home/presentation/widgets/home_hero.dart b/apps/mobile/lib/features/home/presentation/widgets/home_hero.dart new file mode 100644 index 00000000..8fc2b157 --- /dev/null +++ b/apps/mobile/lib/features/home/presentation/widgets/home_hero.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/widgets/gradient_action_button.dart'; + +class HomeHero extends StatelessWidget { + const HomeHero({super.key, required this.onStart}); + + final VoidCallback onStart; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + final titleSize = width < 420 ? 34.0 : 44.0; + final colorScheme = Theme.of(context).colorScheme; + final secondaryTextColor = Theme.of(context).brightness == Brightness.light + ? AppTheme.lightTextSecondary + : AppTheme.textSecondary; + + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.primary, AppTheme.primaryLight], + ), + borderRadius: BorderRadius.circular(999), + ), + child: const Text( + 'AI 驱动 · 专为学生设计', + style: TextStyle( + color: AppTheme.textPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 28), + Text( + '重塑错题整理', + textAlign: TextAlign.center, + style: TextStyle( + color: colorScheme.onSurface, + fontSize: titleSize, + fontWeight: FontWeight.w900, + height: 1.16, + ), + ), + FlowingGradientText( + text: '一键生成知识图谱', + style: TextStyle( + fontSize: titleSize, + fontWeight: FontWeight.w900, + height: 1.16, + ), + ), + const SizedBox(height: 28), + Text( + '上传试卷或手写笔记,AI 自动完成 OCR 识别、题目分割、公式还原、知识点标注。', + textAlign: TextAlign.center, + style: TextStyle( + color: secondaryTextColor, + fontSize: 18, + height: 1.7, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 34), + Wrap( + alignment: WrapAlignment.center, + spacing: 14, + runSpacing: 12, + children: [ + GradientActionButton( + label: '开始使用', + icon: Icons.cloud_upload_outlined, + onPressed: onStart, + ), + ], + ), + ], + ), + ); + } +} + +class FlowingGradientText extends StatefulWidget { + const FlowingGradientText({ + super.key, + required this.text, + required this.style, + this.colors = const [ + AppTheme.primaryLight, + AppTheme.textPrimary, + AppTheme.primary, + AppTheme.primaryLight, + ], + }); + + final String text; + final TextStyle style; + final List colors; + + @override + State createState() => _FlowingGradientTextState(); +} + +class _FlowingGradientTextState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2600), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) { + final flowOffset = bounds.width * (_controller.value * 2 - 1); + final shaderBounds = Rect.fromLTWH( + bounds.left + flowOffset, + bounds.top, + bounds.width * 2, + bounds.height, + ); + return LinearGradient( + colors: widget.colors, + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ).createShader(shaderBounds); + }, + child: child, + ); + }, + child: Text( + widget.text, + textAlign: TextAlign.center, + style: widget.style.copyWith(color: AppTheme.textPrimary), + ), + ); + } +} diff --git a/apps/mobile/lib/features/home/presentation/widgets/home_top_bar.dart b/apps/mobile/lib/features/home/presentation/widgets/home_top_bar.dart new file mode 100644 index 00000000..bacd130a --- /dev/null +++ b/apps/mobile/lib/features/home/presentation/widgets/home_top_bar.dart @@ -0,0 +1,90 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/constants/app_assets.dart'; + +class HomeTopBar extends StatelessWidget { + const HomeTopBar({ + super.key, + required this.onEnterWorkspace, + required this.themeModeListenable, + required this.onToggleThemeMode, + }); + + final VoidCallback onEnterWorkspace; + final ValueListenable themeModeListenable; + final VoidCallback onToggleThemeMode; + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).colorScheme.onSurface; + + return Row( + children: [ + Container( + width: 38, + height: 38, + padding: const EdgeInsets.all(9), + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(11), + boxShadow: [ + BoxShadow( + color: AppTheme.primary.withOpacity(0.35), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: SvgPicture.asset( + AppAssets.logo, + key: const Key('app-logo'), + colorFilter: const ColorFilter.mode( + AppTheme.textPrimary, + BlendMode.srcIn, + ), + placeholderBuilder: (_) => const Icon( + Icons.edit_document, + color: AppTheme.textPrimary, + size: 18, + ), + ), + ), + const SizedBox(width: 12), + Flexible( + child: Text( + '智卷错题本', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: textColor, + fontSize: 20, + fontWeight: FontWeight.w800, + ), + ), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: themeModeListenable, + builder: (context, themeMode, _) { + final isDarkTheme = themeMode == ThemeMode.dark; + + return IconButton( + key: const Key('theme-toggle-button'), + tooltip: isDarkTheme ? '切换为日间主题' : '切换为夜间主题', + onPressed: onToggleThemeMode, + icon: Icon( + isDarkTheme + ? Icons.light_mode_rounded + : Icons.dark_mode_rounded, + color: textColor, + ), + ); + }, + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/login/presentation/pages/login_page.dart b/apps/mobile/lib/features/login/presentation/pages/login_page.dart new file mode 100644 index 00000000..d68ccb87 --- /dev/null +++ b/apps/mobile/lib/features/login/presentation/pages/login_page.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/router/app_router.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../../auth/data/auth_api.dart'; +import '../widgets/login_form_panel.dart'; +import '../widgets/login_hero_panel.dart'; + +class LoginPage extends StatelessWidget { + const LoginPage({super.key, this.authApi}); + + final AuthApi? authApi; + + @override + Widget build(BuildContext context) { + void openWorkspace() { + Navigator.of(context).pushReplacementNamed(AppRoutes.workspace); + } + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + showStars: true, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxHeight < 760; + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + + return AnimatedPadding( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + padding: EdgeInsets.only(bottom: keyboardInset), + child: SingleChildScrollView( + keyboardDismissBehavior: + ScrollViewKeyboardDismissBehavior.onDrag, + padding: const EdgeInsets.only(bottom: 16), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + ), + child: Column( + children: [ + SizedBox( + height: compact ? 200 : 220, + child: const LoginHeroPanel(), + ), + LoginFormPanel( + authApi: authApi, + onLogin: openWorkspace, + ), + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/login/presentation/widgets/login_form_panel.dart b/apps/mobile/lib/features/login/presentation/widgets/login_form_panel.dart new file mode 100644 index 00000000..db0ab502 --- /dev/null +++ b/apps/mobile/lib/features/login/presentation/widgets/login_form_panel.dart @@ -0,0 +1,720 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../auth/data/auth_api.dart'; + +enum _AuthMode { login, register } + +class LoginFormPanel extends StatefulWidget { + const LoginFormPanel({super.key, required this.onLogin, this.authApi}); + + final VoidCallback onLogin; + final AuthApi? authApi; + + @override + State createState() => _LoginFormPanelState(); +} + +class _LoginFormPanelState extends State { + late final AuthApi _authApi; + final _identifierController = TextEditingController(); + final _usernameController = TextEditingController(); + final _emailController = TextEditingController(); + final _codeController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + + _AuthMode _mode = _AuthMode.login; + bool _obscurePassword = true; + bool _obscureConfirmPassword = true; + bool _isSubmitting = false; + bool _isSendingCode = false; + + @override + void initState() { + super.initState(); + _authApi = widget.authApi ?? AuthApi(); + } + + @override + void dispose() { + _identifierController.dispose(); + _usernameController.dispose(); + _emailController.dispose(); + _codeController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + final colorScheme = Theme.of(context).colorScheme; + final textColor = colorScheme.onSurface; + final secondaryColor = + isLight ? AppTheme.lightTextSecondary : AppTheme.textSecondary; + final fieldFill = isLight ? Colors.white : const Color(0xFF15151D); + final panelBorder = + isLight ? const Color(0xFFE3E5EE) : const Color(0xFF2A2A35); + final isRegister = _mode == _AuthMode.register; + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + final fieldScrollPadding = EdgeInsets.fromLTRB( + 20, + 20, + 20, + keyboardInset + 96, + ); + + return Padding( + padding: const EdgeInsets.fromLTRB(36, 28, 36, 36), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isRegister ? '创建账户' : '欢迎回来', + style: TextStyle( + color: textColor, + fontSize: 30, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + Text( + isRegister ? '免费注册,开始智能错题整理' : '登录以继续使用你的错题本', + style: TextStyle(color: secondaryColor, fontSize: 17), + ), + const SizedBox(height: 34), + Container( + height: 56, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: + isLight ? const Color(0xFFF1F2F6) : const Color(0xFF12121A), + borderRadius: BorderRadius.circular(15), + border: Border.all(color: panelBorder), + ), + child: Row( + children: [ + Expanded( + child: _SegmentButton( + label: '登录', + selected: !isRegister, + onTap: () { + setState(() { + _mode = _AuthMode.login; + }); + }, + ), + ), + Expanded( + child: _SegmentButton( + label: '注册', + selected: isRegister, + onTap: () { + setState(() { + _mode = _AuthMode.register; + }); + }, + ), + ), + ], + ), + ), + const SizedBox(height: 28), + if (isRegister) + ..._buildRegisterFields( + textColor: textColor, + secondaryColor: secondaryColor, + fieldFill: fieldFill, + panelBorder: panelBorder, + isLight: isLight, + scrollPadding: fieldScrollPadding, + ) + else + ..._buildLoginFields( + textColor: textColor, + secondaryColor: secondaryColor, + fieldFill: fieldFill, + panelBorder: panelBorder, + scrollPadding: fieldScrollPadding, + ), + const SizedBox(height: 22), + SizedBox( + width: double.infinity, + height: 52, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppTheme.primary, AppTheme.primaryLight], + ), + borderRadius: BorderRadius.circular(14), + ), + child: TextButton( + onPressed: _isSubmitting ? null : _handlePrimaryPressed, + style: TextButton.styleFrom( + foregroundColor: AppTheme.textPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + child: Text( + _primaryButtonText(isRegister), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700), + ), + ), + ), + ), + ], + ), + ), + ); + } + + String _primaryButtonText(bool isRegister) { + if (_isSubmitting) { + return isRegister ? '创建中...' : '登录中...'; + } + + return isRegister ? '创建账户' : '登录'; + } + + Future _handlePrimaryPressed() async { + if (_mode == _AuthMode.register) { + await _register(); + } else { + await _login(); + } + } + + Future _login() async { + final identifier = _identifierController.text.trim(); + final password = _passwordController.text; + if (identifier.isEmpty) { + _showMessage('请输入邮箱或用户名'); + return; + } + if (password.isEmpty) { + _showMessage('请输入密码'); + return; + } + + setState(() { + _isSubmitting = true; + }); + try { + await _authApi.login(identifier: identifier, password: password); + if (mounted) { + widget.onLogin(); + } + } on ApiException catch (error, stackTrace) { + debugPrint('[Login] api error: $error'); + debugPrintStack( + label: '[Login] api stack', + stackTrace: stackTrace, + maxFrames: 20, + ); + _showMessage(error.message); + } catch (error, stackTrace) { + debugPrint('[Login] unexpected error: $error'); + debugPrintStack( + label: '[Login] unexpected stack', + stackTrace: stackTrace, + maxFrames: 20, + ); + _showMessage('登录失败:$error'); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + Future _register() async { + final username = _usernameController.text.trim(); + final email = _emailController.text.trim(); + final code = _codeController.text.trim(); + final password = _passwordController.text; + final confirmPassword = _confirmPasswordController.text; + + if (username.isEmpty) { + _showMessage('请输入用户名'); + return; + } + if (email.isEmpty) { + _showMessage('请输入邮箱'); + return; + } + if (code.isEmpty) { + _showMessage('请输入验证码'); + return; + } + if (password.length < 6) { + _showMessage('密码至少 6 位'); + return; + } + if (password != confirmPassword) { + _showMessage('两次输入的密码不一致'); + return; + } + + setState(() { + _isSubmitting = true; + }); + try { + await _authApi.register( + email: email, + username: username, + password: password, + code: code, + ); + if (mounted) { + widget.onLogin(); + } + } on ApiException catch (error) { + _showMessage(error.message); + } catch (_) { + _showMessage('注册失败,请稍后再试'); + } finally { + if (mounted) { + setState(() { + _isSubmitting = false; + }); + } + } + } + + Future _sendCode() async { + final email = _emailController.text.trim(); + if (email.isEmpty) { + _showMessage('请输入邮箱'); + return; + } + + setState(() { + _isSendingCode = true; + }); + try { + await _authApi.sendCode(email: email, type: 'register'); + _showMessage('验证码已发送'); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (_) { + _showMessage('验证码发送失败,请稍后再试'); + } finally { + if (mounted) { + setState(() { + _isSendingCode = false; + }); + } + } + } + + void _showMessage(String message) { + if (!mounted) { + return; + } + + showAppSnackBar(context, message); + } + + List _buildLoginFields({ + required Color textColor, + required Color secondaryColor, + required Color fieldFill, + required Color panelBorder, + required EdgeInsets scrollPadding, + }) { + return [ + Text('账号', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + _KeyboardAwareTextField( + controller: _identifierController, + scrollPadding: scrollPadding, + textInputAction: TextInputAction.next, + decoration: _fieldDecoration( + hintText: '请输入邮箱或用户名', + fillColor: fieldFill, + borderColor: panelBorder, + ), + ), + const SizedBox(height: 24), + Text('密码', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + _KeyboardAwareTextField( + controller: _passwordController, + scrollPadding: scrollPadding, + textInputAction: TextInputAction.done, + obscureText: _obscurePassword, + decoration: _fieldDecoration( + hintText: '请输入密码', + fillColor: fieldFill, + borderColor: panelBorder, + suffixIcon: _PasswordVisibilityButton( + obscure: _obscurePassword, + color: secondaryColor, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + ), + const SizedBox(height: 14), + Align( + alignment: Alignment.centerRight, + child: Text( + '忘记密码?', + style: TextStyle(color: secondaryColor, fontSize: 14), + ), + ), + ]; + } + + List _buildRegisterFields({ + required Color textColor, + required Color secondaryColor, + required Color fieldFill, + required Color panelBorder, + required bool isLight, + required EdgeInsets scrollPadding, + }) { + return [ + Text('用户名', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + _KeyboardAwareTextField( + controller: _usernameController, + scrollPadding: scrollPadding, + textInputAction: TextInputAction.next, + decoration: _fieldDecoration( + hintText: '您的昵称', + fillColor: fieldFill, + borderColor: panelBorder, + ), + ), + const SizedBox(height: 22), + Text('邮箱', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _KeyboardAwareTextField( + controller: _emailController, + scrollPadding: scrollPadding, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: _fieldDecoration( + hintText: 'your@email.com', + fillColor: fieldFill, + borderColor: panelBorder, + ), + ), + ), + const SizedBox(width: 10), + SizedBox( + width: 118, + height: 52, + child: TextButton( + onPressed: _isSendingCode ? null : _sendCode, + style: TextButton.styleFrom( + foregroundColor: textColor, + backgroundColor: fieldFill, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15), + side: BorderSide(color: panelBorder), + ), + ), + child: Text( + _isSendingCode ? '发送中...' : '发送验证码', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + ), + ], + ), + const SizedBox(height: 22), + Text('验证码', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + _KeyboardAwareTextField( + controller: _codeController, + scrollPadding: scrollPadding, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.next, + decoration: _fieldDecoration( + hintText: '6 位验证码', + fillColor: fieldFill, + borderColor: panelBorder, + ), + ), + const SizedBox(height: 22), + Text('密码', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + _KeyboardAwareTextField( + controller: _passwordController, + scrollPadding: scrollPadding, + textInputAction: TextInputAction.next, + obscureText: _obscurePassword, + decoration: _fieldDecoration( + hintText: '至少 6 位', + fillColor: fieldFill, + borderColor: panelBorder, + suffixIcon: _PasswordVisibilityButton( + obscure: _obscurePassword, + color: secondaryColor, + onPressed: () { + setState(() { + _obscurePassword = !_obscurePassword; + }); + }, + ), + ), + ), + const SizedBox(height: 22), + Text('确认密码', style: TextStyle(color: textColor, fontSize: 16)), + const SizedBox(height: 10), + _KeyboardAwareTextField( + controller: _confirmPasswordController, + scrollPadding: scrollPadding, + textInputAction: TextInputAction.done, + obscureText: _obscureConfirmPassword, + decoration: _fieldDecoration( + hintText: '再次输入密码', + fillColor: fieldFill, + borderColor: panelBorder, + suffixIcon: _PasswordVisibilityButton( + obscure: _obscureConfirmPassword, + color: secondaryColor, + onPressed: () { + setState(() { + _obscureConfirmPassword = !_obscureConfirmPassword; + }); + }, + ), + ), + ), + ]; + } + + InputDecoration _fieldDecoration({ + required String hintText, + required Color fillColor, + required Color borderColor, + Color? focusedBorderColor, + Widget? suffixIcon, + }) { + return InputDecoration( + hintText: hintText, + suffixIcon: suffixIcon, + filled: true, + fillColor: fillColor, + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide(color: borderColor), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide(color: borderColor), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + borderSide: BorderSide(color: focusedBorderColor ?? AppTheme.primary), + ), + ); + } +} + +class _SegmentButton extends StatelessWidget { + const _SegmentButton({ + required this.label, + required this.selected, + required this.onTap, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Ink( + decoration: BoxDecoration( + color: selected + ? (isLight ? Colors.white : const Color(0xFF2A2A32)) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + ), + ), + ), + ), + ), + ); + } +} + +class _KeyboardAwareTextField extends StatefulWidget { + const _KeyboardAwareTextField({ + required this.controller, + required this.decoration, + required this.scrollPadding, + this.keyboardType, + this.textInputAction, + this.obscureText = false, + }); + + final TextEditingController controller; + final InputDecoration decoration; + final EdgeInsets scrollPadding; + final TextInputType? keyboardType; + final TextInputAction? textInputAction; + final bool obscureText; + + @override + State<_KeyboardAwareTextField> createState() => + _KeyboardAwareTextFieldState(); +} + +class _KeyboardAwareTextFieldState extends State<_KeyboardAwareTextField> { + final FocusNode _focusNode = FocusNode(); + int _ensureVisibleToken = 0; + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode + ..removeListener(_handleFocusChange) + ..dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (!_focusNode.hasFocus) { + return; + } + + _scheduleEnsureVisible(); + } + + void _scheduleEnsureVisible() { + final token = ++_ensureVisibleToken; + WidgetsBinding.instance.addPostFrameCallback( + (_) => _ensureVisible(token, duration: const Duration(milliseconds: 180)), + ); + Future.delayed( + const Duration(milliseconds: 140), + () => _ensureVisible(token, duration: const Duration(milliseconds: 260)), + ); + Future.delayed( + const Duration(milliseconds: 340), + () => _ensureVisible(token, duration: const Duration(milliseconds: 220)), + ); + } + + void _ensureVisible(int token, {required Duration duration}) { + if (!mounted || !_focusNode.hasFocus || token != _ensureVisibleToken) { + return; + } + + final scrollable = Scrollable.maybeOf(context); + final renderObject = context.findRenderObject(); + if (scrollable == null || renderObject is! RenderBox) { + return; + } + + final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; + final screenHeight = MediaQuery.sizeOf(context).height; + final safeBottom = MediaQuery.paddingOf(context).bottom; + final visibleBottom = screenHeight - keyboardInset - safeBottom - 24; + final fieldBottom = + renderObject.localToGlobal(Offset(0, renderObject.size.height)).dy; + final coveredDistance = fieldBottom - visibleBottom; + + if (coveredDistance > 6) { + final position = scrollable.position; + final target = (position.pixels + coveredDistance + 18).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((target - position.pixels).abs() < 2) { + return; + } + position.animateTo( + target, + duration: duration, + curve: Curves.easeOutCubic, + ); + return; + } + + Scrollable.ensureVisible( + context, + duration: duration, + curve: Curves.easeOutCubic, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + ); + } + + @override + Widget build(BuildContext context) { + return TextField( + focusNode: _focusNode, + controller: widget.controller, + keyboardType: widget.keyboardType, + scrollPadding: widget.scrollPadding, + textInputAction: widget.textInputAction, + obscureText: widget.obscureText, + decoration: widget.decoration, + onTap: _scheduleEnsureVisible, + ); + } +} + +class _PasswordVisibilityButton extends StatelessWidget { + const _PasswordVisibilityButton({ + required this.obscure, + required this.color, + required this.onPressed, + }); + + final bool obscure; + final Color color; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: Icon( + obscure ? Icons.visibility_rounded : Icons.visibility_off_rounded, + color: color, + ), + ); + } +} diff --git a/apps/mobile/lib/features/login/presentation/widgets/login_hero_panel.dart b/apps/mobile/lib/features/login/presentation/widgets/login_hero_panel.dart new file mode 100644 index 00000000..d48fbf49 --- /dev/null +++ b/apps/mobile/lib/features/login/presentation/widgets/login_hero_panel.dart @@ -0,0 +1,241 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/constants/app_assets.dart'; +import '../../../home/presentation/widgets/home_hero.dart'; + +class LoginHeroPanel extends StatelessWidget { + const LoginHeroPanel({super.key}); + + @override + Widget build(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + final titleColor = Theme.of(context).colorScheme.onSurface; + + return Stack( + fit: StackFit.expand, + children: [ + const Positioned.fill(child: FlowingLoginWave()), + Padding( + padding: const EdgeInsets.fromLTRB(36, 28, 36, 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 50, + height: 50, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppTheme.primary, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: AppTheme.primary.withOpacity(0.28), + blurRadius: 28, + offset: const Offset(0, 12), + ), + ], + ), + child: SvgPicture.asset( + AppAssets.logo, + colorFilter: const ColorFilter.mode( + AppTheme.textPrimary, + BlendMode.srcIn, + ), + ), + ), + const SizedBox(width: 14), + Text( + '智卷错题本', + style: TextStyle( + color: titleColor, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + const Spacer(), + Text( + '重塑错题整理', + style: TextStyle( + color: titleColor, + fontSize: 28, + height: 1.1, + fontWeight: FontWeight.w900, + ), + ), + FlowingGradientText( + text: '一键生成知识图谱', + style: const TextStyle( + fontSize: 32, + height: 1.12, + fontWeight: FontWeight.w900, + ), + colors: [ + AppTheme.primary, + isLight ? AppTheme.primaryLight : AppTheme.textPrimary, + AppTheme.primaryLight, + AppTheme.primary, + ], + ), + ], + ), + ), + ], + ); + } +} + +class FlowingLoginWave extends StatefulWidget { + const FlowingLoginWave({super.key}); + + @override + State createState() => _FlowingLoginWaveState(); +} + +class _FlowingLoginWaveState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isLight = Theme.of(context).brightness == Brightness.light; + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + return CustomPaint( + painter: LoginWavePainter( + progress: _controller.value, + isLight: isLight, + ), + ); + }, + ); + } +} + +class LoginWavePainter extends CustomPainter { + const LoginWavePainter({required this.progress, required this.isLight}); + + final double progress; + final bool isLight; + + @visibleForTesting + Path buildWavePath(Size size) { + final w = size.width; + final h = size.height; + + final baseY = h * 0.74; + final amplitude = h * 0.2; + + return Path() + ..moveTo(-w * 0.18, baseY) + ..cubicTo( + w * 0.02, + baseY + amplitude * 0.55, + w * 0.18, + baseY + amplitude * 1.15, + w * 0.34, + baseY + amplitude * 0.42, + ) + ..cubicTo( + w * 0.48, + baseY - amplitude * 0.28, + w * 0.58, + baseY - amplitude * 1.55, + w * 0.74, + baseY - amplitude * 0.7, + ) + ..cubicTo( + w * 0.88, + baseY + amplitude * 0.05, + w * 0.98, + baseY + amplitude * 0.95, + w * 1.12, + baseY + amplitude * 0.28, + ) + ..cubicTo( + w * 1.22, + baseY - amplitude * 0.22, + w * 1.3, + baseY - amplitude * 0.62, + w * 1.42, + baseY - amplitude * 0.18, + ); + } + + @visibleForTesting + Rect buildShaderRect(Size size) { + final shaderWidth = size.width * 2.4; + final dx = shaderWidth * progress; + + return Rect.fromLTWH(-shaderWidth + dx, 0, shaderWidth, size.height); + } + + @override + void paint(Canvas canvas, Size size) { + final path = buildWavePath(size); + + final baseColor = isLight + ? AppTheme.primary.withOpacity(0.24) + : AppTheme.primaryLight.withOpacity(0.18); + + final basePaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 4.2 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = baseColor; + + canvas.drawPath(path, basePaint); + + final shaderRect = buildShaderRect(size); + + final movingPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 4.6 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..shader = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + tileMode: TileMode.repeated, + colors: [ + Colors.white.withOpacity(isLight ? 0.12 : 0.1), + Colors.white.withOpacity(isLight ? 0.28 : 0.2), + AppTheme.primaryLight.withOpacity(isLight ? 0.12 : 0.1), + AppTheme.primaryLight.withOpacity(isLight ? 0.58 : 0.38), + AppTheme.primary.withOpacity(isLight ? 0.78 : 0.56), + Colors.white.withOpacity(isLight ? 0.12 : 0.1), + ], + stops: const [0.0, 0.22, 0.42, 0.62, 0.82, 1.0], + ).createShader(shaderRect); + + canvas.drawPath(path, movingPaint); + } + + @override + bool shouldRepaint(covariant LoginWavePainter oldDelegate) { + return oldDelegate.progress != progress || oldDelegate.isLight != isLight; + } +} diff --git a/apps/mobile/lib/features/workspace/data/workspace_api.dart b/apps/mobile/lib/features/workspace/data/workspace_api.dart new file mode 100644 index 00000000..1314cc93 --- /dev/null +++ b/apps/mobile/lib/features/workspace/data/workspace_api.dart @@ -0,0 +1,1523 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import '../../../core/network/api_client.dart'; +import '../../../core/utils/time_format.dart'; + +class WorkspaceApi { + WorkspaceApi({ApiClient? client}) : _client = client ?? ApiClient(); + + final ApiClient _client; + + Future getModelOptions() async { + final payload = await _client.getJson('/api/models/options'); + return ModelOptionsResponse.fromJson(payload); + } + + Future getSystemStatus() async { + final payload = await _client.getJson('/api/status'); + return SystemStatusResponse.fromJson(payload); + } + + Future uploadFiles({ + required List files, + bool resetSession = false, + }) async { + if (files.isEmpty) { + return const UploadResponse( + success: false, + message: '未选择文件', + result: UploadResult(fileCount: 0, files: []), + ); + } + + final multipartFiles = []; + + for (final file in files) { + multipartFiles.add( + http.MultipartFile.fromBytes( + 'files', + file.bytes, + filename: file.filename, + ), + ); + } + + final payload = await _client.postMultipart( + '/api/upload', + fields: {'reset_session': resetSession ? 'true' : 'false'}, + files: multipartFiles, + ); + + return UploadResponse.fromJson(payload); + } + + Future cancelUploadedFile({ + required String fileKey, + }) async { + final payload = await _client.postJson('/api/cancel_file', { + 'file_key': fileKey, + }); + return CancelUploadedFileResponse.fromJson(payload); + } + + Future resetUploadSession() async { + final payload = await _client.postJson('/api/upload/reset', const {}); + return ResetUploadSessionResponse.fromJson(payload); + } + + Future eraseUploadedFiles() async { + final payload = await _client.postJson('/api/erase', const {}); + return EraseResponse.fromJson(payload); + } + + Future runOcr() async { + final payload = await _client.postJson('/api/ocr', const {}); + return OcrResponse.fromJson(payload); + } + + Future splitQuestions({required SplitRequest request}) async { + final payload = await _client.postJson('/api/split', request.toJson()); + return SplitResponse.fromJson(payload); + } + + Future getSplitRecords({int limit = 10}) async { + final path = Uri( + path: '/api/split-records', + queryParameters: {'limit': '${limit.clamp(1, 100)}'}, + ).toString(); + final payload = await _client.getJson(path); + return SplitRecordsResponse.fromJson(payload); + } + + Future getSplitRecordDetail({ + required int recordId, + }) async { + final payload = await _client.getJson('/api/split-records/$recordId'); + return SplitRecordDetailResponse.fromJson(payload); + } + + Future getProjects({String? projectType}) async { + final path = Uri( + path: '/api/projects', + queryParameters: { + if (projectType != null && projectType.isNotEmpty) + 'project_type': projectType, + }, + ).toString(); + final payload = await _client.getJson(path); + return ProjectsResponse.fromJson(payload); + } + + Future saveSplitQuestionsToDb({ + String? runId, + int? splitRecordId, + required int projectId, + required List selectedIds, + List> answers = const [], + }) async { + final normalizedRunId = runId?.trim(); + final hasRunId = normalizedRunId != null && normalizedRunId.isNotEmpty; + final hasSplitRecordId = splitRecordId != null && splitRecordId > 0; + + if (!hasRunId && !hasSplitRecordId) { + throw const ApiException(statusCode: 0, message: '缺少分割任务 ID 或分割记录 ID'); + } + + final payload = await _client.postJson('/api/save-to-db', { + if (hasRunId) 'run_id': normalizedRunId, + if (hasSplitRecordId) 'split_record_id': splitRecordId, + 'project_id': projectId, + 'selected_ids': selectedIds, + 'answers': answers, + }); + + return SaveToDbResponse.fromJson(payload); + } + + Future organizeNotePreview({ + required List files, + SplitRequest? modelRequest, + }) async { + final multipartFiles = files + .map( + (file) => http.MultipartFile.fromBytes( + 'files', + file.bytes, + filename: file.filename, + ), + ) + .toList(); + + final payload = await _client.postMultipart( + '/api/notes/', + fields: { + if (modelRequest != null) ...{ + 'model_provider': modelRequest.modelProvider, + if (_hasValue(modelRequest.modelName)) + 'model_name': modelRequest.modelName!, + if (_hasValue(modelRequest.providerSource)) + 'provider_source': modelRequest.providerSource!, + if (_hasValue(modelRequest.providerId)) + 'provider_id': modelRequest.providerId!, + }, + }, + files: multipartFiles, + successCodes: const {200, 201}, + ); + + return NotePreviewResponse.fromJson(payload); + } + + Future saveOrganizedNote({ + required int projectId, + required NotePreview preview, + }) async { + final payload = await _client.postJson( + '/api/notes/save-organized', + preview.toSaveJson(projectId: projectId), + successCodes: const {200, 201}, + ); + return NoteSavedResponse.fromJson(payload); + } + + Future queryErrorBank({ + int page = 1, + int pageSize = 10, + String? subject, + String? knowledgeTag, + String? questionType, + String? keyword, + int? projectId, + String? reviewStatus, + String? startDate, + String? endDate, + }) async { + final path = Uri( + path: '/api/error-bank', + queryParameters: { + 'page': '$page', + 'page_size': '$pageSize', + if (_hasValue(subject)) 'subject': subject!.trim(), + if (_hasValue(knowledgeTag)) 'knowledge_tag': knowledgeTag!.trim(), + if (_hasValue(questionType)) 'question_type': questionType!.trim(), + if (_hasValue(keyword)) 'keyword': keyword!.trim(), + if (projectId != null) 'project_id': '$projectId', + if (_hasValue(reviewStatus)) 'review_status': reviewStatus!.trim(), + if (_hasValue(startDate)) 'start_date': startDate!.trim(), + if (_hasValue(endDate)) 'end_date': endDate!.trim(), + }, + ).toString(); + final payload = await _client.getJson(path); + return LibraryQuestionListResponse.fromJson(payload); + } + + Future queryNotes({ + int page = 1, + int limit = 10, + String? subject, + String? knowledgeTag, + String? keyword, + int? projectId, + }) async { + final path = Uri( + path: '/api/notes/', + queryParameters: { + 'page': '$page', + 'limit': '$limit', + if (_hasValue(subject)) 'subject': subject!.trim(), + if (_hasValue(knowledgeTag)) 'knowledge_tag': knowledgeTag!.trim(), + if (_hasValue(keyword)) 'keyword': keyword!.trim(), + if (projectId != null) 'project_id': '$projectId', + }, + ).toString(); + final payload = await _client.getJson(path); + return LibraryNoteListResponse.fromJson(payload); + } + + Future loadProtectedImage(String url) { + return _client.getBytes(url); + } + + String get baseUrl => _client.baseUrl; +} + +class UploadFileItem { + const UploadFileItem({required this.filename, required this.bytes}); + + final String filename; + final List bytes; +} + +class UploadResponse { + const UploadResponse({ + required this.success, + required this.message, + required this.result, + }); + + final bool success; + final String message; + final UploadResult result; + + factory UploadResponse.fromJson(Map json) { + return UploadResponse( + success: json['success'] as bool? ?? false, + message: json['message'] as String? ?? '上传失败', + result: UploadResult.fromJson( + (json['result'] as Map?)?.cast() ?? {}, + ), + ); + } +} + +class UploadResult { + const UploadResult({required this.fileCount, required this.files}); + + final int fileCount; + final List files; + + factory UploadResult.fromJson(Map json) { + final rawFiles = (json['files'] as List?) ?? const []; + return UploadResult( + fileCount: (json['file_count'] as int?) ?? rawFiles.length, + files: rawFiles + .whereType>() + .map(UploadedFile.fromJson) + .toList(), + ); + } +} + +class UploadedFile { + const UploadedFile({required this.fileKey, required this.filename}); + + final String fileKey; + final String filename; + + factory UploadedFile.fromJson(Map json) { + return UploadedFile( + fileKey: json['file_key'] as String? ?? '', + filename: json['filename'] as String? ?? '', + ); + } +} + +class CancelUploadedFileResponse { + const CancelUploadedFileResponse({ + required this.success, + required this.message, + }); + + final bool success; + final String message; + + factory CancelUploadedFileResponse.fromJson(Map json) { + return CancelUploadedFileResponse( + success: json['success'] as bool? ?? false, + message: json['message'] as String? ?? '操作失败', + ); + } +} + +class ResetUploadSessionResponse { + const ResetUploadSessionResponse({ + required this.success, + required this.message, + }); + + final bool success; + final String message; + + factory ResetUploadSessionResponse.fromJson(Map json) { + return ResetUploadSessionResponse( + success: json['success'] as bool? ?? false, + message: json['message'] as String? ?? '操作失败', + ); + } +} + +class EraseResponse { + const EraseResponse({ + required this.success, + required this.message, + required this.files, + }); + + final bool success; + final String message; + final List files; + + factory EraseResponse.fromJson(Map json) { + final rawResult = json['result']; + final fileList = []; + + void collectFiles(dynamic rawFiles) { + if (rawFiles is! List) { + return; + } + + for (final item in rawFiles) { + if (item is Map) { + fileList.add(EraseResultFile.fromJson(item.cast())); + } + } + } + + if (rawResult is List) { + collectFiles(rawResult); + } else if (rawResult is Map) { + collectFiles(rawResult['files']); + collectFiles(rawResult['images']); + } + + collectFiles(json['files']); + collectFiles(json['images']); + + fileList.sort((a, b) { + final left = a.index; + final right = b.index; + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + return left.compareTo(right); + }); + + return EraseResponse( + success: json['success'] as bool? ?? false, + message: json['message'] as String? ?? '操作失败', + files: fileList, + ); + } +} + +class EraseResultFile { + const EraseResultFile({ + required this.fileKey, + required this.beforeFileKey, + required this.afterFileKey, + required this.beforeImageUrl, + required this.afterImageUrl, + this.index, + }); + + final String fileKey; + final String? beforeFileKey; + final String? afterFileKey; + final String? beforeImageUrl; + final String? afterImageUrl; + final int? index; + + factory EraseResultFile.fromJson(Map json) { + final rawIndex = json['index']; + final index = rawIndex is int ? rawIndex : int.tryParse('$rawIndex'); + + return EraseResultFile( + fileKey: + (json['file_key'] ?? json['name'] ?? json['index'] ?? '').toString(), + beforeFileKey: json['before_file_key']?.toString(), + afterFileKey: json['after_file_key']?.toString(), + beforeImageUrl: + (json['before_image_url'] ?? json['original_url'])?.toString(), + afterImageUrl: + (json['after_image_url'] ?? json['erased_url'])?.toString(), + index: index, + ); + } +} + +class OcrResponse { + const OcrResponse({ + required this.success, + required this.message, + required this.pages, + required this.totalBlocks, + }); + + final bool success; + final String message; + final List pages; + final int totalBlocks; + + factory OcrResponse.fromJson(Map json) { + final rawPages = (json['pages'] as List?) ?? const []; + return OcrResponse( + success: json['success'] as bool? ?? false, + message: json['message'] as String? ?? 'OCR 失败', + pages: rawPages + .whereType>() + .map(OcrPage.fromJson) + .toList() + ..sort((a, b) => a.pageIndex.compareTo(b.pageIndex)), + totalBlocks: _readInt(json['total_blocks']) ?? 0, + ); + } +} + +class OcrPage { + const OcrPage({ + required this.pageIndex, + required this.pageWidth, + required this.pageHeight, + required this.imageUrl, + required this.blocks, + }); + + final int pageIndex; + final double pageWidth; + final double pageHeight; + final String? imageUrl; + final List blocks; + + factory OcrPage.fromJson(Map json) { + final rawBlocks = (json['blocks'] as List?) ?? const []; + return OcrPage( + pageIndex: _readInt(json['page_index']) ?? 0, + pageWidth: (_readNum(json['page_width']) ?? 1).toDouble(), + pageHeight: (_readNum(json['page_height']) ?? 1).toDouble(), + imageUrl: json['image_url']?.toString(), + blocks: rawBlocks + .whereType>() + .map(OcrBlock.fromJson) + .toList(), + ); + } +} + +class OcrBlock { + const OcrBlock({ + required this.bbox, + required this.content, + required this.label, + }); + + final List bbox; + final String content; + final String label; + + bool get hasValidBox => + bbox.length == 4 && bbox[2] > bbox[0] && bbox[3] > bbox[1]; + + factory OcrBlock.fromJson(Map json) { + final rawBox = (json['bbox'] as List?) ?? const []; + return OcrBlock( + bbox: rawBox + .map(_readNum) + .whereType() + .map((item) => item.toDouble()) + .toList(growable: false), + content: json['content']?.toString() ?? '', + label: json['label']?.toString() ?? 'text', + ); + } +} + +class SplitRequest { + const SplitRequest({ + required this.modelProvider, + required this.modelName, + required this.providerSource, + required this.providerId, + }); + + final String modelProvider; + final String? modelName; + final String? providerSource; + final String? providerId; + + Map toJson() { + return { + 'model_provider': modelProvider, + 'model_name': modelName, + 'provider_source': providerSource, + 'provider_id': providerId, + }; + } +} + +class SplitResponse { + const SplitResponse({ + required this.success, + required this.message, + required this.runId, + required this.questions, + required this.warnings, + }); + + final bool success; + final String message; + final String? runId; + final List questions; + final List warnings; + + factory SplitResponse.fromJson(Map json) { + final rawQuestions = (json['questions'] as List?) ?? const []; + final rawWarnings = (json['warnings'] as List?) ?? const []; + return SplitResponse( + success: json['success'] as bool? ?? false, + message: json['message'] as String? ?? '题目分割失败', + runId: json['run_id']?.toString(), + questions: rawQuestions + .whereType>() + .map(SplitQuestion.fromJson) + .toList(), + warnings: rawWarnings.map((item) => item.toString()).toList(), + ); + } +} + +class SplitQuestion { + const SplitQuestion({ + required this.uid, + required this.questionId, + required this.questionType, + required this.sectionTitle, + required this.contentBlocks, + required this.options, + required this.imageRefs, + required this.optionImages, + required this.hasFormula, + required this.hasImage, + required this.needsCorrection, + required this.knowledgeTags, + }); + + final String uid; + final String questionId; + final String? questionType; + final String? sectionTitle; + final List contentBlocks; + final List options; + final List imageRefs; + final List optionImages; + final bool hasFormula; + final bool hasImage; + final bool needsCorrection; + final List knowledgeTags; + + String get plainText { + return contentBlocks + .map((block) => block.content) + .where((text) => text.trim().isNotEmpty) + .join('\n'); + } + + factory SplitQuestion.fromJson(Map json) { + final rawBlocks = (json['content_blocks'] as List?) ?? + (json['content_json'] as List?) ?? + const []; + final rawOptions = (json['options'] as List?) ?? + (json['options_json'] as List?) ?? + const []; + final rawTags = (json['knowledge_tags'] as List?) ?? const []; + final rawImageRefs = (json['image_refs'] as List?) ?? const []; + final rawOptionImages = (json['option_images'] as List?) ?? const []; + + return SplitQuestion( + uid: (json['uid'] ?? json['id'] ?? '').toString(), + questionId: (json['question_id'] ?? json['id'] ?? '').toString(), + questionType: json['question_type']?.toString(), + sectionTitle: json['section_title']?.toString(), + contentBlocks: rawBlocks + .whereType>() + .map(SplitQuestionBlock.fromJson) + .toList(), + options: rawOptions.map((item) => item.toString()).toList(), + imageRefs: rawImageRefs.map((item) => item.toString()).toList(), + optionImages: rawOptionImages.map((item) => item.toString()).toList(), + hasFormula: json['has_formula'] as bool? ?? false, + hasImage: json['has_image'] as bool? ?? false, + needsCorrection: json['needs_correction'] as bool? ?? false, + knowledgeTags: rawTags.map((item) => item.toString()).toList(), + ); + } +} + +class SplitQuestionBlock { + const SplitQuestionBlock({required this.blockType, required this.content}); + + final String blockType; + final String content; + + bool get isImage => blockType.toLowerCase() == 'image'; + + factory SplitQuestionBlock.fromJson(Map json) { + return SplitQuestionBlock( + blockType: json['block_type']?.toString() ?? 'text', + content: json['content']?.toString() ?? '', + ); + } +} + +class SplitRecordsResponse { + const SplitRecordsResponse({required this.success, required this.records}); + + final bool success; + final List records; + + factory SplitRecordsResponse.fromJson(Map json) { + final rawRecords = (json['records'] as List?) ?? const []; + return SplitRecordsResponse( + success: json['success'] as bool? ?? false, + records: rawRecords + .whereType>() + .map(SplitRecord.fromJson) + .toList(growable: false), + ); + } +} + +class SplitRecordDetailResponse { + const SplitRecordDetailResponse({ + required this.success, + required this.message, + required this.record, + }); + + final bool success; + final String message; + final SplitRecord? record; + + factory SplitRecordDetailResponse.fromJson(Map json) { + final recordJson = _readSplitRecordJson(json); + return SplitRecordDetailResponse( + success: json['success'] as bool? ?? false, + message: (json['message'] ?? json['error'] ?? '').toString(), + record: recordJson == null ? null : SplitRecord.fromJson(recordJson), + ); + } + + static Map? _readSplitRecordJson(Map json) { + for (final key in const ['record', 'split_record', 'data', 'result']) { + final value = json[key]; + if (value is! Map) { + continue; + } + + final nestedRecord = value['record']; + if (nestedRecord is Map) { + return nestedRecord; + } + return value; + } + + if (json.containsKey('id') || + json.containsKey('questions') || + json.containsKey('question_count')) { + return json; + } + return null; + } +} + +class SplitRecord { + const SplitRecord({ + required this.id, + required this.subject, + required this.modelProvider, + required this.fileNames, + required this.originalImages, + required this.questionCount, + required this.createdAt, + required this.questions, + }); + + final int id; + final String? subject; + final String? modelProvider; + final List fileNames; + final List originalImages; + final int questionCount; + final DateTime? createdAt; + final List questions; + + String get displaySubject { + final value = subject?.trim(); + return value == null || value.isEmpty ? '未识别' : value; + } + + factory SplitRecord.fromJson(Map json) { + final rawFileNames = (json['file_names'] as List?) ?? const []; + final rawOriginalImages = (json['original_images'] as List?) ?? const []; + final rawQuestions = (json['questions'] as List?) ?? const []; + return SplitRecord( + id: _readInt(json['id']) ?? 0, + subject: json['subject']?.toString(), + modelProvider: json['model_provider']?.toString(), + fileNames: rawFileNames.map((item) => item.toString()).toList(), + originalImages: rawOriginalImages.map((item) => item.toString()).toList(), + questionCount: _readInt(json['question_count']) ?? rawQuestions.length, + createdAt: _readDateTime(json['created_at']), + questions: rawQuestions + .whereType>() + .map(SplitQuestion.fromJson) + .toList(growable: false), + ); + } +} + +class ProjectsResponse { + const ProjectsResponse({required this.success, required this.projects}); + + final bool success; + final List projects; + + factory ProjectsResponse.fromJson(Map json) { + final rawProjects = (json['projects'] as List?) ?? const []; + return ProjectsResponse( + success: json['success'] as bool? ?? false, + projects: rawProjects + .whereType>() + .map(WorkspaceProject.fromJson) + .toList(), + ); + } +} + +class WorkspaceProject { + const WorkspaceProject({ + required this.id, + required this.publicId, + required this.name, + required this.title, + required this.projectType, + required this.summary, + required this.description, + required this.color, + required this.icon, + required this.isDefault, + required this.questionCount, + required this.noteCount, + required this.createdAt, + required this.updatedAt, + }); + + final int id; + final String publicId; + final String name; + final String title; + final String projectType; + final String summary; + final String description; + final String color; + final String icon; + final bool isDefault; + final int questionCount; + final int noteCount; + final DateTime? createdAt; + final DateTime? updatedAt; + + bool get isQuestionProject => projectType == 'question'; + bool get isNoteProject => projectType == 'note'; + String get displayName => title.trim().isNotEmpty ? title : name; + String get displayDescription { + if (summary.trim().isNotEmpty) { + return summary; + } + if (description.trim().isNotEmpty) { + return description; + } + return '暂无描述'; + } + + int get itemCount => isQuestionProject ? questionCount : noteCount; + + factory WorkspaceProject.fromJson(Map json) { + return WorkspaceProject( + id: _readInt(json['id']) ?? 0, + publicId: json['public_id']?.toString() ?? '', + name: json['name']?.toString() ?? '', + title: json['title']?.toString() ?? '', + projectType: json['project_type']?.toString() ?? '', + summary: json['summary']?.toString() ?? '', + description: json['description']?.toString() ?? '', + color: json['color']?.toString() ?? '', + icon: json['icon']?.toString() ?? '', + isDefault: json['is_default'] as bool? ?? false, + questionCount: _readInt(json['question_count']) ?? 0, + noteCount: _readInt(json['note_count']) ?? 0, + createdAt: _readDateTime(json['created_at']), + updatedAt: _readDateTime(json['updated_at']), + ); + } +} + +class SaveToDbResponse { + const SaveToDbResponse({required this.success, required this.message}); + + final bool success; + final String message; + + factory SaveToDbResponse.fromJson(Map json) { + return SaveToDbResponse( + success: json['success'] as bool? ?? false, + message: json['message']?.toString() ?? '导入失败', + ); + } +} + +class NotePreviewResponse { + const NotePreviewResponse({required this.success, required this.notePreview}); + + final bool success; + final NotePreview? notePreview; + + factory NotePreviewResponse.fromJson(Map json) { + final rawPreview = json['note_preview']; + return NotePreviewResponse( + success: json['success'] as bool? ?? false, + notePreview: rawPreview is Map + ? NotePreview.fromJson(rawPreview.cast()) + : null, + ); + } +} + +class NotePreview { + const NotePreview({ + required this.title, + required this.subject, + required this.contentMarkdown, + required this.knowledgeTags, + required this.sourceImages, + required this.ocrText, + }); + + final String title; + final String subject; + final String contentMarkdown; + final List knowledgeTags; + final List sourceImages; + final String ocrText; + + String get displayTitle => title.trim().isEmpty ? '未命名笔记' : title.trim(); + String get displaySubject => subject.trim().isEmpty ? '未知' : subject.trim(); + + Map toSaveJson({required int projectId}) { + return { + 'project_id': projectId, + 'title': displayTitle, + 'subject': displaySubject, + 'content_markdown': contentMarkdown, + 'source_images': sourceImages, + 'ocr_text': ocrText, + 'knowledge_tags': knowledgeTags, + }; + } + + factory NotePreview.fromJson(Map json) { + return NotePreview( + title: json['title']?.toString() ?? '', + subject: json['subject']?.toString() ?? '', + contentMarkdown: json['content_markdown']?.toString() ?? '', + knowledgeTags: _readStringList(json['knowledge_tags']), + sourceImages: _readStringList(json['source_images']), + ocrText: json['ocr_text']?.toString() ?? '', + ); + } +} + +class NoteSavedResponse { + const NoteSavedResponse({required this.success, required this.note}); + + final bool success; + final LibraryNoteItem? note; + + factory NoteSavedResponse.fromJson(Map json) { + final rawNote = json['note']; + return NoteSavedResponse( + success: json['success'] as bool? ?? false, + note: rawNote is Map + ? LibraryNoteItem.fromJson(rawNote.cast()) + : null, + ); + } +} + +class LibraryQuestionListResponse { + const LibraryQuestionListResponse({ + required this.success, + required this.items, + required this.total, + required this.grandTotal, + required this.page, + required this.pageSize, + required this.totalPages, + }); + + final bool success; + final List items; + final int total; + final int grandTotal; + final int page; + final int pageSize; + final int totalPages; + + bool get hasMore => page < totalPages; + + factory LibraryQuestionListResponse.fromJson(Map json) { + final rawItems = (json['items'] ?? + json['questions'] ?? + json['records'] ?? + json['data']) as List? ?? + const []; + final items = rawItems + .whereType>() + .map(LibraryQuestionItem.fromJson) + .toList(); + final pageSize = _readInt(json['page_size'] ?? json['pageSize']) ?? 10; + final total = _readInt(json['total']) ?? items.length; + final computedPages = + pageSize <= 0 ? 1 : ((total + pageSize - 1) ~/ pageSize); + final totalPages = _readInt(json['total_pages'] ?? json['totalPages']) ?? + (computedPages < 1 ? 1 : computedPages); + + return LibraryQuestionListResponse( + success: json['success'] as bool? ?? false, + items: items, + total: total, + grandTotal: _readInt(json['grand_total']) ?? total, + page: _readInt(json['page']) ?? 1, + pageSize: pageSize, + totalPages: totalPages, + ); + } +} + +class LibraryQuestionItem { + const LibraryQuestionItem({ + required this.id, + required this.questionType, + required this.subject, + required this.contentBlocks, + required this.options, + required this.imageRefs, + required this.knowledgeTags, + required this.reviewStatus, + required this.reviewIsDue, + required this.reviewCount, + required this.reviewIntervalDays, + required this.reviewDueAt, + required this.reviewLastAt, + required this.reviewPriority, + required this.needsCorrection, + required this.hasFormula, + required this.hasImage, + required this.originalFilename, + required this.easeFactor, + required this.answer, + required this.userAnswer, + required this.createdAt, + required this.updatedAt, + }); + + final int id; + final String questionType; + final String subject; + final List contentBlocks; + final List options; + final List imageRefs; + final List knowledgeTags; + final String reviewStatus; + final bool reviewIsDue; + final int reviewCount; + final int reviewIntervalDays; + final DateTime? reviewDueAt; + final DateTime? reviewLastAt; + final int? reviewPriority; + final bool needsCorrection; + final bool hasFormula; + final bool hasImage; + final String originalFilename; + final double? easeFactor; + final String? answer; + final String? userAnswer; + final DateTime? createdAt; + final DateTime? updatedAt; + + String get previewText { + final text = contentBlocks + .where((block) => !block.isImage) + .map((block) => block.content.trim()) + .where((item) => item.isNotEmpty) + .join('\n\n'); + return text.isEmpty ? '暂无题干' : text; + } + + factory LibraryQuestionItem.fromJson(Map json) { + final rawBlocks = _readMapList( + json['content_blocks'] ?? json['content_json'] ?? json['blocks'], + ); + final options = _readStringList(json['options'] ?? json['options_json']); + final imageRefs = _readStringList( + json['image_refs'] ?? json['image_refs_json'], + ); + final knowledgeTags = _readStringList(json['knowledge_tags']); + + return LibraryQuestionItem( + id: _readInt(json['id'] ?? json['question_id']) ?? 0, + questionType: json['question_type']?.toString() ?? '', + subject: json['subject']?.toString() ?? '', + contentBlocks: rawBlocks.map(LibraryContentBlock.fromJson).toList(), + options: options, + imageRefs: imageRefs, + knowledgeTags: knowledgeTags, + reviewStatus: json['review_status']?.toString() ?? '', + reviewIsDue: json['review_is_due'] as bool? ?? false, + reviewCount: _readInt(json['review_count']) ?? 0, + reviewIntervalDays: _readInt(json['review_interval_days']) ?? 0, + reviewDueAt: _readDateTime(json['review_due_at']), + reviewLastAt: _readDateTime(json['review_last_at']), + reviewPriority: _readInt(json['review_priority']), + needsCorrection: json['needs_correction'] as bool? ?? false, + hasFormula: json['has_formula'] as bool? ?? false, + hasImage: json['has_image'] as bool? ?? false, + originalFilename: json['original_filename']?.toString() ?? '', + easeFactor: _readNum(json['ease_factor'])?.toDouble(), + answer: json['answer']?.toString(), + userAnswer: json['user_answer']?.toString(), + createdAt: _readDateTime(json['created_at']), + updatedAt: _readDateTime(json['updated_at']), + ); + } +} + +class LibraryNoteListResponse { + const LibraryNoteListResponse({ + required this.success, + required this.items, + required this.total, + required this.page, + required this.limit, + required this.totalPages, + }); + + final bool success; + final List items; + final int total; + final int page; + final int limit; + final int totalPages; + + bool get hasMore => page < totalPages; + + factory LibraryNoteListResponse.fromJson(Map json) { + final rawItems = (json['items'] ?? + json['notes'] ?? + json['records'] ?? + json['data']) as List? ?? + const []; + final items = rawItems + .whereType>() + .map(LibraryNoteItem.fromJson) + .toList(); + final limit = _readInt(json['limit'] ?? json['page_size']) ?? 10; + final total = _readInt(json['total']) ?? items.length; + final computedPages = limit <= 0 ? 1 : ((total + limit - 1) ~/ limit); + final totalPages = _readInt(json['total_pages'] ?? json['totalPages']) ?? + (computedPages < 1 ? 1 : computedPages); + + return LibraryNoteListResponse( + success: json['success'] as bool? ?? false, + items: items, + total: total, + page: _readInt(json['page']) ?? 1, + limit: limit, + totalPages: totalPages, + ); + } +} + +class LibraryNoteItem { + const LibraryNoteItem({ + required this.id, + required this.title, + required this.subject, + required this.summary, + required this.contentMarkdown, + required this.contentBlocks, + required this.imageRefs, + required this.knowledgeTags, + required this.reviewStatus, + required this.reviewIsDue, + required this.reviewCount, + required this.reviewIntervalDays, + required this.reviewDueAt, + required this.reviewLastAt, + required this.reviewPriority, + required this.easeFactor, + required this.createdAt, + required this.updatedAt, + }); + + final int id; + final String title; + final String subject; + final String summary; + final String contentMarkdown; + final List contentBlocks; + final List imageRefs; + final List knowledgeTags; + final String reviewStatus; + final bool reviewIsDue; + final int reviewCount; + final int reviewIntervalDays; + final DateTime? reviewDueAt; + final DateTime? reviewLastAt; + final int? reviewPriority; + final double? easeFactor; + final DateTime? createdAt; + final DateTime? updatedAt; + + String get displayTitle => title.trim().isEmpty ? '未命名笔记' : title.trim(); + + String get previewText { + if (contentMarkdown.trim().isNotEmpty) { + return contentMarkdown.trim(); + } + final text = contentBlocks + .where((block) => !block.isImage) + .map((block) => block.content.trim()) + .where((item) => item.isNotEmpty) + .join('\n\n'); + if (text.isNotEmpty) { + return text; + } + return summary.trim().isEmpty ? '暂无内容' : summary.trim(); + } + + factory LibraryNoteItem.fromJson(Map json) { + final rawBlocks = _readMapList( + json['content_blocks'] ?? json['content_json'] ?? json['blocks'], + ); + final rawTags = _readStringList(json['knowledge_tags']); + final rawImages = [ + ..._readStringList(json['image_refs']), + ..._readStringList(json['image_refs_json']), + ..._readStringList(json['source_images']), + ]; + final content = json['content']?.toString(); + final contentMarkdown = json['content_markdown']?.toString() ?? ''; + + return LibraryNoteItem( + id: _readInt(json['id'] ?? json['note_id']) ?? 0, + title: (json['title'] ?? json['name'] ?? '').toString(), + subject: json['subject']?.toString() ?? '', + summary: (json['summary'] ?? json['description'] ?? '').toString(), + contentMarkdown: contentMarkdown, + contentBlocks: [ + ...rawBlocks.map(LibraryContentBlock.fromJson), + if (_hasValue(content)) + LibraryContentBlock(blockType: 'text', content: content!.trim()), + ], + imageRefs: rawImages, + knowledgeTags: rawTags, + reviewStatus: json['review_status']?.toString() ?? '', + reviewIsDue: json['review_is_due'] as bool? ?? false, + reviewCount: _readInt(json['review_count']) ?? 0, + reviewIntervalDays: _readInt(json['review_interval_days']) ?? 0, + reviewDueAt: _readDateTime(json['review_due_at']), + reviewLastAt: _readDateTime(json['review_last_at']), + reviewPriority: _readInt(json['review_priority']), + easeFactor: _readNum(json['ease_factor'])?.toDouble(), + createdAt: _readDateTime(json['created_at']), + updatedAt: _readDateTime(json['updated_at']), + ); + } +} + +class LibraryContentBlock { + const LibraryContentBlock({required this.blockType, required this.content}); + + final String blockType; + final String content; + + bool get isImage => blockType.toLowerCase() == 'image'; + + factory LibraryContentBlock.fromJson(Map json) { + return LibraryContentBlock( + blockType: json['block_type']?.toString() ?? 'text', + content: json['content']?.toString() ?? '', + ); + } +} + +int? _readInt(dynamic value) { + if (value is int) { + return value; + } + if (value is num) { + return value.toInt(); + } + return int.tryParse('$value'); +} + +num? _readNum(dynamic value) { + if (value is num) { + return value; + } + return num.tryParse('$value'); +} + +List _readStringList(dynamic value) { + if (value == null) { + return const []; + } + if (value is List) { + return value.map((item) => item.toString()).toList(); + } + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed == 'null') { + return const []; + } + try { + final decoded = jsonDecode(trimmed); + if (decoded is List) { + return decoded.map((item) => item.toString()).toList(); + } + } catch (_) { + // Treat plain strings as a single item. + } + return [trimmed]; + } + return [value.toString()]; +} + +List> _readMapList(dynamic value) { + if (value == null) { + return const []; + } + if (value is List) { + return value + .whereType() + .map((item) => item.cast()) + .toList(); + } + if (value is String) { + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed == 'null') { + return const []; + } + try { + final decoded = jsonDecode(trimmed); + if (decoded is List) { + return _readMapList(decoded); + } + } catch (_) { + // Ignore malformed JSON content. + } + } + return const []; +} + +DateTime? _readDateTime(dynamic value) { + return parseBackendDateTime(value); +} + +bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; + +class ModelOptionsResponse { + const ModelOptionsResponse({ + required this.success, + required this.defaultOptionId, + required this.groups, + required this.options, + }); + + final bool success; + final String? defaultOptionId; + final List groups; + final List options; + + factory ModelOptionsResponse.fromJson(Map json) { + final rawGroups = (json['groups'] as List?) ?? const []; + final rawOptions = (json['options'] as List?) ?? const []; + + return ModelOptionsResponse( + success: json['success'] as bool? ?? false, + defaultOptionId: json['default_option_id'] as String?, + groups: rawGroups + .whereType>() + .map(ModelOptionGroup.fromJson) + .toList(), + options: rawOptions + .whereType>() + .map(WorkspaceModelOption.fromJson) + .toList(), + ); + } +} + +class ModelOptionGroup { + const ModelOptionGroup({required this.key, required this.label}); + + final String key; + final String label; + + factory ModelOptionGroup.fromJson(Map json) { + return ModelOptionGroup( + key: json['key'] as String? ?? '', + label: json['label'] as String? ?? '', + ); + } +} + +class WorkspaceModelOption { + const WorkspaceModelOption({ + required this.optionId, + required this.label, + required this.modelName, + required this.source, + required this.groupLabel, + required this.groupKey, + required this.providerName, + required this.providerId, + required this.category, + required this.configured, + required this.isDefault, + required this.available, + required this.supportsFunctionCalling, + required this.reason, + }); + + final String optionId; + final String label; + final String modelName; + final String source; + final String groupLabel; + final String groupKey; + final String providerName; + final String providerId; + final String category; + final bool configured; + final bool isDefault; + final bool available; + final bool supportsFunctionCalling; + final String reason; + + bool get isHostedSource => + source == 'system' || + source == 'hosted' || + groupKey == 'system' || + groupLabel == '平台托管'; + bool get isSelfSource => + source == 'personal' || + source == 'self' || + groupKey == 'personal' || + groupLabel == '自己设置'; + + String get displayName => modelName.isNotEmpty ? modelName : label; + + factory WorkspaceModelOption.fromJson(Map json) { + return WorkspaceModelOption( + optionId: json['option_id'] as String? ?? '', + label: json['label'] as String? ?? '', + modelName: json['model_name'] as String? ?? '', + source: json['source'] as String? ?? '', + groupLabel: json['group_label'] as String? ?? '', + groupKey: json['group_key'] as String? ?? '', + providerName: json['provider_name'] as String? ?? '', + providerId: json['provider_id'] as String? ?? '', + category: json['category'] as String? ?? '', + configured: json['configured'] as bool? ?? false, + isDefault: json['is_default'] as bool? ?? false, + available: json['available'] as bool? ?? false, + supportsFunctionCalling: + json['supports_function_calling'] as bool? ?? false, + reason: json['reason'] as String? ?? '', + ); + } +} + +class SystemStatusResponse { + const SystemStatusResponse({required this.success, required this.status}); + + final bool success; + final WorkspaceSystemStatus status; + + factory SystemStatusResponse.fromJson(Map json) { + return SystemStatusResponse( + success: json['success'] as bool? ?? false, + status: WorkspaceSystemStatus.fromJson( + (json['status'] as Map?)?.cast() ?? {}, + ), + ); + } +} + +class WorkspaceSystemStatus { + const WorkspaceSystemStatus({ + required this.paddleocrConfigured, + required this.ensexamConfigured, + required this.langsmithEnabled, + required this.availableModels, + required this.outputDirs, + }); + + final bool paddleocrConfigured; + final bool ensexamConfigured; + final bool langsmithEnabled; + final List availableModels; + final Map outputDirs; + + factory WorkspaceSystemStatus.fromJson(Map json) { + final rawModels = (json['available_models'] as List?) ?? const []; + final rawOutputDirs = (json['output_dirs'] as Map?) ?? const {}; + + return WorkspaceSystemStatus( + paddleocrConfigured: json['paddleocr_configured'] as bool? ?? false, + ensexamConfigured: json['ensexam_configured'] as bool? ?? false, + langsmithEnabled: json['langsmith_enabled'] as bool? ?? false, + availableModels: rawModels + .whereType>() + .map(AvailableModelStatus.fromJson) + .toList(), + outputDirs: rawOutputDirs.map( + (key, value) => MapEntry(key.toString(), value?.toString() ?? ''), + ), + ); + } +} + +class AvailableModelStatus { + const AvailableModelStatus({ + required this.configured, + required this.defaultModel, + required this.label, + required this.managed, + required this.models, + required this.status, + required this.value, + }); + + final bool configured; + final String defaultModel; + final String label; + final bool managed; + final List models; + final String status; + final String value; + + factory AvailableModelStatus.fromJson(Map json) { + final rawModels = (json['models'] as List?) ?? const []; + return AvailableModelStatus( + configured: json['configured'] as bool? ?? false, + defaultModel: json['default_model'] as String? ?? '', + label: json['label'] as String? ?? '', + managed: json['managed'] as bool? ?? false, + models: + rawModels.whereType().map((item) => item.toString()).toList(), + status: json['status'] as String? ?? '', + value: json['value'] as String? ?? '', + ); + } +} diff --git a/apps/mobile/lib/features/workspace/data/workspace_project_store.dart b/apps/mobile/lib/features/workspace/data/workspace_project_store.dart new file mode 100644 index 00000000..f3abb8f5 --- /dev/null +++ b/apps/mobile/lib/features/workspace/data/workspace_project_store.dart @@ -0,0 +1,63 @@ +import 'package:flutter/foundation.dart'; + +import '../../../core/network/api_client.dart'; +import 'workspace_api.dart'; + +class WorkspaceProjectStore extends ChangeNotifier { + WorkspaceProjectStore._({WorkspaceApi? api}) : _api = api ?? WorkspaceApi(); + + static final WorkspaceProjectStore instance = WorkspaceProjectStore._(); + + final WorkspaceApi _api; + + final List _projects = []; + Future? _loadingFuture; + bool _isLoading = false; + String? _errorMessage; + + List get projects => List.unmodifiable(_projects); + List get questionProjects => + _projects.where((project) => project.isQuestionProject).toList(); + List get noteProjects => + _projects.where((project) => project.isNoteProject).toList(); + + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + + int get totalQuestionCount => + _projects.fold(0, (total, project) => total + project.questionCount); + int get totalNoteCount => + _projects.fold(0, (total, project) => total + project.noteCount); + + Future ensureLoaded() { + if (_projects.isNotEmpty) { + return Future.value(); + } + return refresh(); + } + + Future refresh() { + if (_loadingFuture != null) { + return _loadingFuture!; + } + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + _loadingFuture = _api.getProjects().then((response) { + _projects + ..clear() + ..addAll(response.projects); + _errorMessage = response.success ? null : '项目列表加载失败'; + }).catchError((error) { + _errorMessage = error is ApiException ? error.message : '项目列表加载失败'; + }).whenComplete(() { + _isLoading = false; + _loadingFuture = null; + notifyListeners(); + }); + + return _loadingFuture!; + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/chat_conversation_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/chat_conversation_page.dart new file mode 100644 index 00000000..cb22fbc1 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/chat_conversation_page.dart @@ -0,0 +1,1694 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../../core/widgets/markdown_math_text.dart'; +import '../../../../core/widgets/math_rich_text.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../../chat/data/chat_api.dart'; +import '../../data/workspace_api.dart'; +import '../../data/workspace_project_store.dart'; + +class ChatConversationPage extends StatefulWidget { + const ChatConversationPage({ + super.key, + required this.sessionId, + required this.title, + this.chatApi, + this.workspaceApi, + }); + + final String sessionId; + final String title; + final ChatApi? chatApi; + final WorkspaceApi? workspaceApi; + + @override + State createState() => _ChatConversationPageState(); +} + +class _ChatConversationPageState extends State { + late final ChatApi _chatApi; + late final WorkspaceApi _workspaceApi; + final WorkspaceProjectStore _projectStore = WorkspaceProjectStore.instance; + final TextEditingController _inputController = TextEditingController(); + final ScrollController _messageScrollController = ScrollController(); + + final List<_ChatMessageView> _messages = []; + final List<_QuestionReference> _references = []; + final List _hostedModels = []; + final List _selfModels = []; + + bool _isLoadingMessages = false; + bool _isLoadingOlderMessages = false; + bool _isSending = false; + bool _hasMoreMessages = false; + int? _beforeMessageId; + + bool _isLoadingModels = false; + String? _modelError; + String? _selectedModelOptionId; + _ModelSource? _selectedModelSource; + bool _deepThink = false; + + @override + void initState() { + super.initState(); + _chatApi = widget.chatApi ?? ChatApi(); + _workspaceApi = widget.workspaceApi ?? WorkspaceApi(); + unawaited(_projectStore.ensureLoaded()); + unawaited(_fetchModelOptions()); + unawaited(_loadMessages(reset: true)); + } + + @override + void dispose() { + _inputController.dispose(); + _messageScrollController.dispose(); + super.dispose(); + } + + List get _availableHostOptions => + _hostedModels.where((item) => item.available && item.configured).toList(); + + List get _availableSelfOptions => + _selfModels.where((item) => item.available && item.configured).toList(); + + WorkspaceModelOption? get _selectedModelOption { + final id = _selectedModelOptionId; + if (id == null) { + return null; + } + for (final option in [..._availableHostOptions, ..._availableSelfOptions]) { + if (option.optionId == id) { + return option; + } + } + return null; + } + + String? get _selectedModelDisplayName => _selectedModelOption?.displayName; + + Future _fetchModelOptions() async { + setState(() { + _isLoadingModels = true; + _modelError = null; + }); + + try { + final response = await _workspaceApi.getModelOptions(); + final available = response.options.where( + (item) => item.available && item.configured, + ); + final defaultOption = _findDefaultOption( + available.toList(), + response.defaultOptionId, + ); + + if (!mounted) { + return; + } + setState(() { + _hostedModels + ..clear() + ..addAll(response.options.where((item) => item.isHostedSource)); + _selfModels + ..clear() + ..addAll(response.options.where((item) => item.isSelfSource)); + _selectedModelOptionId = defaultOption?.optionId; + _selectedModelSource = defaultOption == null + ? null + : defaultOption.isHostedSource + ? _ModelSource.hosted + : _ModelSource.self; + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _modelError = error.message; + _selectedModelOptionId = null; + _selectedModelSource = null; + }); + } finally { + if (mounted) { + setState(() => _isLoadingModels = false); + } + } + } + + WorkspaceModelOption? _findDefaultOption( + List options, + String? defaultOptionId, + ) { + if (options.isEmpty) { + return null; + } + if (defaultOptionId != null) { + for (final option in options) { + if (option.optionId == defaultOptionId) { + return option; + } + } + } + for (final option in options) { + if (option.isDefault) { + return option; + } + } + return options.first; + } + + Future _loadMessages({required bool reset}) async { + if (reset) { + setState(() { + _isLoadingMessages = true; + _messages.clear(); + _beforeMessageId = null; + _hasMoreMessages = false; + }); + } else { + if (_isLoadingOlderMessages || !_hasMoreMessages) { + return; + } + setState(() => _isLoadingOlderMessages = true); + } + + try { + final response = await _chatApi.getMessages( + sessionId: widget.sessionId, + limit: 30, + beforeId: reset ? null : _beforeMessageId, + ); + final loaded = response.messages.map(_ChatMessageView.fromApi).toList() + ..sort(_compareMessages); + + if (!mounted) { + return; + } + setState(() { + if (reset) { + _messages + ..clear() + ..addAll(loaded); + } else { + _messages.insertAll(0, loaded); + } + _hasMoreMessages = response.hasMore; + _beforeMessageId = response.nextBeforeId ?? + (_messages.isEmpty ? null : _messages.first.id); + }); + + if (reset) { + _scrollToBottom(); + } + } on ApiException catch (error) { + if (mounted) { + showAppSnackBar(context, error.message); + } + } finally { + if (mounted) { + setState(() { + if (reset) { + _isLoadingMessages = false; + } else { + _isLoadingOlderMessages = false; + } + }); + } + } + } + + static int _compareMessages(_ChatMessageView left, _ChatMessageView right) { + final leftId = left.id; + final rightId = right.id; + if (leftId != null && rightId != null) { + return leftId.compareTo(rightId); + } + return left.localOrder.compareTo(right.localOrder); + } + + Future _sendMessage() async { + final text = _inputController.text.trim(); + if (text.isEmpty || _isSending) { + return; + } + + final model = _selectedModelOption; + if (model == null) { + showAppSnackBar(context, _modelError ?? '请先选择可用模型'); + return; + } + + final userMessage = _ChatMessageView.local(role: 'user', content: text); + final assistantMessage = _ChatMessageView.local( + role: 'assistant', + content: '', + streaming: true, + ); + + setState(() { + _isSending = true; + _inputController.clear(); + _messages + ..add(userMessage) + ..add(assistantMessage); + }); + _scrollToBottom(); + + try { + await for (final event in _chatApi.streamMessage( + sessionId: widget.sessionId, + request: ChatStreamRequest( + message: text, + modelProvider: model.category.isNotEmpty ? model.category : 'openai', + modelName: model.modelName.isNotEmpty ? model.modelName : null, + providerSource: model.source.isNotEmpty ? model.source : null, + providerId: model.providerId.isNotEmpty ? model.providerId : null, + deepThink: _deepThink, + contextRefs: _buildContextRefs(), + ), + )) { + if (!mounted) { + return; + } + + if (event.error != null && event.error!.isNotEmpty) { + setState(() { + assistantMessage + ..content = event.error! + ..isStreaming = false + ..hasError = true; + }); + showAppSnackBar(context, event.error!); + break; + } + + setState(() { + if (event.reasoning != null) { + assistantMessage.reasoning += event.reasoning!; + } + if (event.token != null) { + assistantMessage.content += event.token!; + } + if (event.done) { + assistantMessage.isStreaming = false; + } + }); + _scrollToBottom(); + } + } on ApiException catch (error) { + if (mounted) { + setState(() { + assistantMessage + ..content = error.message + ..isStreaming = false + ..hasError = true; + }); + showAppSnackBar(context, error.message); + } + } finally { + if (mounted) { + setState(() { + _isSending = false; + assistantMessage.isStreaming = false; + }); + } + } + } + + List _buildContextRefs() { + final grouped = >{}; + for (final reference in _references) { + grouped + .putIfAbsent(reference.project.id, () => []) + .add(reference.question.id); + } + return grouped.entries + .map( + (entry) => ChatContextRef( + type: 'question', + projectId: entry.key, + questionIds: entry.value, + ), + ) + .toList(); + } + + Future _openReferenceDialog() async { + await _projectStore.ensureLoaded(); + final projects = _projectStore.questionProjects; + if (!mounted) { + return; + } + if (projects.isEmpty) { + showAppSnackBar(context, '暂无可引用的错题库'); + return; + } + + final result = await showDialog>( + context: context, + builder: (context) { + return _QuestionReferenceDialog( + chatApi: _chatApi, + projects: projects, + initialReferences: _references, + ); + }, + ); + + if (!mounted || result == null) { + return; + } + setState(() { + _references + ..clear() + ..addAll(result); + }); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_messageScrollController.hasClients) { + return; + } + _messageScrollController.animateTo( + _messageScrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOut, + ); + }); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: true, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _ConversationHeader( + palette: palette, + title: widget.title, + onBack: () => Navigator.of(context).maybePop(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 0), + child: _buildMessageArea(palette), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 8, 18, 16), + child: _ChatComposer( + palette: palette, + controller: _inputController, + isSending: _isSending, + deepThink: _deepThink, + referenceLabel: _referenceLabel, + modelSelector: _ModelSelector( + isLight: palette.isLight, + isLoading: _isLoadingModels, + selectedModelId: _selectedModelOptionId, + selectedModelName: _selectedModelDisplayName, + selectedSource: _selectedModelSource, + hosted: _availableHostOptions, + self: _availableSelfOptions, + modelError: _modelError, + onPickHosted: (option) { + setState(() { + _selectedModelOptionId = option.optionId; + _selectedModelSource = _ModelSource.hosted; + }); + }, + onPickSelf: (option) { + setState(() { + _selectedModelOptionId = option.optionId; + _selectedModelSource = _ModelSource.self; + }); + }, + onPickSettings: () => + showAppSnackBar(context, 'API 设置入口待接入'), + onPickEmpty: () => showAppSnackBar(context, '当前暂无可用模型'), + panelBg: palette.panelBg, + border: palette.panelBorder, + ), + onToggleDeepThink: () => + setState(() => _deepThink = !_deepThink), + onPickReferences: _openReferenceDialog, + onClearReferences: () => setState(_references.clear), + onSend: _sendMessage, + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '内容由 AI 生成,仅供参考', + style: TextStyle( + color: palette.textSub.withOpacity(0.65), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildMessageArea(AppThemePalette palette) { + if (_isLoadingMessages) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + + if (_messages.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Hi, Admin', + style: TextStyle( + color: palette.textMain, + fontSize: 30, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 12), + Text( + '有问题,尽管问', + style: TextStyle( + color: palette.textSub, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _messageScrollController, + padding: const EdgeInsets.fromLTRB(4, 12, 4, 12), + itemCount: _messages.length + (_hasMoreMessages ? 1 : 0), + itemBuilder: (context, index) { + if (_hasMoreMessages && index == 0) { + return Center( + child: TextButton( + onPressed: _isLoadingOlderMessages + ? null + : () => _loadMessages(reset: false), + child: Text(_isLoadingOlderMessages ? '加载中...' : '加载更早消息'), + ), + ); + } + + final messageIndex = index - (_hasMoreMessages ? 1 : 0); + return _ChatBubble(palette: palette, message: _messages[messageIndex]); + }, + ); + } + + String? get _referenceLabel { + if (_references.isEmpty) { + return null; + } + final projectName = _references.first.project.displayName; + return '$projectName · ${_references.length} 题'; + } +} + +class _ConversationHeader extends StatelessWidget { + const _ConversationHeader({ + required this.palette, + required this.title, + required this.onBack, + }); + + final AppThemePalette palette; + final String title; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 8, 14, 8), + child: Row( + children: [ + IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + ), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ); + } +} + +class _ChatComposer extends StatelessWidget { + const _ChatComposer({ + required this.palette, + required this.controller, + required this.isSending, + required this.deepThink, + required this.modelSelector, + required this.onToggleDeepThink, + required this.onPickReferences, + required this.onClearReferences, + required this.onSend, + this.referenceLabel, + }); + + final AppThemePalette palette; + final TextEditingController controller; + final bool isSending; + final bool deepThink; + final Widget modelSelector; + final String? referenceLabel; + final VoidCallback onToggleDeepThink; + final VoidCallback onPickReferences; + final VoidCallback onClearReferences; + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.panelBorder), + ), + child: Column( + children: [ + TextField( + controller: controller, + minLines: 1, + maxLines: 5, + textInputAction: TextInputAction.newline, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: '有问题,尽管问,shift+enter 换行', + hintStyle: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + modelSelector, + TextButton.icon( + onPressed: onToggleDeepThink, + style: TextButton.styleFrom( + backgroundColor: + deepThink ? palette.primary : palette.panelBg, + foregroundColor: + deepThink ? Colors.white : palette.textSub, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + icon: const Icon(Icons.psychology_rounded, size: 17), + label: const Text( + '深度思考', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ), + if (referenceLabel != null) + Container( + constraints: const BoxConstraints(maxWidth: 180), + padding: const EdgeInsets.symmetric( + horizontal: 9, + vertical: 7, + ), + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.18), + borderRadius: BorderRadius.circular(9), + border: Border.all( + color: palette.primary.withOpacity(0.45), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.storage_rounded, + color: palette.primaryLight, + size: 14, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + referenceLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: onClearReferences, + child: Icon( + Icons.close_rounded, + color: palette.textSub, + size: 15, + ), + ), + ], + ), + ), + ], + ), + ), + IconButton( + tooltip: '引用错题', + onPressed: onPickReferences, + icon: const Icon(Icons.add_rounded), + color: palette.textSub, + ), + IconButton( + tooltip: '发送', + onPressed: isSending ? null : onSend, + icon: isSending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_upward_rounded), + color: palette.textMain, + style: IconButton.styleFrom( + backgroundColor: palette.subtleOverlay, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ChatBubble extends StatelessWidget { + const _ChatBubble({required this.palette, required this.message}); + + final AppThemePalette palette; + final _ChatMessageView message; + + @override + Widget build(BuildContext context) { + final isUser = message.role == 'user'; + + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + constraints: const BoxConstraints(maxWidth: 760), + margin: EdgeInsets.only( + left: isUser ? 54 : 0, + right: isUser ? 0 : 54, + bottom: 18, + ), + padding: isUser + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 12) + : EdgeInsets.zero, + decoration: BoxDecoration( + color: isUser + ? palette.primary + : message.hasError + ? palette.errorText.withOpacity(0.12) + : Colors.transparent, + borderRadius: BorderRadius.circular(14), + ), + child: isUser + ? MathRichText( + text: message.content, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w800, + height: 1.55, + ), + ) + : _AssistantMessageBody(palette: palette, message: message), + ), + ); + } +} + +class _AssistantMessageBody extends StatelessWidget { + const _AssistantMessageBody({required this.palette, required this.message}); + + final AppThemePalette palette; + final _ChatMessageView message; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (message.reasoning.trim().isNotEmpty) + Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.primary.withOpacity(0.18)), + ), + child: MathRichText( + text: message.reasoning, + style: TextStyle( + color: palette.textSub, + fontSize: 13, + height: 1.5, + fontWeight: FontWeight.w700, + ), + ), + ), + if (message.content.trim().isEmpty && message.isStreaming) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.primary, + ), + ), + const SizedBox(width: 8), + Text( + '正在生成', + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + ], + ) + else + MarkdownMathText( + text: message.content, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + height: 1.65, + fontWeight: FontWeight.w700, + ), + palette: palette, + ), + ], + ); + } +} + +class _QuestionReferenceDialog extends StatefulWidget { + const _QuestionReferenceDialog({ + required this.chatApi, + required this.projects, + required this.initialReferences, + }); + + final ChatApi chatApi; + final List projects; + final List<_QuestionReference> initialReferences; + + @override + State<_QuestionReferenceDialog> createState() => + _QuestionReferenceDialogState(); +} + +class _QuestionReferenceDialogState extends State<_QuestionReferenceDialog> { + late WorkspaceProject _selectedProject; + final TextEditingController _keywordController = TextEditingController(); + final List _questions = []; + final Map _selected = {}; + int _page = 1; + bool _hasMore = false; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _selectedProject = widget.projects.first; + for (final reference in widget.initialReferences) { + _selected[reference.question.id] = reference; + } + unawaited(_loadQuestions(reset: true)); + } + + @override + void dispose() { + _keywordController.dispose(); + super.dispose(); + } + + Future _loadQuestions({required bool reset}) async { + if (_isLoading) { + return; + } + setState(() => _isLoading = true); + + try { + final page = reset ? 1 : _page + 1; + final response = await widget.chatApi.queryErrorBank( + page: page, + pageSize: 20, + projectId: _selectedProject.id, + keyword: _keywordController.text.trim(), + ); + if (!mounted) { + return; + } + setState(() { + if (reset) { + _questions.clear(); + } + _questions.addAll(response.questions); + _page = page; + _hasMore = _questions.length < response.total; + }); + } on ApiException catch (error) { + if (mounted) { + showAppSnackBar(context, error.message); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + void _selectProject(WorkspaceProject project) { + if (project.id == _selectedProject.id) { + return; + } + setState(() => _selectedProject = project); + unawaited(_loadQuestions(reset: true)); + } + + void _toggleQuestion(ErrorBankQuestion question) { + setState(() { + if (_selected.containsKey(question.id)) { + _selected.remove(question.id); + } else { + _selected[question.id] = _QuestionReference( + project: _selectedProject, + question: question, + ); + } + }); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Dialog( + backgroundColor: palette.menuBg, + insetPadding: const EdgeInsets.all(18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: BorderSide(color: palette.panelBorderStrong), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 980, maxHeight: 720), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(22, 18, 18, 18), + child: Row( + children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.18), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.storage_rounded, + color: palette.primaryLight, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + '引用错题回答', + style: TextStyle( + color: palette.textMain, + fontSize: 22, + fontWeight: FontWeight.w900, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close_rounded), + color: palette.textSub, + ), + ], + ), + ), + Divider(height: 1, color: palette.divider), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 700; + final projectList = _buildProjectList(palette, compact); + final questionList = _buildQuestionList(palette); + if (compact) { + return Column( + children: [ + SizedBox(height: 86, child: projectList), + Divider(height: 1, color: palette.divider), + Expanded(child: questionList), + ], + ); + } + return Row( + children: [ + SizedBox(width: 300, child: projectList), + VerticalDivider(width: 1, color: palette.divider), + Expanded(child: questionList), + ], + ); + }, + ), + ), + Divider(height: 1, color: palette.divider), + Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 14), + child: Row( + children: [ + Icon(Icons.link_rounded, color: palette.textSub, size: 18), + const SizedBox(width: 8), + Text( + '已选择 ${_selected.length} 题', + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w800, + ), + ), + const Spacer(), + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('取消', style: TextStyle(color: palette.textSub)), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(_selected.values.toList()), + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + '确定引用', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildProjectList(AppThemePalette palette, bool compact) { + return ListView.builder( + scrollDirection: compact ? Axis.horizontal : Axis.vertical, + padding: const EdgeInsets.all(14), + itemCount: widget.projects.length, + itemBuilder: (context, index) { + final project = widget.projects[index]; + final selected = project.id == _selectedProject.id; + return Padding( + padding: EdgeInsets.only( + right: compact ? 8 : 0, + bottom: compact ? 0 : 8, + ), + child: InkWell( + onTap: () => _selectProject(project), + borderRadius: BorderRadius.circular(10), + child: Container( + width: compact ? 150 : double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: selected + ? palette.primary.withOpacity(0.18) + : Colors.transparent, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon( + Icons.storage_rounded, + color: selected ? palette.primaryLight : palette.textSub, + size: 17, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + project.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: + selected ? palette.primaryLight : palette.textSub, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Widget _buildQuestionList(AppThemePalette palette) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _selectedProject.displayName, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + '选择本次对话要参考的具体题目', + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + TextButton( + onPressed: + _selected.isEmpty ? null : () => setState(_selected.clear), + child: const Text('清空'), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), + child: TextField( + controller: _keywordController, + onSubmitted: (_) => _loadQuestions(reset: true), + style: TextStyle(color: palette.textMain), + decoration: InputDecoration( + isDense: true, + hintText: '搜索错题', + hintStyle: TextStyle(color: palette.textSub), + prefixIcon: Icon(Icons.search_rounded, color: palette.textSub), + filled: true, + fillColor: palette.panelBg, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: palette.panelBorder), + ), + ), + ), + ), + Expanded( + child: _isLoading && _questions.isEmpty + ? const Center(child: CircularProgressIndicator(strokeWidth: 2)) + : _questions.isEmpty + ? Center( + child: Text( + '暂无错题', + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), + itemCount: _questions.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (_hasMore && index == _questions.length) { + return Center( + child: TextButton( + onPressed: _isLoading + ? null + : () => _loadQuestions(reset: false), + child: Text(_isLoading ? '加载中...' : '加载更多'), + ), + ); + } + + final question = _questions[index]; + return _ReferenceQuestionTile( + palette: palette, + question: question, + selected: _selected.containsKey(question.id), + onTap: () => _toggleQuestion(question), + ); + }, + ), + ), + ], + ); + } +} + +class _ReferenceQuestionTile extends StatelessWidget { + const _ReferenceQuestionTile({ + required this.palette, + required this.question, + required this.selected, + required this.onTap, + }); + + final AppThemePalette palette; + final ErrorBankQuestion question; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: selected ? palette.primary.withOpacity(0.15) : palette.cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? palette.primary.withOpacity(0.55) + : palette.panelBorder, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + selected + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded, + color: selected ? palette.primaryLight : palette.textSub, + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _QuestionChip( + text: '#${question.id}', + palette: palette, + highlighted: false, + ), + if (question.questionType.isNotEmpty) + _QuestionChip( + text: question.questionType, + palette: palette, + highlighted: false, + ), + if (question.subject.isNotEmpty) + _QuestionChip( + text: question.subject, + palette: palette, + highlighted: true, + ), + ], + ), + const SizedBox(height: 9), + MathRichText( + text: question.previewText, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 14, + fontWeight: FontWeight.w800, + height: 1.55, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _QuestionChip extends StatelessWidget { + const _QuestionChip({ + required this.text, + required this.palette, + required this.highlighted, + }); + + final String text; + final AppThemePalette palette; + final bool highlighted; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: highlighted + ? palette.primary.withOpacity(0.2) + : palette.subtleOverlay, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + text, + style: TextStyle( + color: highlighted ? palette.primaryLight : palette.textSub, + fontSize: 11, + fontWeight: FontWeight.w900, + ), + ), + ); + } +} + +class _ModelSelector extends StatelessWidget { + const _ModelSelector({ + required this.isLight, + required this.isLoading, + required this.selectedModelId, + required this.selectedModelName, + required this.selectedSource, + required this.hosted, + required this.self, + required this.modelError, + required this.onPickHosted, + required this.onPickSelf, + required this.onPickSettings, + required this.onPickEmpty, + required this.panelBg, + required this.border, + }); + + final bool isLight; + final bool isLoading; + final String? selectedModelId; + final String? selectedModelName; + final _ModelSource? selectedSource; + final List hosted; + final List self; + final String? modelError; + final ValueChanged onPickHosted; + final ValueChanged onPickSelf; + final VoidCallback onPickSettings; + final VoidCallback onPickEmpty; + final Color panelBg; + final Color border; + + static const String _apiSettingsValue = '__api_settings__'; + + @override + Widget build(BuildContext context) { + final hasSelected = selectedModelId != null && + selectedModelName != null && + selectedModelName!.isNotEmpty; + final hasModels = hosted.isNotEmpty || self.isNotEmpty; + final allOptions = [...hosted, ...self]; + final palette = AppThemePalette(isLight: isLight); + + return PopupMenuButton( + tooltip: '模型选择', + enabled: !isLoading, + color: palette.menuBg, + elevation: 12, + offset: const Offset(0, 10), + constraints: const BoxConstraints(minWidth: 274, maxWidth: 320), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: palette.subtleOverlay), + ), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == _apiSettingsValue) { + onPickSettings(); + return; + } + + WorkspaceModelOption? selected; + for (final option in allOptions) { + if (option.optionId == value) { + selected = option; + break; + } + } + + if (selected == null) { + onPickEmpty(); + return; + } + if (selected.isHostedSource) { + onPickHosted(selected); + } else { + onPickSelf(selected); + } + }, + itemBuilder: (context) { + if (isLoading) { + return [ + const PopupMenuItem( + enabled: false, + child: Row( + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 10), + Text('模型加载中...'), + ], + ), + ), + ]; + } + + if (!hasModels) { + return [ + PopupMenuItem( + enabled: false, + child: Text(modelError ?? '当前暂无可用模型'), + ), + _buildDivider(palette), + _buildApiSettingsItem(palette), + ]; + } + + return [ + _buildSectionTitle('平台托管', palette.textSub), + for (final item in hosted) + _buildModelItem( + option: item, + palette: palette, + selected: selectedModelId == item.optionId && + selectedSource == _ModelSource.hosted, + showDefault: item.isDefault, + ), + _buildDivider(palette), + _buildSectionTitle('自己设置', palette.textSub), + for (final item in self) + _buildModelItem( + option: item, + palette: palette, + selected: selectedModelId == item.optionId && + selectedSource == _ModelSource.self, + showDefault: false, + ), + _buildDivider(palette), + _buildApiSettingsItem(palette), + ]; + }, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 10), + decoration: BoxDecoration( + color: panelBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.auto_awesome_rounded, + color: palette.primaryLight, + size: 16, + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + hasSelected ? (selectedModelName ?? '选择模型') : '选择模型', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + fontSize: 13, + ), + ), + ), + if (selectedSource != null) ...[ + const SizedBox(width: 7), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: palette.subtleOverlay, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + selectedSource == _ModelSource.hosted ? '平台' : '自设', + style: TextStyle( + color: palette.textSub, + fontSize: 10, + fontWeight: FontWeight.w800, + height: 1, + ), + ), + ), + ], + Icon( + Icons.keyboard_arrow_down_rounded, + size: 18, + color: palette.textSub, + ), + ], + ), + ), + ); + } + + PopupMenuItem _buildSectionTitle(String title, Color color) { + return PopupMenuItem( + enabled: false, + height: 34, + child: Text( + title, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ); + } + + PopupMenuItem _buildModelItem({ + required WorkspaceModelOption option, + required AppThemePalette palette, + required bool selected, + required bool showDefault, + }) { + return PopupMenuItem( + value: option.optionId, + child: Row( + children: [ + Icon( + selected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: selected ? palette.primary : palette.textSub, + size: 18, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + option.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + ), + ), + ), + if (showDefault) + Text( + '默认', + style: TextStyle( + color: palette.primaryLight, + fontSize: 11, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } + + PopupMenuItem _buildApiSettingsItem(AppThemePalette palette) { + return PopupMenuItem( + value: _apiSettingsValue, + child: Row( + children: [ + Icon(Icons.tune_rounded, color: palette.textSub, size: 18), + const SizedBox(width: 10), + Text( + 'API 设置', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } + + PopupMenuItem _buildDivider(AppThemePalette palette) { + return PopupMenuItem( + enabled: false, + height: 1, + padding: EdgeInsets.zero, + child: Divider(height: 1, color: palette.divider), + ); + } +} + +class _ChatMessageView { + _ChatMessageView({ + required this.id, + required this.role, + required this.content, + required this.reasoning, + required this.localOrder, + this.isStreaming = false, + }); + + final int? id; + final String role; + String content; + String reasoning; + final int localOrder; + bool isStreaming; + bool hasError = false; + + static int _nextLocalOrder = 0; + + factory _ChatMessageView.fromApi(ChatMessage message) { + return _ChatMessageView( + id: message.id, + role: message.role, + content: message.content, + reasoning: message.reasoning, + localOrder: _nextLocalOrder++, + ); + } + + factory _ChatMessageView.local({ + required String role, + required String content, + bool streaming = false, + }) { + return _ChatMessageView( + id: null, + role: role, + content: content, + reasoning: '', + localOrder: _nextLocalOrder++, + isStreaming: streaming, + ); + } +} + +class _QuestionReference { + const _QuestionReference({required this.project, required this.question}); + + final WorkspaceProject project; + final ErrorBankQuestion question; +} + +enum _ModelSource { hosted, self } diff --git a/apps/mobile/lib/features/workspace/presentation/pages/chat_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/chat_page.dart new file mode 100644 index 00000000..eb35bf46 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/chat_page.dart @@ -0,0 +1,597 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/utils/time_format.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../chat/data/chat_api.dart'; +import 'chat_conversation_page.dart'; + +class ChatPage extends StatefulWidget { + const ChatPage({super.key, this.chatApi}); + + final ChatApi? chatApi; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + late final ChatApi _chatApi; + late Future _sessionsFuture; + bool _isMutating = false; + + @override + void initState() { + super.initState(); + _chatApi = widget.chatApi ?? ChatApi(); + _sessionsFuture = _loadSessions(); + } + + Future _loadSessions() { + return _chatApi.getMySessions(limit: 50); + } + + void _refresh() { + setState(() { + _sessionsFuture = _loadSessions(); + }); + } + + Future _createNewSession() async { + if (_isMutating) { + return; + } + + setState(() => _isMutating = true); + try { + final response = await _chatApi.createSession(); + if (!mounted) { + return; + } + + final sessionId = response.sessionId; + if (sessionId == null || sessionId.isEmpty) { + showAppSnackBar(context, '对话创建成功,但未返回会话 ID'); + _refresh(); + return; + } + + await _openConversation( + sessionId: sessionId, + title: response.session?.displayTitle ?? '新对话', + ); + } on ApiException catch (error) { + if (!mounted) { + return; + } + showAppSnackBar(context, error.message); + } finally { + if (mounted) { + setState(() => _isMutating = false); + } + } + } + + Future _openConversation({ + required String sessionId, + required String title, + }) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ChatConversationPage( + sessionId: sessionId, + title: title, + chatApi: _chatApi, + ), + ), + ); + if (mounted) { + _refresh(); + } + } + + Future _renameSession(ChatSession session) async { + final title = await _showRenameDialog(session); + if (!mounted) { + return; + } + if (title == null || title == session.displayTitle) { + return; + } + + setState(() => _isMutating = true); + try { + final response = await _chatApi.renameSession( + sessionId: session.id, + title: title, + ); + if (!mounted) { + return; + } + showAppSnackBar(context, response.message); + _refresh(); + } on ApiException catch (error) { + if (!mounted) { + return; + } + showAppSnackBar(context, error.message); + } finally { + if (mounted) { + setState(() => _isMutating = false); + } + } + } + + Future _deleteSession(ChatSession session) async { + final confirmed = await _showDeleteDialog(session); + if (!mounted) { + return; + } + if (confirmed != true) { + return; + } + + setState(() => _isMutating = true); + try { + final response = await _chatApi.deleteSession(sessionId: session.id); + if (!mounted) { + return; + } + showAppSnackBar(context, response.message); + _refresh(); + } on ApiException catch (error) { + if (!mounted) { + return; + } + showAppSnackBar(context, error.message); + } finally { + if (mounted) { + setState(() => _isMutating = false); + } + } + } + + Future _showRenameDialog(ChatSession session) async { + final palette = AppThemePalette.of(context); + return showDialog( + context: context, + builder: (context) => _RenameSessionDialog( + palette: palette, + initialTitle: session.displayTitle, + ), + ); + } + + Future _showDeleteDialog(ChatSession session) { + final palette = AppThemePalette.of(context); + return showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: palette.menuBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + title: Text( + '删除对话', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + ), + ), + content: Text( + '确定删除「${session.displayTitle}」吗?', + style: TextStyle(color: palette.textSub), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('取消', style: TextStyle(color: palette.textSub)), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: palette.errorText, + foregroundColor: Colors.white, + elevation: 0, + ), + child: const Text('删除'), + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ChatToolbar( + palette: palette, + onNewChat: _createNewSession, + onRefresh: _refresh, + isRefreshing: _isMutating, + ), + const SizedBox(height: 12), + FutureBuilder( + future: _sessionsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return _ChatMessageCard( + palette: palette, + child: const Center( + child: Padding( + padding: EdgeInsets.all(20), + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + if (snapshot.hasError) { + return _ChatMessageCard( + palette: palette, + child: Column( + children: [ + Text( + '对话列表加载失败', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 10), + TextButton(onPressed: _refresh, child: const Text('重新加载')), + ], + ), + ); + } + + final response = snapshot.data; + final sessions = response?.sessions ?? const []; + if (sessions.isEmpty) { + return _ChatMessageCard( + palette: palette, + child: Text( + '暂无独立对话', + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + return Column( + children: [ + for (final session in sessions) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ChatSessionTile( + palette: palette, + session: session, + onTap: () => _openConversation( + sessionId: session.id, + title: session.displayTitle, + ), + onRename: () => _renameSession(session), + onDelete: () => _deleteSession(session), + ), + ), + ], + ); + }, + ), + ], + ); + } +} + +class _ChatToolbar extends StatelessWidget { + const _ChatToolbar({ + required this.palette, + required this.onNewChat, + required this.onRefresh, + required this.isRefreshing, + }); + + final AppThemePalette palette; + final VoidCallback onNewChat; + final VoidCallback onRefresh; + final bool isRefreshing; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + '对话', + style: TextStyle( + color: palette.textMain, + fontSize: 22, + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + tooltip: '刷新', + onPressed: isRefreshing ? null : onRefresh, + icon: const Icon(Icons.refresh_rounded), + color: palette.textSub, + ), + IconButton( + tooltip: '新建对话', + onPressed: onNewChat, + icon: const Icon(Icons.add_rounded), + color: palette.textMain, + ), + ], + ); + } +} + +class _RenameSessionDialog extends StatefulWidget { + const _RenameSessionDialog({ + required this.palette, + required this.initialTitle, + }); + + final AppThemePalette palette; + final String initialTitle; + + @override + State<_RenameSessionDialog> createState() => _RenameSessionDialogState(); +} + +class _RenameSessionDialogState extends State<_RenameSessionDialog> { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialTitle); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _submit() { + final value = _controller.text.trim(); + if (value.isEmpty) { + return; + } + Navigator.of(context).pop(value); + } + + @override + Widget build(BuildContext context) { + final palette = widget.palette; + + return AlertDialog( + backgroundColor: palette.menuBg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text( + '重命名', + style: TextStyle(color: palette.textMain, fontWeight: FontWeight.w900), + ), + content: TextField( + controller: _controller, + autofocus: true, + maxLength: 40, + onSubmitted: (_) => _submit(), + style: TextStyle(color: palette.textMain), + decoration: InputDecoration( + hintText: '输入对话标题', + hintStyle: TextStyle(color: palette.textSub), + filled: true, + fillColor: palette.panelBg, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: palette.panelBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: palette.primary), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('取消', style: TextStyle(color: palette.textSub)), + ), + ElevatedButton( + onPressed: _submit, + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + elevation: 0, + ), + child: const Text('保存'), + ), + ], + ); + } +} + +class _ChatSessionTile extends StatelessWidget { + const _ChatSessionTile({ + required this.palette, + required this.session, + required this.onTap, + required this.onRename, + required this.onDelete, + }); + + final AppThemePalette palette; + final ChatSession session; + final VoidCallback onTap; + final VoidCallback onRename; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + children: [ + Icon( + Icons.chat_bubble_rounded, + color: palette.primaryLight.withOpacity( + palette.isLight ? 0.42 : 0.46, + ), + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 5), + Text( + formatRelativeTime( + session.updatedAt ?? session.createdAt, + ), + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + _SessionMenu( + palette: palette, + onRename: onRename, + onDelete: onDelete, + ), + ], + ), + ), + ), + ); + } +} + +class _SessionMenu extends StatelessWidget { + const _SessionMenu({ + required this.palette, + required this.onRename, + required this.onDelete, + }); + + final AppThemePalette palette; + final VoidCallback onRename; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + return PopupMenuButton<_SessionAction>( + tooltip: '更多', + color: palette.menuBg, + elevation: 10, + offset: const Offset(0, 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: palette.panelBorder), + ), + icon: Icon(Icons.more_horiz_rounded, color: palette.textSub), + onSelected: (value) { + switch (value) { + case _SessionAction.rename: + onRename(); + case _SessionAction.delete: + onDelete(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem<_SessionAction>( + value: _SessionAction.rename, + child: Row( + children: [ + Icon(Icons.edit_rounded, color: palette.textSub, size: 20), + const SizedBox(width: 12), + Text( + '重命名', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + PopupMenuItem<_SessionAction>( + value: _SessionAction.delete, + child: Row( + children: [ + Icon(Icons.delete_rounded, color: palette.errorText, size: 20), + const SizedBox(width: 12), + Text( + '删除', + style: TextStyle( + color: palette.errorText, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _ChatMessageCard extends StatelessWidget { + const _ChatMessageCard({required this.palette, required this.child}); + + final AppThemePalette palette; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.panelBorder), + ), + child: child, + ); + } +} + +enum _SessionAction { rename, delete } diff --git a/apps/mobile/lib/features/workspace/presentation/pages/lamp_connect_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/lamp_connect_page.dart new file mode 100644 index 00000000..17919804 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/lamp_connect_page.dart @@ -0,0 +1,1802 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../../device/data/device_api.dart'; +import '../../../device/data/esp_ble_device_provisioner.dart'; + +const String _deviceCaptureUploadPath = '/api/device/capture'; +final String _defaultDeviceCaptureUploadUrl = _joinUrl( + ApiClient.defaultBaseUrl, + _deviceCaptureUploadPath, +); + +String _normalizeDeviceCaptureUploadUrl(String value) { + final uploadUrl = + value.trim().isEmpty ? _defaultDeviceCaptureUploadUrl : value.trim(); + final uri = Uri.tryParse(uploadUrl); + if (uri == null || + !uri.hasScheme || + uri.host.isEmpty || + (uri.path.isNotEmpty && uri.path != '/')) { + return uploadUrl; + } + + return _joinUrl(uploadUrl, _deviceCaptureUploadPath); +} + +String _joinUrl(String baseUrl, String path) { + final base = baseUrl.trim().replaceFirst(RegExp(r'/+$'), ''); + final suffix = path.startsWith('/') ? path : '/$path'; + return '$base$suffix'; +} + +class LampConnectPage extends StatefulWidget { + const LampConnectPage({super.key, this.deviceApi}); + + final DeviceApi? deviceApi; + + @override + State createState() => _LampConnectPageState(); +} + +class _LampConnectPageState extends State + with SingleTickerProviderStateMixin { + static const String _deviceNamePrefix = '智能学习台灯-'; + static final Uuid _provisioningServiceUuid = Uuid.parse( + '72135ce8-61d6-4aae-bdcb-5dfb935d0bd1', + ); + + late final DeviceApi _deviceApi; + final FlutterReactiveBle _ble = FlutterReactiveBle(); + final Map _devices = {}; + final TextEditingController _wifiSsidController = TextEditingController(); + final TextEditingController _wifiPasswordController = TextEditingController(); + final TextEditingController _uploadUrlController = TextEditingController( + text: _defaultDeviceCaptureUploadUrl, + ); + + StreamSubscription? _bleStatusSub; + StreamSubscription? _scanSub; + StreamSubscription? _connectionSub; + + BleStatus _bleStatus = BleStatus.unknown; + DiscoveredDevice? _selectedDevice; + List _services = []; + + bool _bindingLoading = false; + bool _checking = true; + bool _permissionReady = false; + bool _bluetoothReady = false; + bool _scanning = false; + bool _connecting = false; + bool _connected = false; + bool _discoveringServices = false; + bool _autoConfigDialogShown = false; + bool _provisioning = false; + bool _provisioningComplete = false; + + String? _message; + String? _error; + String? _deviceIp; + String? _activeBindingDeviceUuid; + String _imageProfile = 'medium'; + Map? _pendingConfigPayload; + + late final AnimationController _scanController; + + @override + void initState() { + super.initState(); + _deviceApi = widget.deviceApi ?? DeviceApi(); + _scanController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + )..repeat(); + + _bleStatusSub = _ble.statusStream.listen((status) { + if (!mounted) { + return; + } + + setState(() { + _bleStatus = status; + _bluetoothReady = status == BleStatus.ready; + }); + + if (status == BleStatus.ready && _permissionReady && !_scanning) { + unawaited(_startScan()); + } + }); + + unawaited(_init()); + } + + @override + void dispose() { + _bleStatusSub?.cancel(); + _scanSub?.cancel(); + _connectionSub?.cancel(); + _wifiSsidController.dispose(); + _wifiPasswordController.dispose(); + _uploadUrlController.dispose(); + _scanController.dispose(); + super.dispose(); + } + + Future _init() async { + setState(() { + _checking = true; + _message = '正在检查蓝牙权限'; + _error = null; + }); + + final permissionReady = await _requestPermissions(); + + if (!mounted) { + return; + } + + setState(() { + _permissionReady = permissionReady; + _checking = false; + }); + + if (!permissionReady) { + setState(() { + _error = '缺少蓝牙权限,请在系统设置中允许蓝牙访问'; + _message = null; + }); + return; + } + + if (_bleStatus == BleStatus.ready) { + await _startScan(); + } else { + setState(() => _message = '请先打开手机蓝牙'); + } + } + + Future _requestPermissions() async { + if (kIsWeb) { + return true; + } + + if (defaultTargetPlatform == TargetPlatform.android) { + final statuses = await [ + Permission.bluetoothScan, + Permission.bluetoothConnect, + Permission.locationWhenInUse, + ].request(); + + return (statuses[Permission.bluetoothScan]?.isGranted ?? false) && + (statuses[Permission.bluetoothConnect]?.isGranted ?? false) && + (statuses[Permission.locationWhenInUse]?.isGranted ?? false); + } + + if (defaultTargetPlatform == TargetPlatform.iOS) { + final bluetooth = await Permission.bluetooth.request(); + return bluetooth.isGranted || bluetooth.isLimited; + } + + return true; + } + + Future _startScan() async { + if (!_permissionReady) { + await _init(); + return; + } + + if (_bleStatus != BleStatus.ready) { + setState(() { + _message = '请先打开手机蓝牙'; + _error = null; + }); + return; + } + + await _scanSub?.cancel(); + await _connectionSub?.cancel(); + + setState(() { + _devices.clear(); + _selectedDevice = null; + _services = []; + _scanning = true; + _connected = false; + _connecting = false; + _discoveringServices = false; + _autoConfigDialogShown = false; + _provisioning = false; + _bindingLoading = false; + _provisioningComplete = false; + _deviceIp = null; + _activeBindingDeviceUuid = null; + _pendingConfigPayload = null; + _error = null; + _message = '正在搜索附近产品'; + }); + + _scanSub = _ble + .scanForDevices( + withServices: _scanServiceUuids, + scanMode: ScanMode.lowLatency, + ) + .listen( + (device) { + final name = device.name.trim(); + if (!mounted || !name.startsWith(_deviceNamePrefix)) { + return; + } + + setState(() => _devices[device.id] = device); + }, + onError: (Object error) { + if (!mounted) { + return; + } + + setState(() { + _scanning = false; + _error = '扫描失败:$error'; + _message = null; + }); + }, + ); + + Future.delayed(const Duration(seconds: 12), () async { + if (!mounted || !_scanning) { + return; + } + + await _scanSub?.cancel(); + _scanSub = null; + + setState(() { + _scanning = false; + _message = + _devices.isEmpty ? '没有搜索到设备,请确认台灯处于配网模式' : '请确认要连接的设备,并点击进入连接'; + }); + }); + } + + Future _connectDevice(DiscoveredDevice device) async { + await _scanSub?.cancel(); + await _connectionSub?.cancel(); + + setState(() { + _selectedDevice = device; + _scanning = false; + _connecting = true; + _connected = false; + _discoveringServices = false; + _services = []; + _error = null; + _message = '正在连接 ${_deviceLabel(device)}'; + }); + + _connectionSub = _ble + .connectToDevice( + id: device.id, + connectionTimeout: const Duration(seconds: 15), + ) + .listen( + (update) async { + if (!mounted) { + return; + } + + if (update.connectionState == DeviceConnectionState.connected) { + setState(() { + _connecting = false; + _connected = true; + _message = '连接成功,正在发现服务'; + }); + + await _discoverServices(device.id); + _scheduleConfigDialog(); + } else if (update.connectionState == + DeviceConnectionState.disconnected) { + setState(() { + _connecting = false; + _connected = false; + _discoveringServices = false; + _autoConfigDialogShown = false; + if (_provisioning) { + _message = '设备已退出配网模式,正在确认结果'; + } else { + _message = _provisioningComplete ? '配网完成,设备已退出配网模式' : '设备已断开连接'; + } + }); + } + }, + onError: (Object error) { + if (!mounted) { + return; + } + + setState(() { + _connecting = false; + _connected = false; + _discoveringServices = false; + _provisioning = false; + _error = '连接失败:$error'; + _message = null; + }); + }, + ); + } + + Future _disconnect({String message = '已断开连接'}) async { + await _connectionSub?.cancel(); + + setState(() { + _connecting = false; + _connected = false; + _discoveringServices = false; + _autoConfigDialogShown = false; + _provisioning = false; + _provisioningComplete = false; + _selectedDevice = null; + _services = []; + _deviceIp = null; + _activeBindingDeviceUuid = null; + _pendingConfigPayload = null; + _message = message; + }); + } + + void _scheduleConfigDialog() { + if (_autoConfigDialogShown || !_connected) { + return; + } + + _autoConfigDialogShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_connected) { + return; + } + unawaited(_openConfigDialog()); + }); + } + + Future _openConfigDialog() async { + if (!_connected) { + return; + } + + _ensureDefaultUploadUrl(); + + final hadConfig = _pendingConfigPayload != null; + final configured = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setDialogState) { + final palette = AppThemePalette.of(context); + + return MediaQuery.removeViewInsets( + removeBottom: true, + context: context, + child: Dialog( + backgroundColor: palette.pageBg, + insetPadding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 24, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: _PanelShell( + palette: palette, + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + '设备配网', + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + ), + IconButton( + onPressed: _provisioning + ? null + : () => + Navigator.of(dialogContext).pop(false), + icon: Icon( + Icons.close_rounded, + color: palette.textSub, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + '点击配置后会先创建后端设备绑定,随后写入 Wi-Fi 和上传参数;关闭窗口将断开当前连接。', + style: TextStyle( + color: palette.textSub, + fontSize: 12, + height: 1.45, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 14), + _ProvisioningConfigPanel( + palette: palette, + ssidController: _wifiSsidController, + passwordController: _wifiPasswordController, + uploadUrlController: _uploadUrlController, + imageProfile: _imageProfile, + onImageProfileChanged: (value) { + setState(() { + _imageProfile = value; + }); + setDialogState(() {}); + }, + ), + if (_provisioning || _message != null) ...[ + const SizedBox(height: 10), + _InlineNotice( + text: _message ?? '正在配置设备', + palette: palette, + ), + ], + if (_error != null) ...[ + const SizedBox(height: 10), + _InlineNotice( + text: _error!, + palette: palette, + isError: true, + ), + ], + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _LampSecondaryButton( + text: '取消', + icon: Icons.close_rounded, + palette: palette, + onPressed: _provisioning + ? null + : () => Navigator.of( + dialogContext, + ).pop(false), + ), + ), + const SizedBox(width: 10), + Expanded( + child: _LampPrimaryButton( + text: _bindingLoading + ? '绑定中' + : _provisioning + ? '配置中' + : '配置', + icon: _provisioning || _bindingLoading + ? Icons.sync_rounded + : Icons.send_rounded, + palette: palette, + onPressed: _provisioning || _bindingLoading + ? null + : () async { + setDialogState(() {}); + final accepted = + await _submitProvisioningConfig( + onStateChanged: () { + if (dialogContext.mounted) { + setDialogState(() {}); + } + }, + ); + if (!dialogContext.mounted) { + return; + } + setDialogState(() {}); + + if (accepted) { + Navigator.of(dialogContext).pop(true); + } + }, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); + }, + ); + + if (!mounted || !_connected || configured == true || hadConfig) { + return; + } + + await _disconnect(message: '未完成配网,已断开连接'); + } + + void _ensureDefaultUploadUrl() { + if (_uploadUrlController.text.trim().isNotEmpty) { + return; + } + + _uploadUrlController.text = _defaultDeviceCaptureUploadUrl; + } + + Future _submitProvisioningConfig({VoidCallback? onStateChanged}) async { + final ssid = _wifiSsidController.text.trim(); + final password = _wifiPasswordController.text; + final uploadUrl = _normalizeDeviceCaptureUploadUrl( + _uploadUrlController.text.trim(), + ); + + String? validationError; + if (ssid.isEmpty) { + validationError = '请输入 Wi-Fi 名称'; + } else if (!_isValidUploadUrl(uploadUrl)) { + validationError = '请输入有效的上传地址'; + } + + if (validationError != null) { + setState(() { + _error = validationError; + }); + onStateChanged?.call(); + return false; + } + + if (_uploadUrlController.text.trim() != uploadUrl) { + _uploadUrlController.text = uploadUrl; + } + + setState(() { + _error = null; + _message = '正在创建设备绑定'; + _bindingLoading = true; + _provisioning = true; + }); + onStateChanged?.call(); + + String? createdDeviceUuid; + + try { + final binding = await _deviceApi.bindDevice(); + final deviceUuid = binding.deviceUuid.trim(); + + if (!binding.success || deviceUuid.isEmpty) { + throw const DeviceProvisioningException('设备绑定创建失败'); + } + + createdDeviceUuid = deviceUuid; + + if (!mounted) { + await _rollbackDeviceBinding(deviceUuid); + return false; + } + + setState(() { + _bindingLoading = false; + _activeBindingDeviceUuid = deviceUuid; + _message = '正在准备配网'; + }); + onStateChanged?.call(); + + final result = await _provisionDevice( + DeviceProvisioningRequest( + deviceId: deviceUuid, + wifiSsid: ssid, + wifiPassword: password, + uploadUrl: uploadUrl, + imageProfile: _imageProfile, + ), + onStateChanged: onStateChanged, + ); + + if (!mounted) { + return false; + } + + setState(() { + _pendingConfigPayload = { + 'device_id': deviceUuid, + 'upload_url': uploadUrl, + 'image_profile': _imageProfile, + }; + _activeBindingDeviceUuid = null; + _bindingLoading = false; + _provisioning = false; + _provisioningComplete = true; + _deviceIp = result.deviceIp; + _error = null; + _message = result.deviceIp == null || result.deviceIp!.isEmpty + ? '配网完成' + : '配网完成,设备 IP:${result.deviceIp}'; + }); + onStateChanged?.call(); + return true; + } catch (error) { + if (createdDeviceUuid != null && createdDeviceUuid.isNotEmpty) { + final clearActiveBinding = + _activeBindingDeviceUuid == createdDeviceUuid; + if (mounted) { + setState(() { + if (clearActiveBinding) { + _activeBindingDeviceUuid = null; + } + _bindingLoading = false; + _message = '配网失败,正在回滚设备绑定'; + }); + onStateChanged?.call(); + } + await _rollbackDeviceBinding(createdDeviceUuid); + } + + if (!mounted) { + return false; + } + + setState(() { + _activeBindingDeviceUuid = null; + _bindingLoading = false; + _provisioning = false; + _error = error is DeviceProvisioningException + ? error.message + : '配网失败:$error'; + _message = null; + }); + onStateChanged?.call(); + return false; + } + } + + Future _rollbackDeviceBinding(String deviceUuid) async { + try { + await _deviceApi.unbindDevice(deviceUuid); + } catch (_) { + // The original provisioning failure is more useful to show here. + } + } + + Future _provisionDevice( + DeviceProvisioningRequest request, { + VoidCallback? onStateChanged, + }) { + final device = _selectedDevice; + if (device == null) { + throw const DeviceProvisioningException('没有可配置的设备连接'); + } + + final provisioner = EspBleDeviceProvisioner( + ble: _ble, + bleDeviceId: device.id, + provisioningServiceUuid: _provisioningServiceUuid, + isConnected: () => _connected, + onProgress: (message) { + if (!mounted) { + return; + } + setState(() { + _message = message; + _error = null; + }); + onStateChanged?.call(); + }, + ); + + return provisioner.provision(request); + } + + bool _isValidUploadUrl(String value) { + return isValidDeviceUploadUrl(value); + } + + Future _discoverServices(String deviceId) async { + setState(() { + _discoveringServices = true; + _message = '正在发现设备服务'; + }); + + try { + await _ble.discoverAllServices(deviceId); + final services = await _ble.getDiscoveredServices(deviceId); + + if (!mounted) { + return; + } + + setState(() { + _services = services; + _discoveringServices = false; + _message = '已连接设备'; + }); + } catch (error) { + if (!mounted) { + return; + } + + setState(() { + _discoveringServices = false; + _error = '发现服务失败:$error'; + _message = null; + }); + } + } + + bool get _isBusy => + _bindingLoading || + _checking || + _scanning || + _connecting || + _discoveringServices || + _provisioning; + + List get _scanServiceUuids => [_provisioningServiceUuid]; + + String get _titleText { + if (_checking) { + return '准备蓝牙搜索'; + } + if (_connected) { + return _provisioning ? '正在配置设备' : '设备已连接'; + } + if (_provisioningComplete) { + return '配网完成'; + } + if (_connecting) { + return '正在建立连接'; + } + return '搜索附近设备'; + } + + String get _subtitleText { + if (_checking) { + return '正在检查蓝牙权限'; + } + if (!_permissionReady) { + return '允许蓝牙权限后,才能发现并连接附近的学习台灯'; + } + if (!_bluetoothReady) { + return '请开启手机蓝牙,并确保设备处于可连接状态'; + } + if (_connected) { + return _provisioning ? '正在写入 Wi-Fi 和上传参数' : '连接已建立,请完成设备配网'; + } + if (_provisioningComplete) { + return '设备已完成配置并退出配网模式'; + } + return '正在搜索处于配网模式的智能学习台灯'; + } + + String get _statusLabel { + if (_bindingLoading) { + return '绑定中'; + } + if (_checking) { + return '检查中'; + } + if (_provisioning) { + return '配置中'; + } + if (_provisioningComplete) { + return '已完成'; + } + if (_connected) { + return '已连接'; + } + if (_connecting) { + return '连接中'; + } + if (_scanning) { + return '搜索中'; + } + if (!_permissionReady) { + return '待授权'; + } + if (!_bluetoothReady) { + return '蓝牙未就绪'; + } + return '待搜索'; + } + + IconData get _statusIcon { + if (_provisioningComplete) { + return Icons.check_rounded; + } + if (_connected) { + return Icons.link_rounded; + } + if (_checking || _connecting || _scanning || _provisioning) { + return Icons.sync_rounded; + } + if (!_permissionReady) { + return Icons.lock_outline_rounded; + } + if (!_bluetoothReady) { + return Icons.bluetooth_disabled_rounded; + } + return Icons.bluetooth_searching_rounded; + } + + Color _statusColor(AppThemePalette palette) { + if (_checking || _connecting || _scanning || _provisioning) { + return const Color(0xFFD0A35C); + } + if (_error != null || !_permissionReady || !_bluetoothReady) { + return palette.errorText; + } + if (_connected || _provisioningComplete) { + return const Color(0xFF63C9B2); + } + return palette.primary; + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final maxWidth = + constraints.maxWidth >= 760 ? 680.0 : constraints.maxWidth; + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SearchTopBar( + palette: palette, + statusLabel: _statusLabel, + statusIcon: _statusIcon, + statusColor: _statusColor(palette), + onBack: () => Navigator.of(context).pop(), + ), + const SizedBox(height: 14), + _SearchPanel( + palette: palette, + title: _titleText, + subtitle: _subtitleText, + statusLabel: _statusLabel, + statusIcon: _statusIcon, + statusColor: _statusColor(palette), + controller: _scanController, + active: _isBusy, + connected: _connected, + action: _buildActionArea(palette), + message: _message, + error: _error, + ), + if (_devices.isNotEmpty && !_connected) ...[ + const SizedBox(height: 14), + _buildDeviceList(palette), + ], + if (_connected) ...[ + const SizedBox(height: 14), + _buildConnectedPanel(palette), + ], + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } + + Widget _buildActionArea(AppThemePalette palette) { + if (!_permissionReady) { + return _LampPrimaryButton( + text: '允许蓝牙权限', + icon: Icons.lock_open_rounded, + palette: palette, + onPressed: _init, + ); + } + + if (!_bluetoothReady) { + return _LampPrimaryButton( + text: '重新检查蓝牙', + icon: Icons.bluetooth_rounded, + palette: palette, + onPressed: _init, + ); + } + + return _LampPrimaryButton( + text: _scanning ? '正在搜索' : '重新搜索', + icon: Icons.bluetooth_searching_rounded, + palette: palette, + onPressed: _scanning ? null : _startScan, + ); + } + + Widget _buildDeviceList(AppThemePalette palette) { + final devices = _devices.values.toList() + ..sort((a, b) => b.rssi.compareTo(a.rssi)); + + return _PanelShell( + palette: palette, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader( + palette: palette, + title: '可连接设备', + trailing: '${devices.length} 台', + ), + const SizedBox(height: 10), + ...devices.map( + (device) => _DeviceTile( + device: device, + palette: palette, + connecting: _connecting && _selectedDevice?.id == device.id, + onTap: _connecting ? null : () => _connectDevice(device), + ), + ), + ], + ), + ); + } + + Widget _buildConnectedPanel(AppThemePalette palette) { + final device = _selectedDevice; + + return _InfoPanel( + palette: palette, + title: '当前连接', + rows: [ + _InfoRowData(label: '设备名称', value: _deviceLabel(device)), + _InfoRowData(label: '连接状态', value: _connected ? '已连接' : '未连接'), + _InfoRowData(label: '服务数量', value: '${_services.length}'), + _InfoRowData(label: '配置状态', value: _configStateText), + if (_deviceIp != null && _deviceIp!.isNotEmpty) + _InfoRowData(label: '设备 IP', value: _deviceIp!), + ], + ); + } + + String get _configStateText { + if (_provisioning) { + return '写入中'; + } + if (_provisioningComplete) { + return '已完成'; + } + return _pendingConfigPayload == null ? '待配置' : '已写入'; + } + + String _deviceLabel(DiscoveredDevice? device) { + if (device == null) { + return '-'; + } + + final name = device.name.trim(); + return name.isEmpty ? '未命名设备' : name; + } +} + +class _SearchTopBar extends StatelessWidget { + const _SearchTopBar({ + required this.palette, + required this.statusLabel, + required this.statusIcon, + required this.statusColor, + required this.onBack, + }); + + final AppThemePalette palette; + final String statusLabel; + final IconData statusIcon; + final Color statusColor; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Icon(Icons.arrow_back_ios_new_rounded), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '台灯搜索', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + fontSize: 18, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _SearchPanel extends StatelessWidget { + const _SearchPanel({ + required this.palette, + required this.title, + required this.subtitle, + required this.statusLabel, + required this.statusIcon, + required this.statusColor, + required this.controller, + required this.active, + required this.connected, + required this.action, + required this.message, + required this.error, + }); + + final AppThemePalette palette; + final String title; + final String subtitle; + final String statusLabel; + final IconData statusIcon; + final Color statusColor; + final AnimationController controller; + final bool active; + final bool connected; + final Widget action; + final String? message; + final String? error; + + @override + Widget build(BuildContext context) { + return _PanelShell( + palette: palette, + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: palette.textMain, + fontSize: 22, + height: 1.2, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + Text( + subtitle, + style: TextStyle( + color: palette.textSub, + fontSize: 13, + height: 1.45, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + _StatusBadge( + palette: palette, + label: statusLabel, + icon: statusIcon, + color: statusColor, + ), + ], + ), + const SizedBox(height: 18), + _SearchRadar( + palette: palette, + controller: controller, + active: active, + connected: connected, + ), + const SizedBox(height: 16), + action, + if (message != null) ...[ + const SizedBox(height: 6), + _InlineNotice(text: message!, palette: palette), + ], + if (error != null) ...[ + const SizedBox(height: 6), + _InlineNotice(text: error!, palette: palette, isError: true), + ], + ], + ), + ); + } +} + +class _SearchRadar extends StatelessWidget { + const _SearchRadar({ + required this.palette, + required this.controller, + required this.active, + required this.connected, + }); + + final AppThemePalette palette; + final AnimationController controller; + final bool active; + final bool connected; + + @override + Widget build(BuildContext context) { + final accent = connected ? palette.primaryLight : palette.primary; + + return Center( + child: AnimatedBuilder( + animation: controller, + builder: (context, child) { + final progress = active ? controller.value : 0.35; + final pulseScale = 0.62 + progress * 0.36; + final pulseOpacity = active ? 0.18 + (1 - progress) * 0.12 : 0.16; + + return SizedBox( + width: 236, + height: 236, + child: Stack( + alignment: Alignment.center, + children: [ + _RadarRing(size: 236, opacity: 0.11, accent: accent), + _RadarRing(size: 176, opacity: 0.15, accent: accent), + _RadarRing(size: 116, opacity: 0.19, accent: accent), + if (active) + Transform.scale( + scale: pulseScale, + child: Container( + width: 236, + height: 236, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: accent.withOpacity(pulseOpacity), + ), + ), + ), + Container( + width: 82, + height: 82, + decoration: BoxDecoration( + color: palette.isLight + ? Colors.white.withOpacity(0.92) + : Colors.white.withOpacity(0.08), + shape: BoxShape.circle, + border: Border.all(color: accent.withOpacity(0.24)), + boxShadow: [ + BoxShadow( + color: accent.withOpacity(0.16), + blurRadius: 28, + offset: const Offset(0, 12), + ), + ], + ), + child: Icon( + connected + ? Icons.check_rounded + : Icons.bluetooth_searching_rounded, + color: accent, + size: 34, + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _RadarRing extends StatelessWidget { + const _RadarRing({ + required this.size, + required this.opacity, + required this.accent, + }); + + final double size; + final double opacity; + final Color accent; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: accent.withOpacity(opacity * 0.38), + border: Border.all(color: accent.withOpacity(opacity)), + ), + ); + } +} + +class _ProvisioningConfigPanel extends StatelessWidget { + const _ProvisioningConfigPanel({ + required this.palette, + required this.ssidController, + required this.passwordController, + required this.uploadUrlController, + required this.imageProfile, + required this.onImageProfileChanged, + }); + + final AppThemePalette palette; + final TextEditingController ssidController; + final TextEditingController passwordController; + final TextEditingController uploadUrlController; + final String imageProfile; + final ValueChanged onImageProfileChanged; + + @override + Widget build(BuildContext context) { + return _PanelShell( + palette: palette, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(palette: palette, title: '配网参数'), + const SizedBox(height: 12), + _ConfigTextField( + palette: palette, + controller: ssidController, + label: 'Wi-Fi 名称', + hintText: '输入要连接的 SSID', + icon: Icons.wifi_rounded, + ), + const SizedBox(height: 10), + _ConfigTextField( + palette: palette, + controller: passwordController, + label: 'Wi-Fi 密码', + hintText: '输入 Wi-Fi 密码', + icon: Icons.lock_outline_rounded, + obscureText: true, + ), + const SizedBox(height: 10), + _ConfigTextField( + palette: palette, + controller: uploadUrlController, + label: '上传地址', + hintText: _defaultDeviceCaptureUploadUrl, + icon: Icons.cloud_upload_outlined, + keyboardType: TextInputType.url, + ), + const SizedBox(height: 12), + Text( + '图片档位', + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + _ProfileOption( + palette: palette, + label: '低', + value: 'low', + selectedValue: imageProfile, + onSelected: onImageProfileChanged, + ), + const SizedBox(width: 8), + _ProfileOption( + palette: palette, + label: '中', + value: 'medium', + selectedValue: imageProfile, + onSelected: onImageProfileChanged, + ), + const SizedBox(width: 8), + _ProfileOption( + palette: palette, + label: '高', + value: 'high', + selectedValue: imageProfile, + onSelected: onImageProfileChanged, + ), + ], + ), + ], + ), + ); + } +} + +class _ConfigTextField extends StatelessWidget { + const _ConfigTextField({ + required this.palette, + required this.controller, + required this.label, + required this.hintText, + required this.icon, + this.obscureText = false, + this.keyboardType, + }); + + final AppThemePalette palette; + final TextEditingController controller; + final String label; + final String hintText; + final IconData icon; + final bool obscureText; + final TextInputType? keyboardType; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + obscureText: obscureText, + keyboardType: keyboardType, + style: TextStyle( + color: palette.textMain, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + decoration: InputDecoration( + labelText: label, + hintText: hintText, + prefixIcon: Icon(icon, color: palette.textSub, size: 18), + labelStyle: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + hintStyle: TextStyle( + color: palette.textSub.withOpacity(0.66), + fontWeight: FontWeight.w600, + ), + filled: true, + fillColor: palette.selectedBg, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 12, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: palette.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: palette.primary.withOpacity(0.72)), + ), + ), + ); + } +} + +class _ProfileOption extends StatelessWidget { + const _ProfileOption({ + required this.palette, + required this.label, + required this.value, + required this.selectedValue, + required this.onSelected, + }); + + final AppThemePalette palette; + final String label; + final String value; + final String selectedValue; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final selected = value == selectedValue; + + return Expanded( + child: InkWell( + onTap: () => onSelected(value), + borderRadius: BorderRadius.circular(10), + child: Container( + height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? palette.primary.withOpacity(0.16) + : palette.selectedBg, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: + selected ? palette.primary.withOpacity(0.36) : palette.border, + ), + ), + child: Text( + label, + style: TextStyle( + color: selected ? palette.primaryLight : palette.textSub, + fontSize: 13, + fontWeight: FontWeight.w900, + ), + ), + ), + ), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({ + required this.device, + required this.palette, + required this.connecting, + required this.onTap, + }); + + final DiscoveredDevice device; + final AppThemePalette palette; + final bool connecting; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final name = device.name.trim().isEmpty ? '未命名设备' : device.name.trim(); + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.selectedBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.border), + ), + child: Row( + children: [ + Icon(Icons.light, color: palette.primaryLight, size: 26), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + if (connecting) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.primaryLight, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _InfoPanel extends StatelessWidget { + const _InfoPanel({ + required this.palette, + required this.title, + required this.rows, + }); + + final AppThemePalette palette; + final String title; + final List<_InfoRowData> rows; + + @override + Widget build(BuildContext context) { + return _PanelShell( + palette: palette, + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(palette: palette, title: title), + const SizedBox(height: 12), + ...rows.map( + (row) => Padding( + padding: const EdgeInsets.only(bottom: 9), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 82, + child: Text( + row.label, + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ), + Expanded( + child: Text( + row.value, + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _InfoRowData { + const _InfoRowData({required this.label, required this.value}); + + final String label; + final String value; +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.palette, + required this.title, + this.trailing, + }); + + final AppThemePalette palette; + final String title; + final String? trailing; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + if (trailing != null) + Text( + trailing!, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ], + ); + } +} + +class _StatusBadge extends StatelessWidget { + const _StatusBadge({ + required this.palette, + required this.label, + required this.icon, + required this.color, + }); + + final AppThemePalette palette; + final String label; + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: color.withOpacity(palette.isLight ? 0.10 : 0.14), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withOpacity(0.20)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 14), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ); + } +} + +class _InlineNotice extends StatelessWidget { + const _InlineNotice({ + required this.text, + required this.palette, + this.isError = false, + }); + + final String text; + final AppThemePalette palette; + final bool isError; + + @override + Widget build(BuildContext context) { + final color = isError ? palette.errorText : palette.textSub; + + return Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + color: color, + fontSize: 13, + height: 1.45, + fontWeight: FontWeight.w800, + ), + ); + } +} + +class _PanelShell extends StatelessWidget { + const _PanelShell({ + required this.palette, + required this.child, + required this.padding, + }); + + final AppThemePalette palette; + final Widget child; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: child, + ); + } +} + +class _LampPrimaryButton extends StatelessWidget { + const _LampPrimaryButton({ + required this.text, + required this.icon, + required this.palette, + required this.onPressed, + }); + + final String text; + final IconData icon; + final AppThemePalette palette; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 50, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: palette.primaryLight, size: 18), + const SizedBox(width: 8), + Text( + text, + style: TextStyle( + color: palette.primaryLight, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + ); + } +} + +class _LampSecondaryButton extends StatelessWidget { + const _LampSecondaryButton({ + required this.text, + required this.icon, + required this.palette, + required this.onPressed, + }); + + final String text; + final IconData icon; + final AppThemePalette palette; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 50, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: palette.textSub, size: 18), + const SizedBox(width: 8), + Text( + text, + style: TextStyle( + color: palette.textSub, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/lamp_detail_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/lamp_detail_page.dart new file mode 100644 index 00000000..994a270d --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/lamp_detail_page.dart @@ -0,0 +1,548 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/protected_image.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../../device/data/device_api.dart'; + +class LampDetailPage extends StatefulWidget { + const LampDetailPage({super.key, required this.deviceUuid, this.deviceApi}); + + final String deviceUuid; + final DeviceApi? deviceApi; + + @override + State createState() => _LampDetailPageState(); +} + +class _LampDetailPageState extends State { + late final DeviceApi _deviceApi; + + List _images = const []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _deviceApi = widget.deviceApi ?? DeviceApi(); + unawaited(_loadImages()); + } + + Future _loadImages() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final response = await _deviceApi.getImages( + deviceUuid: widget.deviceUuid, + limit: 50, + ); + if (!mounted) { + return; + } + setState(() { + _images = response.images; + _loading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _loading = false; + _error = error is ApiException ? error.message : '图片列表加载失败'; + }); + } + } + + void _openPreview(DeviceCapture capture) { + final imageUrl = _normaliseDeviceImageUrl(capture.imageUrl); + if (imageUrl.isEmpty) { + return; + } + + showDialog( + context: context, + builder: (context) { + final palette = AppThemePalette.of(context); + + return Dialog.fullscreen( + backgroundColor: palette.pageBg, + child: SafeArea( + child: Column( + children: [ + _LampPreviewHeader( + palette: palette, + title: capture.displayName, + onClose: () => Navigator.of(context).pop(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 6, 16, 18), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ColoredBox( + color: Colors.transparent, + child: ProtectedImage( + url: imageUrl, + loadBytes: _deviceApi.loadImageBytes, + fit: BoxFit.contain, + loading: _ImageLoading(palette: palette), + error: _ImagePlaceholder( + palette: palette, + icon: Icons.broken_image_rounded, + text: '图片加载失败', + ), + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _LampDetailHeader( + palette: palette, + count: _images.length, + onBack: () => Navigator.of(context).maybePop(), + onRefresh: () => unawaited(_loadImages()), + ), + Expanded(child: _buildBody(palette)), + ], + ), + ), + ), + ); + } + + Widget _buildBody(AppThemePalette palette) { + if (_loading) { + return Center( + child: CircularProgressIndicator( + color: palette.primaryLight, + strokeWidth: 2, + ), + ); + } + + if (_error != null) { + return _LampDetailStateView( + palette: palette, + icon: Icons.error_outline_rounded, + title: '加载失败', + message: _error!, + actionText: '重试', + onAction: () => unawaited(_loadImages()), + ); + } + + if (_images.isEmpty) { + return RefreshIndicator( + color: palette.primary, + onRefresh: _loadImages, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(24, 70, 24, 24), + children: [ + _LampDetailStateView( + palette: palette, + icon: Icons.photo_library_outlined, + title: '暂无上传图片', + message: '设备拍摄上传后会显示在这里', + ), + ], + ), + ); + } + + return RefreshIndicator( + color: palette.primary, + onRefresh: _loadImages, + child: LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth >= 720 ? 3 : 2; + return GridView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(18, 10, 18, 24), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.78, + ), + itemCount: _images.length, + itemBuilder: (context, index) { + final capture = _images[index]; + return _DeviceImageCard( + capture: capture, + palette: palette, + loadBytes: _deviceApi.loadImageBytes, + onTap: () => _openPreview(capture), + ); + }, + ); + }, + ), + ); + } +} + +class _LampDetailHeader extends StatelessWidget { + const _LampDetailHeader({ + required this.palette, + required this.count, + required this.onBack, + required this.onRefresh, + }); + + final AppThemePalette palette; + final int count; + final VoidCallback onBack; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), + child: Row( + children: [ + IconButton( + tooltip: '返回', + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '智能学习台灯', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + IconButton( + tooltip: '刷新', + onPressed: onRefresh, + icon: const Icon(Icons.refresh_rounded), + color: palette.textMain, + ), + ], + ), + ); + } +} + +class _DeviceImageCard extends StatelessWidget { + const _DeviceImageCard({ + required this.capture, + required this.palette, + required this.loadBytes, + required this.onTap, + }); + + final DeviceCapture capture; + final AppThemePalette palette; + final Future Function(String url) loadBytes; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final imageUrl = _normaliseDeviceImageUrl(capture.imageUrl); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: imageUrl.isEmpty ? null : onTap, + borderRadius: BorderRadius.circular(8), + child: Ink( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all(color: palette.panelBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(8), + ), + child: ColoredBox( + color: palette.imageBg, + child: imageUrl.isEmpty + ? _ImagePlaceholder( + palette: palette, + icon: Icons.image_not_supported_outlined, + text: '无图片地址', + ) + : ProtectedImage( + url: imageUrl, + loadBytes: loadBytes, + fit: BoxFit.cover, + loading: _ImageLoading(palette: palette), + error: _ImagePlaceholder( + palette: palette, + icon: Icons.broken_image_rounded, + text: '加载失败', + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(10, 9, 10, 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _captureMeta(capture), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _LampDetailStateView extends StatelessWidget { + const _LampDetailStateView({ + required this.palette, + required this.icon, + required this.title, + required this.message, + this.actionText, + this.onAction, + }); + + final AppThemePalette palette; + final IconData icon; + final String title; + final String message; + final String? actionText; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: palette.primaryLight, size: 36), + const SizedBox(height: 12), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 6), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textSub, + fontSize: 13, + fontWeight: FontWeight.w700, + height: 1.35, + ), + ), + if (actionText != null && onAction != null) ...[ + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onAction, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text(actionText!), + ), + ], + ], + ), + ), + ); + } +} + +class _LampPreviewHeader extends StatelessWidget { + const _LampPreviewHeader({ + required this.palette, + required this.title, + required this.onClose, + }); + + final AppThemePalette palette; + final String title; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 12, 8), + child: Row( + children: [ + IconButton( + tooltip: '关闭', + onPressed: onClose, + icon: const Icon(Icons.close_rounded), + color: palette.textMain, + ), + Expanded( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ); + } +} + +class _ImageLoading extends StatelessWidget { + const _ImageLoading({required this.palette}); + + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: palette.primaryLight, + strokeWidth: 2, + ), + ), + ); + } +} + +class _ImagePlaceholder extends StatelessWidget { + const _ImagePlaceholder({ + required this.palette, + required this.icon, + required this.text, + }); + + final AppThemePalette palette; + final IconData icon; + final String text; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: palette.textSub, size: 24), + const SizedBox(height: 6), + Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +String _normaliseDeviceImageUrl(String? raw) { + final value = raw?.trim() ?? ''; + if (value.isEmpty || + value.startsWith('http://') || + value.startsWith('https://') || + value.startsWith('/')) { + return value; + } + return '/$value'; +} + +String _captureMeta(DeviceCapture capture) { + final parts = [ + _formatCaptureTime(capture.createdAt), + if (capture.fileSize != null) _formatFileSize(capture.fileSize!), + ]; + return parts.join(' · '); +} + +String _formatCaptureTime(DateTime? time) { + if (time == null) { + return '暂无时间'; + } + + final local = time.toLocal(); + final minute = local.minute.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + return '${local.month}月${local.day}日 $hour:$minute'; +} + +String _formatFileSize(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / 1024 / 1024).toStringAsFixed(1)} MB'; +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/lamp_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/lamp_page.dart new file mode 100644 index 00000000..562807b6 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/lamp_page.dart @@ -0,0 +1,377 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../device/data/device_api.dart'; +import 'lamp_connect_page.dart'; +import 'lamp_detail_page.dart'; + +class LampPage extends StatefulWidget { + const LampPage({super.key, this.deviceApi}); + + final DeviceApi? deviceApi; + + @override + State createState() => _LampPageState(); +} + +class _LampPageState extends State { + late final DeviceApi _deviceApi; + + DeviceBindingResponse? _binding; + bool _loading = true; + bool _unbinding = false; + String? _error; + + @override + void initState() { + super.initState(); + _deviceApi = widget.deviceApi ?? DeviceApi(); + _loadBinding(); + } + + Future _loadBinding() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final binding = await _deviceApi.getBinding(); + if (!mounted) { + return; + } + setState(() { + _binding = binding; + _loading = false; + }); + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _loading = false; + _error = error is ApiException ? error.message : '设备绑定状态加载失败'; + }); + } + } + + Future _openConnectPage() async { + if (_binding?.bound == true) { + return; + } + + await Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => const LampConnectPage())); + + if (mounted) { + await _loadBinding(); + } + } + + Future _openDetailPage() async { + final deviceUuid = _binding?.deviceUuid; + if (_binding?.bound != true || deviceUuid == null || deviceUuid.isEmpty) { + return; + } + + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + LampDetailPage(deviceUuid: deviceUuid, deviceApi: _deviceApi), + ), + ); + } + + Future _unbind() async { + final deviceUuid = _binding?.deviceUuid; + if (deviceUuid == null || deviceUuid.isEmpty || _unbinding) { + return; + } + + final confirmed = await showDialog( + context: context, + builder: (context) { + final palette = AppThemePalette.of(context); + + return AlertDialog( + backgroundColor: palette.menuBg, + title: Text( + '解除绑定', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + ), + ), + content: Text( + '解除后可以重新绑定另一台智能学习台灯。', + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text('取消', style: TextStyle(color: palette.textSub)), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text('解除', style: TextStyle(color: palette.errorText)), + ), + ], + ); + }, + ); + + if (confirmed != true || !mounted) { + return; + } + + setState(() { + _unbinding = true; + _error = null; + }); + + try { + await _deviceApi.unbindDevice(deviceUuid); + if (mounted) { + await _loadBinding(); + } + } catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error is ApiException ? error.message : '解除绑定失败'; + }); + } finally { + if (mounted) { + setState(() => _unbinding = false); + } + } + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + final bound = _binding?.bound == true && _binding?.deviceUuid != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '智能学习台灯', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + fontSize: 22, + ), + ), + const Spacer(), + if (!bound && !_loading) + _AddLampButton(palette: palette, onTap: _openConnectPage), + ], + ), + const SizedBox(height: 16), + if (_loading) + _LampLoadingCard(palette: palette) + else if (bound) + _BoundLampCard( + palette: palette, + unbinding: _unbinding, + onTap: _openDetailPage, + onUnbind: _unbind, + ) + else + _EmptyLampCard(palette: palette, onTap: _openConnectPage), + if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + style: TextStyle( + color: palette.errorText, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ], + ); + } +} + +class _AddLampButton extends StatelessWidget { + const _AddLampButton({required this.palette, required this.onTap}); + + final AppThemePalette palette; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + width: 64, + height: 64, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: palette.panelBorder), + ), + child: Icon( + Icons.add_rounded, + color: palette.primaryLight, + size: 34, + semanticLabel: '添加学习台灯', + ), + ), + ); + } +} + +class _BoundLampCard extends StatelessWidget { + const _BoundLampCard({ + required this.palette, + required this.unbinding, + required this.onTap, + required this.onUnbind, + }); + + final AppThemePalette palette; + final bool unbinding; + final VoidCallback onTap; + final VoidCallback onUnbind; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.selectedBg, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.light, color: palette.primaryLight, size: 30), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '智能学习台灯', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + fontSize: 16, + ), + ), + const SizedBox(height: 5), + Text( + '已绑定', + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ], + ), + ), + IconButton( + onPressed: unbinding ? null : onUnbind, + tooltip: '解除绑定', + icon: unbinding + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.textSub, + ), + ) + : Icon(Icons.link_off_rounded, color: palette.textSub), + ), + ], + ), + ), + ); + } +} + +class _EmptyLampCard extends StatelessWidget { + const _EmptyLampCard({required this.palette, required this.onTap}); + + final AppThemePalette palette; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + children: [ + Icon(Icons.add_rounded, color: palette.primaryLight, size: 28), + const SizedBox(width: 12), + Expanded( + child: Text( + '添加智能学习台灯', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w900, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _LampLoadingCard extends StatelessWidget { + const _LampLoadingCard({required this.palette}); + + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return Container( + height: 84, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.panelBorder), + ), + child: CircularProgressIndicator(color: palette.primaryLight), + ); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/library_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/library_page.dart new file mode 100644 index 00000000..3581d7a5 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/library_page.dart @@ -0,0 +1,511 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/utils/time_format.dart'; +import '../../data/workspace_api.dart'; +import '../../data/workspace_project_store.dart'; +import 'library_project_detail_page.dart'; + +enum _LibraryFilter { all, question, note } + +class LibraryPage extends StatefulWidget { + const LibraryPage({super.key}); + + @override + State createState() => _LibraryPageState(); +} + +class _LibraryPageState extends State { + final WorkspaceProjectStore _store = WorkspaceProjectStore.instance; + final TextEditingController _searchController = TextEditingController(); + _LibraryFilter _filter = _LibraryFilter.all; + + @override + void initState() { + super.initState(); + unawaited(_store.ensureLoaded()); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return AnimatedBuilder( + animation: _store, + builder: (context, _) { + final projects = _filteredProjects; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '库', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + fontSize: 22, + ), + ), + const SizedBox(height: 16), + _buildStats(palette), + const SizedBox(height: 16), + Divider(color: palette.border), + const SizedBox(height: 14), + _buildToolbar(palette), + const SizedBox(height: 14), + _buildTableHeader(palette), + const SizedBox(height: 8), + if (_store.isLoading && _store.projects.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center(child: CircularProgressIndicator()), + ) + else if (_store.errorMessage != null && _store.projects.isEmpty) + _buildMessage( + palette, + _store.errorMessage!, + actionText: '重试', + onAction: () => unawaited(_store.refresh()), + ) + else if (projects.isEmpty) + _buildMessage(palette, '暂无匹配项目') + else + ...projects.map((project) => _buildProjectRow(project, palette)), + ], + ); + }, + ); + } + + List get _filteredProjects { + final keyword = _searchController.text.trim().toLowerCase(); + return _store.projects.where((project) { + final typeMatched = switch (_filter) { + _LibraryFilter.all => true, + _LibraryFilter.question => project.isQuestionProject, + _LibraryFilter.note => project.isNoteProject, + }; + if (!typeMatched) { + return false; + } + if (keyword.isEmpty) { + return true; + } + return project.displayName.toLowerCase().contains(keyword) || + project.displayDescription.toLowerCase().contains(keyword); + }).toList(); + } + + Widget _buildStats(AppThemePalette palette) { + final cards = [ + _StatItem( + title: '全部项目', + value: _store.projects.length.toString(), + icon: Icons.inventory_2_rounded, + color: palette.primary, + ), + _StatItem( + title: '错题库', + value: _store.questionProjects.length.toString(), + icon: Icons.storage_rounded, + color: const Color(0xFF58A6FF), + ), + _StatItem( + title: '笔记本', + value: _store.noteProjects.length.toString(), + icon: Icons.menu_book_rounded, + color: const Color(0xFF32D99C), + ), + _StatItem( + title: '总题目', + value: _store.totalQuestionCount.toString(), + icon: Icons.format_list_numbered_rounded, + color: const Color(0xFFFFB020), + ), + _StatItem( + title: '总笔记', + value: _store.totalNoteCount.toString(), + icon: Icons.sticky_note_2_rounded, + color: const Color(0xFFFF8A3D), + ), + ]; + + return LayoutBuilder( + builder: (context, constraints) { + const gap = 12.0; + final width = constraints.maxWidth; + final columns = width >= 300 + ? 3 + : width >= 210 + ? 2 + : 1; + final itemWidth = (width - gap * (columns - 1)) / columns; + final compact = itemWidth < 136; + + return Wrap( + spacing: gap, + runSpacing: gap, + children: cards + .map( + (item) => SizedBox( + width: itemWidth, + child: _StatCard( + item: item, + palette: palette, + compact: compact, + ), + ), + ) + .toList(), + ); + }, + ); + } + + Widget _buildToolbar(AppThemePalette palette) { + return LayoutBuilder( + builder: (context, constraints) { + final filters = Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFilterButton( + _LibraryFilter.all, + '全部 ${_store.projects.length}', + palette, + ), + const SizedBox(width: 6), + _buildFilterButton( + _LibraryFilter.question, + '错题库 ${_store.questionProjects.length}', + palette, + ), + const SizedBox(width: 6), + _buildFilterButton( + _LibraryFilter.note, + '笔记本 ${_store.noteProjects.length}', + palette, + ), + ], + ); + if (constraints.maxWidth < 760) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: filters, + ), + ], + ); + } + + return Row(children: [filters, const Spacer()]); + }, + ); + } + + Widget _buildFilterButton( + _LibraryFilter filter, + String text, + AppThemePalette palette, + ) { + final selected = _filter == filter; + return Padding( + padding: const EdgeInsets.only(right: 2), + child: TextButton( + onPressed: () => setState(() => _filter = filter), + style: TextButton.styleFrom( + backgroundColor: selected ? palette.primary : palette.panel, + foregroundColor: selected ? Colors.white : palette.textSub, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), + ), + child: Text( + text, + style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 12), + ), + ), + ); + } + + Widget _buildTableHeader(AppThemePalette palette) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 8), + child: Row( + children: [ + Expanded(flex: 5, child: Text('项目', style: _headerStyle(palette))), + Expanded(flex: 2, child: Text('内容', style: _headerStyle(palette))), + Expanded(flex: 2, child: Text('最近更新', style: _headerStyle(palette))), + ], + ), + ); + } + + Widget _buildProjectRow(WorkspaceProject project, AppThemePalette palette) { + final iconColor = palette.primaryLight.withOpacity(0.5); + return Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _openProject(project), + borderRadius: BorderRadius.circular(12), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.fromLTRB(20, 14, 20, 14), + decoration: BoxDecoration( + border: Border.all(color: palette.panelBorder), + borderRadius: BorderRadius.circular(12), + color: palette.cardBg, + ), + child: Row( + children: [ + Expanded( + flex: 5, + child: Row( + children: [ + Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.panel, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + project.isQuestionProject + ? Icons.storage_rounded + : Icons.menu_book_rounded, + color: iconColor, + size: 16, + ), + ), + const SizedBox(width: 6), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.displayName, + style: TextStyle( + color: palette.textMain, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + project.displayDescription, + style: TextStyle( + color: palette.textSub, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), + Expanded( + flex: 2, + child: Text( + project.isQuestionProject + ? '${project.questionCount} 道题' + : '${project.noteCount} 篇笔记', + style: TextStyle( + color: palette.textSub, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + flex: 2, + child: Row( + children: [ + Expanded( + child: Text( + formatRelativeTime( + project.updatedAt ?? project.createdAt, + ), + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + fontSize: 11, + ), + ), + ), + Icon( + Icons.chevron_right_rounded, + color: palette.textSub, + size: 18, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + void _openProject(WorkspaceProject project) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryProjectDetailPage(project: project), + ), + ); + } + + Widget _buildMessage( + AppThemePalette palette, + String message, { + String? actionText, + VoidCallback? onAction, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 44), + child: Center( + child: Column( + children: [ + Text( + message, + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + if (actionText != null && onAction != null) ...[ + const SizedBox(height: 10), + OutlinedButton(onPressed: onAction, child: Text(actionText)), + ], + ], + ), + ), + ); + } + + TextStyle _headerStyle(AppThemePalette palette) { + return TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w800, + ); + } +} + +class _StatItem { + const _StatItem({ + required this.title, + required this.value, + required this.icon, + required this.color, + }); + + final String title; + final String value; + final IconData icon; + final Color color; +} + +class _StatCard extends StatelessWidget { + const _StatCard({ + required this.item, + required this.palette, + required this.compact, + }); + + final _StatItem item; + final AppThemePalette palette; + final bool compact; + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(minHeight: compact ? 94 : 68), + padding: EdgeInsets.all(compact ? 10 : 14), + decoration: BoxDecoration( + color: palette.panel, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.border), + ), + child: compact ? _buildCompact() : _buildRegular(), + ); + } + + Widget _buildRegular() { + return Row( + children: [ + _buildIcon(size: 40, iconSize: 20), + const SizedBox(width: 12), + Expanded( + child: _buildTexts(crossAxisAlignment: CrossAxisAlignment.start), + ), + ], + ); + } + + Widget _buildCompact() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildIcon(size: 32, iconSize: 17), + const SizedBox(height: 8), + _buildTexts(crossAxisAlignment: CrossAxisAlignment.start), + ], + ); + } + + Widget _buildIcon({required double size, required double iconSize}) { + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: item.color.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(item.icon, color: item.color, size: iconSize), + ); + } + + Widget _buildTexts({required CrossAxisAlignment crossAxisAlignment}) { + return Column( + crossAxisAlignment: crossAxisAlignment, + children: [ + Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + item.value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: compact ? 17 : 18, + fontWeight: FontWeight.w900, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/library_project_detail_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/library_project_detail_page.dart new file mode 100644 index 00000000..3ec7098b --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/library_project_detail_page.dart @@ -0,0 +1,1517 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/utils/time_format.dart'; +import '../../../../core/widgets/markdown_math_text.dart'; +import '../../../../core/widgets/protected_image.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../data/workspace_api.dart'; + +class LibraryProjectDetailPage extends StatefulWidget { + const LibraryProjectDetailPage({ + super.key, + required this.project, + this.workspaceApi, + }); + + final WorkspaceProject project; + final WorkspaceApi? workspaceApi; + + @override + State createState() => + _LibraryProjectDetailPageState(); +} + +class _LibraryProjectDetailPageState extends State { + late final WorkspaceApi _workspaceApi; + final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + final List _questions = []; + final List _notes = []; + Timer? _searchDebounce; + + int _page = 1; + int _total = 0; + bool _hasMore = false; + bool _isLoading = false; + bool _isLoadingMore = false; + String? _errorMessage; + String _keyword = ''; + + static const int _pageSize = 10; + + bool get _isQuestionProject => widget.project.isQuestionProject; + + @override + void initState() { + super.initState(); + _workspaceApi = widget.workspaceApi ?? WorkspaceApi(); + _scrollController.addListener(_onScroll); + unawaited(_loadItems(reset: true)); + } + + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollController.position.extentAfter < 360 && + _hasMore && + !_isLoading && + !_isLoadingMore) { + unawaited(_loadItems(reset: false)); + } + } + + void _onSearchChanged(String value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer(const Duration(milliseconds: 350), () { + final keyword = value.trim(); + if (keyword == _keyword) { + return; + } + _keyword = keyword; + unawaited(_loadItems(reset: true)); + }); + } + + Future _loadItems({required bool reset}) async { + if (reset) { + setState(() { + _isLoading = true; + _errorMessage = null; + _page = 1; + _hasMore = false; + _total = 0; + _questions.clear(); + _notes.clear(); + }); + } else { + if (_isLoadingMore || !_hasMore) { + return; + } + setState(() => _isLoadingMore = true); + } + + final nextPage = reset ? 1 : _page + 1; + + try { + if (_isQuestionProject) { + final response = await _workspaceApi.queryErrorBank( + page: nextPage, + pageSize: _pageSize, + keyword: _keyword, + projectId: widget.project.id, + ); + if (!mounted) { + return; + } + setState(() { + _page = response.page; + _total = response.total; + _hasMore = response.hasMore; + if (reset) { + _questions + ..clear() + ..addAll(response.items); + } else { + _questions.addAll(response.items); + } + }); + } else { + final response = await _workspaceApi.queryNotes( + page: nextPage, + limit: _pageSize, + keyword: _keyword, + projectId: widget.project.id, + ); + if (!mounted) { + return; + } + setState(() { + _page = response.page; + _total = response.total; + _hasMore = response.hasMore; + if (reset) { + _notes + ..clear() + ..addAll(response.items); + } else { + _notes.addAll(response.items); + } + }); + } + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() => _errorMessage = error.message); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + _isLoadingMore = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _LibraryDetailHeader( + palette: palette, + project: widget.project, + total: _total, + onBack: () => Navigator.of(context).maybePop(), + onRefresh: () => unawaited(_loadItems(reset: true)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 10), + child: _buildSearchBar(palette), + ), + Expanded(child: _buildBody(palette)), + ], + ), + ), + ), + ); + } + + Widget _buildSearchBar(AppThemePalette palette) { + return TextField( + controller: _searchController, + onChanged: (value) { + setState(() {}); + _onSearchChanged(value); + }, + style: TextStyle(color: palette.textMain, fontSize: 14), + decoration: InputDecoration( + isDense: true, + hintText: _isQuestionProject ? '搜索题干、知识点' : '搜索笔记、知识点', + hintStyle: TextStyle(color: palette.textSub), + prefixIcon: Icon(Icons.search_rounded, color: palette.textSub), + suffixIcon: _searchController.text.trim().isEmpty + ? null + : IconButton( + tooltip: '清空', + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + setState(() {}); + }, + icon: Icon(Icons.close_rounded, color: palette.textSub), + ), + filled: true, + fillColor: palette.panel, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: palette.panelBorder), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: palette.panelBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: palette.primary), + ), + ), + ); + } + + Widget _buildBody(AppThemePalette palette) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator(strokeWidth: 2)); + } + + if (_errorMessage != null) { + return _LibraryMessageState( + palette: palette, + icon: Icons.error_outline_rounded, + title: '加载失败', + message: _errorMessage!, + actionText: '重试', + onAction: () => unawaited(_loadItems(reset: true)), + ); + } + + final itemCount = _isQuestionProject ? _questions.length : _notes.length; + if (itemCount == 0) { + return _LibraryMessageState( + palette: palette, + icon: _isQuestionProject + ? Icons.storage_rounded + : Icons.menu_book_rounded, + title: _keyword.isEmpty ? '暂无内容' : '没有匹配结果', + message: _keyword.isEmpty + ? (_isQuestionProject ? '这个错题库还没有题目' : '这个笔记本还没有笔记') + : '换个关键词再试试', + ); + } + + return RefreshIndicator( + color: palette.primary, + onRefresh: () => _loadItems(reset: true), + child: ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(18, 2, 18, 24), + itemCount: itemCount + (_hasMore || _isLoadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= itemCount) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: _isLoadingMore + ? const CircularProgressIndicator(strokeWidth: 2) + : TextButton( + onPressed: () => unawaited(_loadItems(reset: false)), + child: const Text('加载更多'), + ), + ), + ); + } + + if (_isQuestionProject) { + final question = _questions[index]; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _QuestionListTile( + question: question, + palette: palette, + onTap: () => _openQuestionDetail(question), + ), + ), + ); + } + + final note = _notes[index]; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _NoteListTile( + note: note, + palette: palette, + onTap: () => _openNoteDetail(note), + ), + ), + ); + }, + ), + ); + } + + void _openQuestionDetail(LibraryQuestionItem question) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryQuestionDetailPage( + project: widget.project, + question: question, + workspaceApi: _workspaceApi, + ), + ), + ); + } + + void _openNoteDetail(LibraryNoteItem note) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LibraryNoteDetailPage( + project: widget.project, + note: note, + workspaceApi: _workspaceApi, + ), + ), + ); + } +} + +class LibraryQuestionDetailPage extends StatelessWidget { + const LibraryQuestionDetailPage({ + super.key, + required this.project, + required this.question, + required this.workspaceApi, + }); + + final WorkspaceProject project; + final LibraryQuestionItem question; + final WorkspaceApi workspaceApi; + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _LibraryItemDetailHeader( + palette: palette, + icon: Icons.storage_rounded, + title: project.displayName, + meta: question.questionType.isEmpty + ? '错题详情' + : question.questionType, + onBack: () => Navigator.of(context).maybePop(), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 24), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _QuestionCard( + question: question, + palette: palette, + workspaceApi: workspaceApi, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class LibraryNoteDetailPage extends StatelessWidget { + const LibraryNoteDetailPage({ + super.key, + required this.project, + required this.note, + required this.workspaceApi, + }); + + final WorkspaceProject project; + final LibraryNoteItem note; + final WorkspaceApi workspaceApi; + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _LibraryItemDetailHeader( + palette: palette, + icon: Icons.menu_book_rounded, + title: note.displayTitle, + meta: project.displayName, + onBack: () => Navigator.of(context).maybePop(), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(18, 14, 18, 24), + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _NoteCard( + note: note, + palette: palette, + workspaceApi: workspaceApi, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _LibraryItemDetailHeader extends StatelessWidget { + const _LibraryItemDetailHeader({ + required this.palette, + required this.icon, + required this.title, + required this.meta, + required this.onBack, + }); + + final AppThemePalette palette; + final IconData icon; + final String title; + final String meta; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.divider)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 16, 12), + child: Row( + children: [ + IconButton( + tooltip: '返回', + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + ), + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.16), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: palette.primaryLight, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 5), + _MetaChip(label: meta, palette: palette), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _QuestionListTile extends StatelessWidget { + const _QuestionListTile({ + required this.question, + required this.palette, + required this.onTap, + }); + + final LibraryQuestionItem question; + final AppThemePalette palette; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return _LibrarySummaryTile( + palette: palette, + title: question.questionType.isEmpty ? '错题' : question.questionType, + preview: question.previewText, + meta: [ + if (question.subject.isNotEmpty) + _SummaryMetaItem.subject(question.subject), + if (question.reviewStatus.isNotEmpty) + _SummaryMetaItem.status(question.reviewStatus), + if (question.knowledgeTags.isNotEmpty) + _SummaryMetaItem.knowledge(question.knowledgeTags.first), + _SummaryMetaItem.time( + formatRelativeTime(question.updatedAt ?? question.createdAt), + ), + ], + highlighted: question.needsCorrection || question.reviewIsDue, + onTap: onTap, + ); + } +} + +class _NoteListTile extends StatelessWidget { + const _NoteListTile({ + required this.note, + required this.palette, + required this.onTap, + }); + + final LibraryNoteItem note; + final AppThemePalette palette; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return _LibrarySummaryTile( + palette: palette, + title: note.displayTitle, + preview: note.summary.trim().isEmpty ? note.previewText : note.summary, + meta: [ + if (note.subject.isNotEmpty) _SummaryMetaItem.subject(note.subject), + if (note.reviewStatus.isNotEmpty) + _SummaryMetaItem.status(note.reviewStatus), + if (note.knowledgeTags.isNotEmpty) + _SummaryMetaItem.knowledge(note.knowledgeTags.first), + _SummaryMetaItem.time( + formatRelativeTime(note.updatedAt ?? note.createdAt), + ), + ], + highlighted: false, + onTap: onTap, + ); + } +} + +class _LibrarySummaryTile extends StatelessWidget { + const _LibrarySummaryTile({ + required this.palette, + required this.title, + required this.preview, + required this.meta, + required this.highlighted, + required this.onTap, + }); + + final AppThemePalette palette; + final String title; + final String preview; + final List<_SummaryMetaItem> meta; + final bool highlighted; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.fromLTRB(16, 14, 14, 14), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + height: 1.2, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 6), + Text( + _compactPreview(preview), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + height: 1.45, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 7, + runSpacing: 7, + children: meta + .where((item) => item.label.trim().isNotEmpty) + .take(4) + .map( + (item) => _SummaryMetaChip( + item: item, + palette: palette, + highlighted: highlighted, + ), + ) + .toList(growable: false), + ), + ], + ), + ), + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + color: palette.textSub, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +enum _SummaryMetaKind { subject, knowledge, status, time } + +class _SummaryMetaItem { + const _SummaryMetaItem._(this.kind, this.label); + + factory _SummaryMetaItem.subject(String label) { + return _SummaryMetaItem._(_SummaryMetaKind.subject, label); + } + + factory _SummaryMetaItem.knowledge(String label) { + return _SummaryMetaItem._(_SummaryMetaKind.knowledge, label); + } + + factory _SummaryMetaItem.status(String label) { + return _SummaryMetaItem._(_SummaryMetaKind.status, label); + } + + factory _SummaryMetaItem.time(String label) { + return _SummaryMetaItem._(_SummaryMetaKind.time, label); + } + + final _SummaryMetaKind kind; + final String label; +} + +class _SummaryMetaChip extends StatelessWidget { + const _SummaryMetaChip({ + required this.item, + required this.palette, + required this.highlighted, + }); + + final _SummaryMetaItem item; + final AppThemePalette palette; + final bool highlighted; + + @override + Widget build(BuildContext context) { + final tone = _tone(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: tone.$1, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: tone.$2), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(tone.$4, size: 12, color: tone.$3), + const SizedBox(width: 4), + Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: tone.$3, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } + + (Color, Color, Color, IconData) _tone() { + return switch (item.kind) { + _SummaryMetaKind.subject => ( + palette.primary.withOpacity(0.14), + palette.primary.withOpacity(0.12), + palette.primaryLight, + Icons.school_rounded, + ), + _SummaryMetaKind.knowledge => ( + palette.isLight + ? const Color(0xFFE8ECF8) + : Colors.white.withOpacity(0.08), + palette.panelBorder, + palette.textSub, + Icons.sell_rounded, + ), + _SummaryMetaKind.status => _statusTone(), + _SummaryMetaKind.time => ( + palette.badgeBg, + palette.panelBorder, + palette.textSub.withOpacity(0.82), + Icons.schedule_rounded, + ), + }; + } + + (Color, Color, Color, IconData) _statusTone() { + final color = _ReviewStatusTone.fromStatus(item.label).color; + return ( + color.withOpacity(palette.isLight ? 0.12 : 0.18), + color.withOpacity(0.22), + color, + highlighted ? Icons.priority_high_rounded : Icons.flag_rounded, + ); + } +} + +String _compactPreview(String value) { + final text = value + .replaceAll(RegExp(r'```[\s\S]*?```'), ' ') + .replaceAll(RegExp(r'!\[[^\]]*\]\([^)]+\)'), ' ') + .replaceAllMapped(RegExp(r'\[([^\]]+)\]\([^)]+\)'), (match) { + return match.group(1) ?? ''; + }) + .replaceAllMapped(RegExp(r'`([^`]*)`'), (match) { + return match.group(1) ?? ''; + }) + .replaceAll(RegExp(r'<[^>]+>'), ' ') + .replaceAll(RegExp(r'#{1,6}\s*'), ' ') + .replaceAll(r'\parallel', '∥') + .replaceAll(RegExp(r'\$\$?'), ' ') + .replaceAll(RegExp(r'[*_~>]'), '') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return text.isEmpty ? '暂无内容' : text; +} + +class _LibraryDetailHeader extends StatelessWidget { + const _LibraryDetailHeader({ + required this.palette, + required this.project, + required this.total, + required this.onBack, + required this.onRefresh, + }); + + final AppThemePalette palette; + final WorkspaceProject project; + final int total; + final VoidCallback onBack; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + final isQuestion = project.isQuestionProject; + final typeLabel = isQuestion ? '错题库' : '笔记本'; + final icon = isQuestion ? Icons.storage_rounded : Icons.menu_book_rounded; + + return Row( + children: [ + IconButton( + tooltip: '返回', + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + ), + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.16), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: palette.primaryLight, size: 22), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _MetaChip(label: typeLabel, palette: palette), + _MetaChip(label: '$total 条内容', palette: palette), + ], + ), + ], + ), + ), + IconButton( + tooltip: '刷新', + onPressed: onRefresh, + icon: const Icon(Icons.refresh_rounded), + color: palette.textSub, + ), + ], + ); + } +} + +class _QuestionCard extends StatelessWidget { + const _QuestionCard({ + required this.question, + required this.palette, + required this.workspaceApi, + }); + + final LibraryQuestionItem question; + final AppThemePalette palette; + final WorkspaceApi workspaceApi; + + @override + Widget build(BuildContext context) { + final embeddedImages = _embeddedImageUrls(question.previewText); + final images = _imageUrls([ + ...question.contentBlocks + .where((block) => block.isImage) + .map((block) => block.content), + ...question.imageRefs, + ], excluded: embeddedImages); + + return _LibraryCard( + palette: palette, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMeta(), + const SizedBox(height: 14), + MarkdownMathText( + text: question.previewText, + palette: palette, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + height: 1.55, + fontWeight: FontWeight.w700, + ), + imageBuilder: (context, alt, url) => _MarkdownProtectedImage( + url: url, + alt: alt, + palette: palette, + workspaceApi: workspaceApi, + ), + ), + if (images.isNotEmpty) ...[ + const SizedBox(height: 12), + _ImageStrip( + title: '关联原图', + urls: images, + palette: palette, + workspaceApi: workspaceApi, + ), + ], + if (question.options.isNotEmpty) ...[ + const SizedBox(height: 14), + _OptionsGrid(options: question.options, palette: palette), + ], + if (_hasValue(question.answer) || _hasValue(question.userAnswer)) ...[ + const SizedBox(height: 12), + Divider(color: palette.panelBorder), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (_hasValue(question.answer)) + _MetaChip(label: '答案 ${question.answer}', palette: palette), + if (_hasValue(question.userAnswer)) + _MetaChip( + label: '我的答案 ${question.userAnswer}', + palette: palette, + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildMeta() { + return Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (question.questionType.isNotEmpty) + _MetaChip(label: question.questionType, palette: palette), + if (question.subject.isNotEmpty) + _MetaChip(label: question.subject, palette: palette), + if (question.reviewStatus.isNotEmpty) + _ReviewStatusChip(status: question.reviewStatus, palette: palette), + if (question.needsCorrection) + _MetaChip(label: '需校对', palette: palette, highlighted: true), + if (question.reviewCount > 0) + _MetaChip(label: '复习 ${question.reviewCount} 次', palette: palette), + if (question.reviewIntervalDays > 0) + _MetaChip( + label: '${question.reviewIntervalDays} 天间隔', + palette: palette, + ), + ...question.knowledgeTags.take(4).map( + (tag) => + _MetaChip(label: tag, palette: palette, highlighted: true), + ), + ], + ); + } +} + +class _NoteCard extends StatelessWidget { + const _NoteCard({ + required this.note, + required this.palette, + required this.workspaceApi, + }); + + final LibraryNoteItem note; + final AppThemePalette palette; + final WorkspaceApi workspaceApi; + + @override + Widget build(BuildContext context) { + final embeddedImages = _embeddedImageUrls(note.previewText); + final images = _imageUrls([ + ...note.contentBlocks + .where((block) => block.isImage) + .map((block) => block.content), + ...note.imageRefs, + ], excluded: embeddedImages); + + return _LibraryCard( + palette: palette, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (note.subject.isNotEmpty) + _MetaChip(label: note.subject, palette: palette), + if (note.reviewStatus.isNotEmpty) + _ReviewStatusChip(status: note.reviewStatus, palette: palette), + if (note.reviewCount > 0) + _MetaChip(label: '复习 ${note.reviewCount} 次', palette: palette), + if (note.reviewIntervalDays > 0) + _MetaChip( + label: '${note.reviewIntervalDays} 天间隔', + palette: palette, + ), + ...note.knowledgeTags.take(4).map( + (tag) => _MetaChip( + label: tag, + palette: palette, + highlighted: true, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + note.displayTitle, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 10), + MarkdownMathText( + text: note.previewText, + palette: palette, + style: TextStyle( + color: palette.textMain.withOpacity(0.9), + fontSize: 14, + height: 1.55, + fontWeight: FontWeight.w600, + ), + imageBuilder: (context, alt, url) => _MarkdownProtectedImage( + url: url, + alt: alt, + palette: palette, + workspaceApi: workspaceApi, + ), + ), + if (images.isNotEmpty) ...[ + const SizedBox(height: 12), + _ImageStrip( + title: '来源图片', + urls: images, + palette: palette, + workspaceApi: workspaceApi, + ), + ], + const SizedBox(height: 10), + Text( + formatRelativeTime(note.updatedAt ?? note.createdAt), + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } +} + +class _OptionsGrid extends StatelessWidget { + const _OptionsGrid({required this.options, required this.palette}); + + final List options; + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + const gap = 10.0; + final columns = constraints.maxWidth >= 680 ? 2 : 1; + final width = (constraints.maxWidth - gap * (columns - 1)) / columns; + return Wrap( + spacing: gap, + runSpacing: gap, + children: options + .map( + (option) => SizedBox( + width: width, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: palette.panel, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.panelBorder), + ), + child: MarkdownMathText( + text: option, + palette: palette, + style: TextStyle( + color: palette.textMain, + fontSize: 13, + height: 1.45, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _ImageStrip extends StatelessWidget { + const _ImageStrip({ + required this.title, + required this.urls, + required this.palette, + required this.workspaceApi, + }); + + final String title; + final List urls; + final AppThemePalette palette; + final WorkspaceApi workspaceApi; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: palette.panel, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.image_rounded, color: palette.primaryLight, size: 16), + const SizedBox(width: 6), + Text( + title, + style: TextStyle( + color: palette.textMain, + fontSize: 13, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(width: 8), + _MetaChip(label: '${urls.length} 张', palette: palette), + ], + ), + const SizedBox(height: 10), + Wrap( + spacing: 10, + runSpacing: 10, + children: urls + .map( + (url) => ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 400, + height: 300, + child: ProtectedImage( + url: _normaliseImageUrl(url), + loadBytes: workspaceApi.loadProtectedImage, + fit: BoxFit.cover, + loading: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.primary, + ), + ), + ), + error: Center( + child: Icon( + Icons.broken_image_rounded, + color: palette.textSub, + ), + ), + ), + ), + ), + ) + .toList(), + ), + ], + ), + ); + } +} + +class _MarkdownProtectedImage extends StatelessWidget { + const _MarkdownProtectedImage({ + required this.url, + required this.alt, + required this.palette, + required this.workspaceApi, + }); + + final String url; + final String alt; + final AppThemePalette palette; + final WorkspaceApi workspaceApi; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Container( + constraints: const BoxConstraints(maxHeight: 260), + width: double.infinity, + child: ProtectedImage( + url: _normaliseImageUrl(url), + loadBytes: workspaceApi.loadProtectedImage, + fit: BoxFit.contain, + loading: SizedBox( + height: 140, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.primary, + ), + ), + ), + ), + error: SizedBox( + height: 92, + child: Center( + child: Text( + alt.trim().isEmpty ? '图片加载失败' : alt.trim(), + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _LibraryCard extends StatelessWidget { + const _LibraryCard({required this.palette, required this.child}); + + final AppThemePalette palette; + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: palette.panelBorder), + ), + child: child, + ); + } +} + +class _MetaChip extends StatelessWidget { + const _MetaChip({ + required this.label, + required this.palette, + this.highlighted = false, + }); + + final String label; + final AppThemePalette palette; + final bool highlighted; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: highlighted ? palette.primary.withOpacity(0.18) : palette.chip, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: highlighted ? palette.primaryLight : palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ); + } +} + +class _ReviewStatusChip extends StatelessWidget { + const _ReviewStatusChip({required this.status, required this.palette}); + + final String status; + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + final tone = _ReviewStatusTone.fromStatus(status); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: tone.color.withOpacity(palette.isLight ? 0.14 : 0.18), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: tone.color.withOpacity(0.22)), + ), + child: Text( + status, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: tone.color, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + ); + } +} + +class _ReviewStatusTone { + const _ReviewStatusTone(this.color); + + final Color color; + + factory _ReviewStatusTone.fromStatus(String status) { + if (status.contains('待复习')) { + return const _ReviewStatusTone(Color(0xFFFFB020)); + } + if (status.contains('复习中')) { + return const _ReviewStatusTone(Color(0xFF58A6FF)); + } + if (status.contains('已掌握')) { + return const _ReviewStatusTone(Color(0xFF32D99C)); + } + if (status.contains('逾期') || status.contains('需')) { + return const _ReviewStatusTone(Color(0xFFFF6B6B)); + } + return const _ReviewStatusTone(Color(0xFFA796FF)); + } +} + +class _LibraryMessageState extends StatelessWidget { + const _LibraryMessageState({ + required this.palette, + required this.icon, + required this.title, + required this.message, + this.actionText, + this.onAction, + }); + + final AppThemePalette palette; + final IconData icon; + final String title; + final String message; + final String? actionText; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: palette.textSub, size: 34), + const SizedBox(height: 12), + Text( + title, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textSub, + fontWeight: FontWeight.w700, + ), + ), + if (actionText != null && onAction != null) ...[ + const SizedBox(height: 14), + ElevatedButton( + onPressed: onAction, + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + elevation: 0, + ), + child: Text(actionText!), + ), + ], + ], + ), + ), + ); + } +} + +List _imageUrls( + Iterable urls, { + Set excluded = const {}, +}) { + final seen = {}; + final result = []; + for (final raw in urls) { + final value = raw.trim(); + final normalized = _normaliseImageUrl(value); + if (value.isEmpty || + seen.contains(normalized) || + excluded.contains(normalized)) { + continue; + } + seen.add(normalized); + result.add(value); + } + return result; +} + +Set _embeddedImageUrls(String content) { + final urls = {}; + final htmlImagePattern = RegExp( + r"""]*\bsrc\s*=\s*(['"])(.*?)\1""", + caseSensitive: false, + dotAll: true, + ); + final markdownImagePattern = RegExp(r'!\[[^\]]*\]\(([^)]+)\)'); + + for (final match in htmlImagePattern.allMatches(content)) { + final value = match.group(2)?.trim(); + if (value != null && value.isNotEmpty) { + urls.add(_normaliseImageUrl(value)); + } + } + for (final match in markdownImagePattern.allMatches(content)) { + final value = match.group(1)?.trim(); + if (value != null && value.isNotEmpty) { + urls.add(_normaliseImageUrl(value)); + } + } + return urls; +} + +String _normaliseImageUrl(String url) { + final value = url.trim(); + if (value.startsWith('http://') || + value.startsWith('https://') || + value.startsWith('/')) { + return value; + } + if (value.contains('\\') || value.contains(':')) { + final filename = value.split(RegExp(r'[\\/]+')).last; + return filename.isEmpty ? value : '/api/image/$filename'; + } + return '/$value'; +} + +bool _hasValue(String? value) => value != null && value.trim().isNotEmpty; diff --git a/apps/mobile/lib/features/workspace/presentation/pages/profile_avatar.dart b/apps/mobile/lib/features/workspace/presentation/pages/profile_avatar.dart new file mode 100644 index 00000000..c958cdde --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/profile_avatar.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/widgets/protected_image.dart'; +import '../../../auth/data/auth_api.dart'; + +class ProfileAvatar extends StatelessWidget { + const ProfileAvatar({ + super.key, + required this.palette, + required this.authApi, + required this.avatarUrl, + required this.title, + required this.borderRadius, + required this.letterSize, + }); + + final AppThemePalette palette; + final AuthApi authApi; + final String? avatarUrl; + final String title; + final double borderRadius; + final double letterSize; + + @override + Widget build(BuildContext context) { + final imageUrl = avatarUrl; + final hasAvatar = imageUrl != null && imageUrl.isNotEmpty; + + return SizedBox.expand( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + gradient: hasAvatar + ? null + : LinearGradient( + colors: [palette.primary, palette.primaryLight], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + color: palette.panelBg, + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all(color: palette.panelBorder), + boxShadow: [ + BoxShadow( + color: palette.primary.withOpacity(0.22), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: hasAvatar + ? ProtectedImage( + url: imageUrl, + loadBytes: authApi.loadProtectedImage, + fit: BoxFit.cover, + loading: _AvatarLetter(title: title, fontSize: letterSize), + error: _AvatarLetter(title: title, fontSize: letterSize), + ) + : _AvatarLetter(title: title, fontSize: letterSize), + ), + ); + } +} + +class _AvatarLetter extends StatelessWidget { + const _AvatarLetter({required this.title, required this.fontSize}); + + final String title; + final double fontSize; + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + _avatarLetter(title), + style: TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: FontWeight.w900, + ), + ), + ); + } + + static String _avatarLetter(String title) { + final trimmed = title.trim(); + if (trimmed.isEmpty) { + return '我'; + } + return trimmed.characters.first.toUpperCase(); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/profile_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/profile_page.dart new file mode 100644 index 00000000..9110240f --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/profile_page.dart @@ -0,0 +1,437 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../app/router/app_router.dart'; +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../auth/data/auth_api.dart'; +import 'profile_avatar.dart'; +import 'profile_settings_page.dart'; + +class ProfilePage extends StatefulWidget { + const ProfilePage({ + super.key, + required this.authApi, + required this.themeModeListenable, + required this.onToggleThemeMode, + }); + + final AuthApi authApi; + final ValueListenable themeModeListenable; + final VoidCallback onToggleThemeMode; + + @override + State createState() => _ProfilePageState(); +} + +class _ProfilePageState extends State { + late Future _userFuture; + bool _isLoggingOut = false; + + @override + void initState() { + super.initState(); + _userFuture = widget.authApi.me(); + } + + Future _logout() async { + if (_isLoggingOut) { + return; + } + + setState(() => _isLoggingOut = true); + + try { + await widget.authApi.logout(); + } catch (_) { + await widget.authApi.clearStoredSession(); + } + + if (!mounted) { + return; + } + + Navigator.of( + context, + ).pushNamedAndRemoveUntil(AppRoutes.login, (route) => false); + } + + Future _openSettings(AuthUser? user) async { + if (user == null) { + showAppSnackBar(context, '用户信息加载完成后再修改资料'); + return; + } + + final changed = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + ProfileSettingsPage(authApi: widget.authApi, initialUser: user), + ), + ); + + if (changed == true && mounted) { + setState(() { + _userFuture = widget.authApi.me(); + }); + } + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return FutureBuilder( + future: _userFuture, + builder: (context, snapshot) { + final user = snapshot.data; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '我的', + style: TextStyle( + color: palette.textMain, + fontWeight: FontWeight.w800, + fontSize: 22, + ), + ), + const SizedBox(height: 16), + _ProfileHeader( + palette: palette, + authApi: widget.authApi, + user: user, + isLoading: snapshot.connectionState != ConnectionState.done, + hasError: snapshot.hasError, + ), + const SizedBox(height: 16), + _SettingsSection( + palette: palette, + themeModeListenable: widget.themeModeListenable, + onToggleThemeMode: widget.onToggleThemeMode, + onOpenSettings: () => _openSettings(user), + onLogout: _logout, + isLoggingOut: _isLoggingOut, + ), + ], + ); + }, + ); + } +} + +class _ProfileHeader extends StatelessWidget { + const _ProfileHeader({ + required this.palette, + required this.authApi, + required this.user, + required this.isLoading, + required this.hasError, + }); + + final AppThemePalette palette; + final AuthApi authApi; + final AuthUser? user; + final bool isLoading; + final bool hasError; + + @override + Widget build(BuildContext context) { + final title = user?.displayName?.trim().isNotEmpty == true + ? user!.displayName!.trim() + : user?.username ?? (isLoading ? '同步用户信息' : '未登录'); + final accountState = hasError ? '用户信息加载失败' : _accountStateText(user); + final quotaText = _quotaText(user?.quota); + + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + children: [ + SizedBox( + width: 52, + height: 52, + child: ProfileAvatar( + palette: palette, + authApi: authApi, + avatarUrl: user?.avatarUrl, + title: title, + borderRadius: 18, + letterSize: 20, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + ), + if (user?.isAdmin == true) ...[ + const SizedBox(width: 8), + _AdminBadge(palette: palette), + ], + ], + ), + const SizedBox(height: 6), + Text( + accountState, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + quotaText, + style: TextStyle( + color: palette.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ); + } + + static String _quotaText(Map? quota) { + if (quota == null || quota.isEmpty) { + return '额度信息待同步'; + } + + final remaining = quota['remaining'] ?? + quota['remaining_today'] ?? + quota['daily_remaining'] ?? + quota['left']; + final total = quota['total'] ?? quota['limit'] ?? quota['daily_limit']; + + if (remaining != null && total != null) { + return '今日剩余 $remaining / $total 次'; + } + + if (remaining != null) { + return '今日剩余 $remaining 次'; + } + + return '额度信息待同步'; + } + + static String _accountStateText(AuthUser? user) { + if (user == null) { + return '正在读取账号信息'; + } + + return '@${user.username}'; + } +} + +class _AdminBadge extends StatelessWidget { + const _AdminBadge({required this.palette}); + + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.14), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Admin', + style: TextStyle( + color: palette.primaryLight, + fontSize: 11, + fontWeight: FontWeight.w900, + ), + ), + ); + } +} + +class _SettingsSection extends StatelessWidget { + const _SettingsSection({ + required this.palette, + required this.themeModeListenable, + required this.onToggleThemeMode, + required this.onOpenSettings, + required this.onLogout, + required this.isLoggingOut, + }); + + final AppThemePalette palette; + final ValueListenable themeModeListenable; + final VoidCallback onToggleThemeMode; + final VoidCallback onOpenSettings; + final VoidCallback onLogout; + final bool isLoggingOut; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + '设置', + style: TextStyle( + color: palette.textSub, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ), + ValueListenableBuilder( + valueListenable: themeModeListenable, + builder: (context, themeMode, _) { + final isDark = themeMode == ThemeMode.dark; + return _ProfileActionTile( + palette: palette, + icon: + isDark ? Icons.light_mode_rounded : Icons.dark_mode_rounded, + title: isDark ? '切换为日间模式' : '切换为夜间模式', + subtitle: isDark ? '当前为夜间模式' : '当前为日间模式', + onTap: onToggleThemeMode, + ); + }, + ), + _ProfileActionTile( + palette: palette, + icon: Icons.person_rounded, + title: '用户资料设置', + subtitle: '昵称和头像管理', + onTap: onOpenSettings, + ), + _ProfileActionTile( + palette: palette, + icon: Icons.logout_rounded, + title: isLoggingOut ? '正在退出' : '退出登录', + subtitle: '清除当前账号登录状态', + isDanger: true, + trailing: isLoggingOut + ? SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.errorText, + ), + ) + : null, + onTap: isLoggingOut ? null : onLogout, + ), + ], + ), + ); + } +} + +class _ProfileActionTile extends StatelessWidget { + const _ProfileActionTile({ + required this.palette, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + this.isDanger = false, + this.trailing, + }); + + final AppThemePalette palette; + final IconData icon; + final String title; + final String subtitle; + final VoidCallback? onTap; + final bool isDanger; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final titleColor = isDanger ? palette.errorText : palette.textMain; + final iconColor = isDanger ? palette.errorText : palette.primaryLight; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: iconColor.withOpacity(palette.isLight ? 0.12 : 0.16), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, color: iconColor, size: 20), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: titleColor, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + trailing ?? + Icon( + Icons.chevron_right_rounded, + color: palette.textSub, + size: 22, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/profile_settings_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/profile_settings_page.dart new file mode 100644 index 00000000..64a2994a --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/profile_settings_page.dart @@ -0,0 +1,603 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../auth/data/auth_api.dart'; +import 'profile_avatar.dart'; + +class ProfileSettingsPage extends StatefulWidget { + const ProfileSettingsPage({ + super.key, + required this.authApi, + required this.initialUser, + }); + + final AuthApi authApi; + final AuthUser initialUser; + + @override + State createState() => _ProfileSettingsPageState(); +} + +class _ProfileSettingsPageState extends State { + late AuthUser _user; + late final TextEditingController _displayNameController; + late final TextEditingController _nicknameController; + + bool _isSaving = false; + bool _isUploadingAvatar = false; + bool _isDeletingAvatar = false; + bool _hasChanges = false; + + @override + void initState() { + super.initState(); + _user = widget.initialUser; + _displayNameController = TextEditingController( + text: _user.displayName ?? _user.username, + ); + _nicknameController = TextEditingController(text: _user.nickname ?? ''); + } + + @override + void dispose() { + _displayNameController.dispose(); + _nicknameController.dispose(); + super.dispose(); + } + + Future _reloadUser() async { + final user = await widget.authApi.me(); + if (!mounted) { + return; + } + setState(() { + _user = user; + _displayNameController.text = user.displayName ?? user.username; + _nicknameController.text = user.nickname ?? ''; + }); + } + + Future _pickAvatar() async { + if (_isUploadingAvatar) { + return; + } + + FilePickerResult? result; + try { + result = await FilePicker.pickFiles( + allowMultiple: false, + withData: true, + type: FileType.custom, + allowedExtensions: const ['png', 'jpg', 'jpeg', 'webp', 'bmp'], + ); + } catch (error) { + _showMessage('选择头像失败:$error'); + return; + } + + final file = + result == null || result.files.isEmpty ? null : result.files.first; + final bytes = file?.bytes; + if (file == null || bytes == null || bytes.isEmpty) { + return; + } + + if (bytes.length > 5 * 1024 * 1024) { + _showMessage('头像不能超过 5MB'); + return; + } + + setState(() => _isUploadingAvatar = true); + try { + final response = await widget.authApi.uploadAvatar( + filename: file.name, + bytes: bytes, + ); + await _reloadUser(); + _hasChanges = true; + _showMessage(response.message); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (error) { + _showMessage('头像上传失败:$error'); + } finally { + if (mounted) { + setState(() => _isUploadingAvatar = false); + } + } + } + + Future _deleteAvatar() async { + if (_isDeletingAvatar || (_user.avatarUrl ?? '').isEmpty) { + return; + } + + setState(() => _isDeletingAvatar = true); + try { + final response = await widget.authApi.deleteAvatar(); + await _reloadUser(); + _hasChanges = true; + _showMessage(response.message); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (error) { + _showMessage('删除头像失败:$error'); + } finally { + if (mounted) { + setState(() => _isDeletingAvatar = false); + } + } + } + + Future _saveProfile() async { + if (_isSaving) { + return; + } + + final displayName = _displayNameController.text.trim(); + final nickname = _nicknameController.text.trim(); + + setState(() => _isSaving = true); + try { + final response = await widget.authApi.updateProfile( + displayName: displayName, + nickname: nickname, + ); + await _reloadUser(); + _hasChanges = true; + _showMessage(response.message); + } on ApiException catch (error) { + _showMessage(error.message); + } catch (error) { + _showMessage('保存失败:$error'); + } finally { + if (mounted) { + setState(() => _isSaving = false); + } + } + } + + void _showMessage(String message) { + if (!mounted) { + return; + } + + showAppSnackBar(context, message); + } + + void _close() { + Navigator.of(context).pop(_hasChanges); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + backgroundColor: palette.pageBg, + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 28), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildHeader(palette), + const SizedBox(height: 12), + _AvatarEditor( + palette: palette, + user: _user, + authApi: widget.authApi, + isUploading: _isUploadingAvatar, + isDeleting: _isDeletingAvatar, + onPickAvatar: _pickAvatar, + onDeleteAvatar: _deleteAvatar, + ), + const SizedBox(height: 28), + Text( + '账户信息', + style: TextStyle( + color: palette.textMain, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 14), + _buildAccountCard(palette), + const SizedBox(height: 28), + Center( + child: SizedBox( + width: 400, + child: ElevatedButton( + onPressed: _isSaving ? null : _saveProfile, + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + disabledBackgroundColor: palette.primary.withOpacity(0.4), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 17), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + ), + child: _isSaving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text( + '保存更改', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeader(AppThemePalette palette) { + return Row( + children: [ + IconButton( + onPressed: _close, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + tooltip: '返回', + ), + Expanded( + child: Text( + '用户资料设置', + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textMain, + fontSize: 17, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 48), + ], + ); + } + + Widget _buildAccountCard(AppThemePalette palette) { + return Container( + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 4), + child: Column( + children: [ + _ReadOnlyAccountRow( + palette: palette, + title: '用户名', + subtitle: '您的唯一登录凭证', + value: '@${_user.username}', + ), + _Divider(palette: palette), + _EditableAccountRow( + palette: palette, + title: '显示名称', + subtitle: '用于在应用中展示的主要名称', + controller: _displayNameController, + hintText: '例如:Admin', + ), + _Divider(palette: palette), + _EditableAccountRow( + palette: palette, + title: '当前昵称', + subtitle: '可选的个性化称呼', + controller: _nicknameController, + hintText: '例如:数学冲刺版', + ), + ], + ), + ); + } +} + +class _AvatarEditor extends StatelessWidget { + const _AvatarEditor({ + required this.palette, + required this.user, + required this.authApi, + required this.isUploading, + required this.isDeleting, + required this.onPickAvatar, + required this.onDeleteAvatar, + }); + + final AppThemePalette palette; + final AuthUser user; + final AuthApi authApi; + final bool isUploading; + final bool isDeleting; + final VoidCallback onPickAvatar; + final VoidCallback onDeleteAvatar; + + @override + Widget build(BuildContext context) { + final hasAvatar = (user.avatarUrl ?? '').isNotEmpty; + final title = user.displayName?.trim().isNotEmpty == true + ? user.displayName!.trim() + : user.username; + + return Column( + children: [ + SizedBox( + width: 132, + height: 132, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: GestureDetector( + onTap: isUploading ? null : onPickAvatar, + child: ProfileAvatar( + palette: palette, + authApi: authApi, + avatarUrl: user.avatarUrl, + title: title, + borderRadius: 36, + letterSize: 34, + ), + ), + ), + if (isUploading) + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(36), + child: Container( + color: Colors.black.withOpacity(0.28), + child: const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + ), + ), + ), + Positioned( + right: -2, + bottom: -2, + child: Tooltip( + message: '更换头像', + child: Material( + color: palette.primary, + shape: const CircleBorder(), + elevation: 0, + child: InkWell( + customBorder: const CircleBorder(), + onTap: isUploading ? null : onPickAvatar, + child: const SizedBox( + width: 42, + height: 42, + child: Icon( + Icons.edit_rounded, + color: Colors.white, + size: 20, + ), + ), + ), + ), + ), + ), + ], + ), + ), + if (hasAvatar) ...[ + const SizedBox(height: 14), + TextButton.icon( + onPressed: isDeleting ? null : onDeleteAvatar, + icon: isDeleting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: palette.errorText, + ), + ) + : const Icon(Icons.close_rounded, size: 18), + label: const Text('移除头像'), + style: TextButton.styleFrom( + foregroundColor: palette.errorText, + textStyle: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ], + ], + ); + } +} + +class _ReadOnlyAccountRow extends StatelessWidget { + const _ReadOnlyAccountRow({ + required this.palette, + required this.title, + required this.subtitle, + required this.value, + }); + + final AppThemePalette palette; + final String title; + final String subtitle; + final String value; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: Row( + children: [ + Expanded( + child: _AccountRowLabel( + palette: palette, + title: title, + subtitle: subtitle, + ), + ), + const SizedBox(width: 16), + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ); + } +} + +class _EditableAccountRow extends StatelessWidget { + const _EditableAccountRow({ + required this.palette, + required this.title, + required this.subtitle, + required this.controller, + required this.hintText, + }); + + final AppThemePalette palette; + final String title; + final String subtitle; + final TextEditingController controller; + final String hintText; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 560; + final label = _AccountRowLabel( + palette: palette, + title: title, + subtitle: subtitle, + ); + final input = _buildInput(); + + if (compact) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [label, const SizedBox(height: 10), input], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: label), + const SizedBox(width: 18), + SizedBox(width: 260, child: input), + ], + ); + }, + ), + ); + } + + Widget _buildInput() { + return TextField( + controller: controller, + style: TextStyle( + color: palette.textMain, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle(color: palette.textSub), + filled: true, + fillColor: palette.panelBg, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: BorderSide(color: palette.panelBorder), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(13), + borderSide: BorderSide(color: palette.primary), + ), + ), + ); + } +} + +class _AccountRowLabel extends StatelessWidget { + const _AccountRowLabel({ + required this.palette, + required this.title, + required this.subtitle, + }); + + final AppThemePalette palette; + final String title; + final String subtitle; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 5), + Text( + subtitle, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider({required this.palette}); + + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return Divider(height: 1, color: palette.panelBorder); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/smart_input_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/smart_input_page.dart new file mode 100644 index 00000000..d2f02acf --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/smart_input_page.dart @@ -0,0 +1,1806 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../data/workspace_api.dart'; +import 'split_history_page.dart'; +import 'smart_input_process_page.dart'; + +import '../../../../core/network/api_client.dart'; + +class SmartInputPage extends StatefulWidget { + const SmartInputPage({super.key}); + + @override + State createState() => _SmartInputPageState(); +} + +class _SmartInputPageState extends State { + _SmartInputMode _mode = _SmartInputMode.examSplit; + bool _eraseHandwriting = true; + String? _selectedModelOptionId; + _ModelSource? _selectedModelSource; + bool _isUploading = false; + final List<_UploadedFile> _files = []; + bool _isLoadingModels = true; + String? _modelError; + final Set _deletingFileKeys = {}; + int _uploadGeneration = 0; + + late final WorkspaceApi _workspaceApi; + final List _hostedModels = []; + final List _selfModels = []; + + List get _availableHostOptions => + _hostedModels.where((item) => item.configured).toList(); + + List get _availableSelfOptions => + _selfModels.where((item) => item.configured).toList(); + + @override + void initState() { + super.initState(); + _workspaceApi = WorkspaceApi(); + _fetchModelOptions(); + } + + @override + void dispose() { + final filesToCancel = List<_UploadedFile>.of(_files); + _files.clear(); + _deletingFileKeys.clear(); + if (filesToCancel.isNotEmpty) { + unawaited(_cancelUploadedFiles(filesToCancel)); + } + super.dispose(); + } + + Future _fetchModelOptions() async { + setState(() { + _isLoadingModels = true; + _modelError = null; + }); + + try { + final response = await _workspaceApi.getModelOptions(); + if (!mounted) { + return; + } + + final available = + response.options.where((option) => option.configured).toList(); + final hosted = available + .where((option) => option.isHostedSource) + .toList(growable: false); + final self = available + .where((option) => option.isSelfSource) + .toList(growable: false); + final fallback = available + .where((option) => !option.isHostedSource && !option.isSelfSource) + .toList(growable: false); + if (fallback.isNotEmpty) { + self.addAll(fallback); + } + + final defaultOptionId = response.defaultOptionId; + WorkspaceModelOption? initialOption = defaultOptionId == null + ? null + : _findOptionById(available, defaultOptionId); + + initialOption ??= _findDefaultOrFirstOption(available); + + if (initialOption == null) { + _hostedModels.clear(); + _selfModels.clear(); + _selectedModelOptionId = null; + _selectedModelSource = null; + } else { + _hostedModels + ..clear() + ..addAll(hosted); + _selfModels + ..clear() + ..addAll(self); + + _selectedModelOptionId = initialOption.optionId; + _selectedModelSource = initialOption.isHostedSource + ? _ModelSource.hosted + : _ModelSource.self; + } + } on ApiException catch (error) { + if (!mounted) { + return; + } + _hostedModels.clear(); + _selfModels.clear(); + _selectedModelOptionId = null; + _selectedModelSource = null; + _modelError = error.message; + } catch (_) { + if (!mounted) { + return; + } + _hostedModels.clear(); + _selfModels.clear(); + _selectedModelOptionId = null; + _selectedModelSource = null; + _modelError = '模型列表加载失败,请稍后再试'; + } finally { + if (mounted) { + setState(() => _isLoadingModels = false); + } + } + } + + Future _openSplitHistory() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SplitHistoryPage(workspaceApi: _workspaceApi), + ), + ); + } + + WorkspaceModelOption? _findOptionById( + List options, + String optionId, + ) { + for (final option in options) { + if (option.optionId == optionId) { + return option; + } + } + return null; + } + + WorkspaceModelOption? _findDefaultOrFirstOption( + List options, + ) { + for (final option in options) { + if (option.isDefault) { + return option; + } + } + + if (options.isNotEmpty) { + return options.first; + } + + return null; + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + final isLight = palette.isLight; + final pageText = palette.textMain; + final helperText = palette.textSub; + final border = palette.panelBorder; + final panelBg = palette.panelBg; + final cardBg = palette.cardBg; + + final steps = _steps; + final titles = _mode == _SmartInputMode.examSplit + ? ('智能录入与分析工作台', _examDescription) + : ('智能笔记整理工作台', _noteDescription); + + return Stack( + children: [ + Positioned.fill( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1100), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildTopBar( + isLight: isLight, + titleColor: pageText, + helperText: helperText, + border: border, + panelBg: panelBg, + ), + const SizedBox(height: 14), + _buildModeAndEraseSwitch( + pageText: pageText, + border: border, + isLight: isLight, + panelBg: panelBg, + palette: palette, + ), + const SizedBox(height: 14), + _buildStepper( + steps: steps, + isLight: isLight, + pageText: pageText, + helperText: helperText, + border: border, + panelBg: panelBg, + ), + const SizedBox(height: 32), + _buildHeroTitle( + title: titles.$1, + description: titles.$2, + pageText: pageText, + helperText: helperText, + ), + const SizedBox(height: 14), + _buildFeatureCards( + cards: _mode == _SmartInputMode.examSplit + ? _examFeatureCards + : _noteFeatureCards, + isLight: isLight, + pageText: pageText, + helperText: helperText, + border: border, + panelBg: panelBg, + ), + const SizedBox(height: 14), + _buildUploadPanel( + isLight: isLight, + helperText: helperText, + panelBg: panelBg, + border: border, + onTap: _pickAndUploadFiles, + ), + const SizedBox(height: 12), + if (_files.isNotEmpty) + _buildFileCards( + files: _files, + cardBg: cardBg, + border: border, + helperText: helperText, + onRemove: _removeFile, + ), + const SizedBox(height: 12), + _buildPrimaryActionButton( + isLight: isLight, + helperText: helperText, + border: border, + panelBg: panelBg, + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildTopBar({ + required bool isLight, + required Color titleColor, + required Color helperText, + required Color border, + required Color panelBg, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxWidth < 460; + + final selector = _ModelSelector( + isLight: isLight, + isLoading: _isLoadingModels, + selectedModelId: _selectedModelOptionId, + selectedModelName: _selectedModelDisplayName, + selectedSource: _selectedModelSource, + hosted: _availableHostOptions, + self: _availableSelfOptions, + modelError: _modelError, + panelBg: panelBg, + border: border, + onPickHosted: (option) { + setState(() { + _selectedModelOptionId = option.optionId; + _selectedModelSource = _ModelSource.hosted; + }); + }, + onPickSelf: (option) { + setState(() { + _selectedModelOptionId = option.optionId; + _selectedModelSource = _ModelSource.self; + }); + }, + onPickSettings: () { + _showHint('API 设置入口预留'); + }, + onPickEmpty: () { + _showHint('暂无可用模型'); + }, + ); + + if (compact) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + '智能录入与分析', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: titleColor, + fontWeight: FontWeight.w800, + fontSize: 22, + ), + ), + ), + _SplitHistoryButton( + isLight: isLight, + panelBg: panelBg, + border: border, + helperText: helperText, + compact: true, + onTap: _openSplitHistory, + ), + const SizedBox(width: 8), + selector, + ], + ); + } + + return Row( + children: [ + Text( + '智能录入与分析', + style: TextStyle( + color: titleColor, + fontWeight: FontWeight.w800, + fontSize: 22, + ), + ), + const Spacer(), + _SplitHistoryButton( + isLight: isLight, + panelBg: panelBg, + border: border, + helperText: helperText, + compact: false, + onTap: _openSplitHistory, + ), + const SizedBox(width: 10), + selector, + ], + ); + }, + ); + } + + Widget _buildStepper({ + required List steps, + required bool isLight, + required Color pageText, + required Color helperText, + required Color border, + required Color panelBg, + }) { + final active = _files.isNotEmpty ? 0 : -1; + + return Container( + decoration: BoxDecoration( + color: panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: List.generate(steps.length, (index) { + final isActive = index == active; + final isDone = index < active; + final hasPassed = index < active; + final isLast = index == steps.length - 1; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StepNode( + index: index + 1, + label: steps[index], + isActive: isActive, + isDone: isDone, + isLight: isLight, + pageText: pageText, + helperText: helperText, + ), + if (!isLast) ...[ + const SizedBox(width: 8), + Container( + width: 18, + height: 1, + color: hasPassed + ? AppTheme.primary.withOpacity(0.7) + : helperText.withOpacity(0.3), + ), + ], + ], + ); + }), + ), + ); + } + + Widget _buildModeAndEraseSwitch({ + required Color pageText, + required Color border, + required bool isLight, + required Color panelBg, + required AppThemePalette palette, + }) { + final Color selectedBg = isLight + ? AppTheme.primary.withOpacity(0.14) + : AppTheme.primary.withOpacity(0.9); + + final Color selectedText = isLight ? AppTheme.primary : Colors.white; + + final Color inactiveText = + isLight ? pageText.withOpacity(0.5) : pageText.withOpacity(0.42); + + final Color dividerColor = border.withOpacity(isLight ? 0.45 : 0.35); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: 42, + decoration: BoxDecoration( + color: panelBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: border, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildModeTab( + mode: _SmartInputMode.examSplit, + label: '试卷分割', + icon: Icons.description_rounded, + selectedBg: selectedBg, + selectedText: selectedText, + inactiveText: inactiveText, + isLight: isLight, + ), + const SizedBox(width: 3), + _buildModeTab( + mode: _SmartInputMode.noteOrganize, + label: '笔记整理', + icon: Icons.menu_book_rounded, + selectedBg: selectedBg, + selectedText: selectedText, + inactiveText: inactiveText, + isLight: isLight, + ), + ], + ), + ), + if (_canEraseHandwriting) ...[ + const SizedBox(width: 14), + Container(width: 1, height: 22, color: dividerColor), + const SizedBox(width: 14), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildCompactSwitch( + value: _eraseHandwriting, + palette: palette, + onChanged: (value) { + setState(() { + _eraseHandwriting = value; + }); + }, + ), + const SizedBox(width: 8), + Text( + '擦除笔迹', + style: TextStyle( + color: _eraseHandwriting + ? pageText.withOpacity(0.78) + : pageText.withOpacity(0.48), + fontSize: 13, + fontWeight: FontWeight.w600, + height: 1, + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildCompactSwitch({ + required bool value, + required AppThemePalette palette, + required ValueChanged onChanged, + }) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(!value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + width: 34, + height: 20, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: value ? AppTheme.primary : palette.controlInactiveBg, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: value + ? AppTheme.primary.withOpacity(0.85) + : palette.subtleOverlay, + width: 1, + ), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.22), + blurRadius: 4, + offset: const Offset(0, 1), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildModeTab({ + required _SmartInputMode mode, + required String label, + required IconData icon, + required Color selectedBg, + required Color selectedText, + required Color inactiveText, + required bool isLight, + }) { + final bool selected = _mode == mode; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(6), + onTap: () => _switchMode(mode), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 13), + decoration: BoxDecoration( + color: selected ? selectedBg : Colors.transparent, + borderRadius: BorderRadius.circular(6), + boxShadow: selected && !isLight + ? [ + BoxShadow( + color: AppTheme.primary.withOpacity(0.28), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 15, + color: selected ? selectedText : inactiveText, + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + color: selected ? selectedText : inactiveText, + fontSize: 13, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeroTitle({ + required String title, + required String description, + required Color pageText, + required Color helperText, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + children: [ + TextSpan( + text: title.split('工作台').first, + style: TextStyle( + color: pageText, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + ), + const TextSpan( + text: '工作台', + style: TextStyle( + color: AppTheme.primary, + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ), + const SizedBox(height: 10), + Text( + description, + style: TextStyle(color: helperText, fontSize: 14, height: 1.45), + ), + ], + ); + } + + Widget _buildFeatureCards({ + required List<_FeatureItem> cards, + required bool isLight, + required Color pageText, + required Color helperText, + required Color panelBg, + required Color border, + }) { + return LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + + final int crossAxisCount = width >= 900 ? 4 : 2; + + const double gap = 12; + final double itemWidth = + (width - gap * (crossAxisCount - 1)) / crossAxisCount; + + return Wrap( + spacing: gap, + runSpacing: gap, + children: cards.map((card) { + return SizedBox( + width: itemWidth, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(14), + constraints: const BoxConstraints(minHeight: 108), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: border), + color: panelBg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: AppTheme.primary.withOpacity( + isLight ? 0.13 : 0.22, + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppTheme.primary.withOpacity( + isLight ? 0.08 : 0.18, + ), + ), + ), + child: Icon( + card.icon, + size: 18, + color: + isLight ? AppTheme.primary : AppTheme.primaryLight, + ), + ), + const SizedBox(height: 12), + Text( + card.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: pageText, + fontSize: width < 360 ? 13 : 14, + fontWeight: FontWeight.w800, + height: 1.25, + ), + ), + ], + ), + ), + ); + }).toList(), + ); + }, + ); + } + + Widget _buildUploadPanel({ + required bool isLight, + required Color helperText, + required Color panelBg, + required Color border, + required Future Function() onTap, + }) { + final canPick = !_isUploading && _hasSelectedModel; + final disabledText = _isUploading ? '正在上传...' : '当前暂无可用模型,上传功能暂时不可用'; + + return InkWell( + onTap: canPick ? () => onTap() : null, + borderRadius: BorderRadius.circular(14), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: 170, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: border), + color: panelBg, + ), + child: Center( + child: canPick + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.cloud_upload_rounded, + size: 40, + color: isLight ? AppTheme.primary : AppTheme.primaryLight, + ), + const SizedBox(height: 10), + Text( + '点击上传文件', + style: TextStyle(color: helperText, fontSize: 16), + ), + const SizedBox(height: 2), + Text( + '浏览文件', + style: TextStyle( + color: + isLight ? AppTheme.primary : AppTheme.primaryLight, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'PDF, PNG, JPG', + style: TextStyle(color: helperText, fontSize: 12), + ), + ], + ) + : Padding( + padding: const EdgeInsets.all(16), + child: Text( + disabledText, + textAlign: TextAlign.center, + style: TextStyle(color: helperText, fontSize: 14), + ), + ), + ), + ), + ); + } + + Widget _buildFileCards({ + required List<_UploadedFile> files, + required Color cardBg, + required Color border, + required Color helperText, + required Future Function(_UploadedFile) onRemove, + }) { + final palette = AppThemePalette.of(context); + return Wrap( + spacing: 10, + runSpacing: 10, + children: files + .map( + (file) => Container( + width: 280, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: border), + ), + child: Row( + children: [ + if (_deletingFileKeys.contains(file.fileKey)) + Padding( + padding: const EdgeInsets.only(left: 4), + child: const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + else + Icon(_iconForFile(file.name), color: AppTheme.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + file.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + LinearProgressIndicator( + value: file.progress / 100, + minHeight: 4, + backgroundColor: palette.progressTrack, + valueColor: const AlwaysStoppedAnimation( + AppTheme.primary, + ), + ), + const SizedBox(height: 4), + Text( + '${file.progress.toInt()}%', + style: TextStyle(color: helperText, fontSize: 11), + ), + ], + ), + ), + IconButton( + tooltip: '删除', + iconSize: 18, + icon: const Icon(Icons.close_rounded), + onPressed: _deletingFileKeys.contains(file.fileKey) || + file.fileKey.isEmpty + ? null + : () => onRemove(file), + color: helperText, + ), + ], + ), + ), + ) + .toList(), + ); + } + + Widget _buildPrimaryActionButton({ + required bool isLight, + required Color helperText, + required Color border, + required Color panelBg, + }) { + final canStart = _hasSelectedModel && _files.isNotEmpty; + + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TextButton( + onPressed: canStart ? _onPrimaryAction : null, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + backgroundColor: panelBg, + disabledBackgroundColor: panelBg, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + side: BorderSide(color: border), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: canStart + ? DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppTheme.primary, AppTheme.primaryLight], + ), + ), + child: Container( + alignment: Alignment.center, + height: 50, + width: double.infinity, + child: _buildPrimaryActionLabel(active: true), + ), + ) + : Container( + alignment: Alignment.center, + height: 50, + width: double.infinity, + child: _buildPrimaryActionLabel(active: false), + ), + ), + ), + ], + ); + } + + Widget _buildPrimaryActionLabel({required bool active}) { + return Text( + _primaryButtonLabel, + style: TextStyle( + color: active ? Colors.white : AppThemePalette.of(context).textSub, + fontWeight: FontWeight.w700, + ), + ); + } + + Future _onPrimaryAction() async { + if (!_hasSelectedModel) { + _showHint('当前暂无可用模型,请先完成 API 设置'); + return; + } + if (_files.isEmpty) { + _showHint('请先上传文件'); + return; + } + + if (!mounted) { + return; + } + + final finished = await Navigator.of(context).push( + MaterialPageRoute( + builder: (routeContext) => SmartInputProcessPage( + files: _files + .map( + (item) => SmartInputUploadedFile( + fileKey: item.fileKey, + name: item.name, + bytes: item.bytes, + ), + ) + .toList(growable: false), + enableQuestionSplit: _mode == _SmartInputMode.examSplit, + splitRequest: _buildSplitRequest(), + initialEraseHandwriting: _effectiveEraseHandwriting, + onConfirmSplit: () { + Navigator.of(routeContext).pop(true); + }, + ), + ), + ); + + if (!mounted) { + return; + } + + if (_files.isNotEmpty) { + setState(_clearLocalFiles); + } + + if (finished == true) { + _showHint(_mode == _SmartInputMode.noteOrganize ? '笔记整理已完成' : '分割已完成'); + } + } + + Future _pickAndUploadFiles() async { + if (!_hasSelectedModel) { + _showHint('请先在模型配置完成后再上传'); + return; + } + + FilePickerResult? result; + try { + result = await FilePicker.pickFiles( + allowMultiple: true, + withData: true, + type: FileType.custom, + allowedExtensions: const ['pdf', 'png', 'jpg', 'jpeg'], + ); + } catch (error) { + _showHint('选择文件失败:$error'); + return; + } + + if (result == null || result.files.isEmpty) { + return; + } + + final selectedFiles = result.files + .where((file) => file.bytes != null && file.bytes!.isNotEmpty) + .toList(); + + if (selectedFiles.isEmpty) { + _showHint('未检测到可上传文件内容'); + return; + } + + final uploadGeneration = _uploadGeneration; + + setState(() { + _isUploading = true; + }); + + try { + final uploadItems = selectedFiles + .map( + (file) => UploadFileItem(filename: file.name, bytes: file.bytes!), + ) + .toList(); + + final response = await _workspaceApi.uploadFiles( + files: uploadItems, + resetSession: _files.isEmpty, + ); + + if (!mounted || uploadGeneration != _uploadGeneration) { + return; + } + + if (!response.success) { + _showHint(response.message); + return; + } + + for (var i = 0; i < response.result.files.length; i++) { + final item = response.result.files[i]; + final uploadItem = + uploadItems[i < uploadItems.length ? i : uploadItems.length - 1]; + _files.add( + _UploadedFile( + name: item.filename, + fileKey: item.fileKey, + bytes: uploadItem.bytes, + progress: 100, + ), + ); + } + + _showHint( + response.result.files.length == 1 + ? '上传成功:${response.result.files.first.filename}' + : '上传成功:${response.result.files.length} 个文件', + ); + } on ApiException catch (error) { + if (!mounted) { + return; + } + _showHint(error.message); + } catch (_) { + if (!mounted) { + return; + } + _showHint('上传失败,请稍后重试'); + } finally { + if (mounted) { + setState(() { + _isUploading = false; + }); + } + } + } + + IconData _iconForFile(String name) { + final lower = name.toLowerCase(); + if (lower.endsWith('.pdf')) { + return Icons.picture_as_pdf_outlined; + } + if (lower.endsWith('.png') || lower.endsWith('.jpg')) { + return Icons.image_outlined; + } + return Icons.insert_drive_file_outlined; + } + + Future _removeFile(_UploadedFile target) async { + if (_deletingFileKeys.contains(target.fileKey)) { + return; + } + + if (target.fileKey.isEmpty) { + setState(() => _files.remove(target)); + return; + } + + setState(() { + _deletingFileKeys.add(target.fileKey); + }); + + try { + final response = await _workspaceApi.cancelUploadedFile( + fileKey: target.fileKey, + ); + + if (!mounted) { + return; + } + + if (response.success) { + setState(() { + _files.removeWhere((item) => item.fileKey == target.fileKey); + }); + _showHint('已移除:${target.name}'); + } else { + _showHint(response.message); + } + } on ApiException catch (error) { + if (!mounted) { + return; + } + _showHint(error.message); + } catch (_) { + if (!mounted) { + return; + } + _showHint('撤销失败,请稍后重试'); + } finally { + if (mounted) { + setState(() { + _deletingFileKeys.remove(target.fileKey); + }); + } + } + } + + void _showHint(String message) { + if (!mounted) { + return; + } + showAppSnackBar(context, message); + } + + bool get _hasSelectedModel => + _selectedModelOptionId != null && _selectedModelDisplayName != null; + + WorkspaceModelOption? get _selectedModelOption { + if (_selectedModelOptionId == null) { + return null; + } + + return _findOptionById([ + ..._availableHostOptions, + ..._availableSelfOptions, + ], _selectedModelOptionId!); + } + + String? get _selectedModelDisplayName { + return _selectedModelOption?.displayName; + } + + void _switchMode(_SmartInputMode mode) { + if (_mode == mode) { + return; + } + + final filesToCancel = List<_UploadedFile>.of(_files); + setState(() { + _mode = mode; + if (_mode == _SmartInputMode.noteOrganize) { + _eraseHandwriting = false; + } + _clearLocalFiles(); + }); + + if (filesToCancel.isNotEmpty) { + unawaited(_cancelUploadedFiles(filesToCancel, showError: true)); + } + } + + void _clearLocalFiles() { + _uploadGeneration++; + _files.clear(); + _deletingFileKeys.clear(); + _isUploading = false; + } + + Future _cancelUploadedFiles( + List<_UploadedFile> files, { + bool showError = false, + }) async { + final fileKeys = files + .map((file) => file.fileKey) + .where((fileKey) => fileKey.isNotEmpty) + .toSet() + .toList(growable: false); + if (fileKeys.isEmpty) { + return; + } + + try { + await Future.wait( + fileKeys.map( + (fileKey) => _workspaceApi.cancelUploadedFile(fileKey: fileKey), + ), + eagerError: false, + ); + } catch (_) { + if (showError && mounted) { + _showHint('部分文件撤销失败,请稍后重试'); + } + } + } + + SplitRequest? _buildSplitRequest() { + final option = _selectedModelOption; + if (option == null) { + return null; + } + + return SplitRequest( + modelProvider: option.category.isNotEmpty ? option.category : 'openai', + modelName: option.modelName.isNotEmpty ? option.modelName : null, + providerSource: option.source.isNotEmpty ? option.source : null, + providerId: option.providerId.isNotEmpty ? option.providerId : null, + ); + } + + String get _primaryButtonLabel { + if (_effectiveEraseHandwriting) { + return '开始擦除笔迹'; + } + return _mode == _SmartInputMode.examSplit ? '开始 OCR 识别' : '启动 AI 笔记整理'; + } + + List get _steps { + final base = switch (_mode) { + _SmartInputMode.examSplit => const ['上传', 'OCR', '分割', '导出'], + _SmartInputMode.noteOrganize => const ['上传', 'OCR', '整理', '保存'], + }; + if (_effectiveEraseHandwriting) { + return ['上传', '擦除', ...base.sublist(1)]; + } + return base; + } + + bool get _canEraseHandwriting => _mode == _SmartInputMode.examSplit; + bool get _effectiveEraseHandwriting => + _canEraseHandwriting && _eraseHandwriting; + + String get _examDescription => '支持 PDF 和图片格式,AI 将自动完成 OCR 识别、题目分割和知识点标注'; + + String get _noteDescription => '支持拍照或扫描件,AI 将自动识别内容并整理为结构化笔记'; + + List<_FeatureItem> get _examFeatureCards => const [ + _FeatureItem(title: '上传文件', icon: Icons.upload_file_outlined), + _FeatureItem(title: 'AI 识别', icon: Icons.auto_awesome_rounded), + _FeatureItem(title: '分割纠错', icon: Icons.find_replace_outlined), + _FeatureItem(title: '导出归档', icon: Icons.archive_outlined), + ]; + + List<_FeatureItem> get _noteFeatureCards => const [ + _FeatureItem(title: '上传笔记', icon: Icons.note_add_outlined), + _FeatureItem(title: 'AI 识别', icon: Icons.auto_awesome_rounded), + _FeatureItem(title: '智能整理', icon: Icons.format_list_bulleted), + _FeatureItem(title: '保存笔记', icon: Icons.save_outlined), + ]; +} + +enum _SmartInputMode { examSplit, noteOrganize } + +enum _ModelSource { hosted, self } + +class _UploadedFile { + _UploadedFile({ + required this.name, + required this.fileKey, + required this.bytes, + required this.progress, + }); + + final String name; + final String fileKey; + final List bytes; + double progress; +} + +class _FeatureItem { + const _FeatureItem({required this.title, required this.icon}); + + final String title; + final IconData icon; +} + +class _StepNode extends StatelessWidget { + const _StepNode({ + required this.index, + required this.label, + required this.isActive, + required this.isDone, + required this.isLight, + required this.pageText, + required this.helperText, + }); + + final int index; + final String label; + final bool isActive; + final bool isDone; + final bool isLight; + final Color pageText; + final Color helperText; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CircleAvatar( + radius: 11, + backgroundColor: isDone + ? AppTheme.primary.withOpacity(0.25) + : isActive + ? (isLight ? AppTheme.primary : AppTheme.primaryLight) + : Colors.transparent, + foregroundColor: isDone + ? AppTheme.primary + : (isActive ? Colors.white : helperText), + child: Text('$index', style: const TextStyle(fontSize: 10)), + ), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + color: isActive + ? pageText + : helperText.withOpacity(isDone ? 0.9 : 0.65), + fontSize: 12, + fontWeight: isActive ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ); + } +} + +class _ModelSelector extends StatelessWidget { + const _ModelSelector({ + required this.isLight, + required this.isLoading, + required this.selectedModelId, + required this.selectedModelName, + required this.selectedSource, + required this.hosted, + required this.self, + required this.modelError, + required this.onPickHosted, + required this.onPickSelf, + required this.onPickSettings, + required this.onPickEmpty, + required this.panelBg, + required this.border, + }); + + final bool isLight; + final bool isLoading; + final String? selectedModelId; + final String? selectedModelName; + final _ModelSource? selectedSource; + final List hosted; + final List self; + final String? modelError; + final ValueChanged onPickHosted; + final ValueChanged onPickSelf; + final VoidCallback onPickSettings; + final VoidCallback onPickEmpty; + final Color panelBg; + final Color border; + + static const String _apiSettingsValue = '__api_settings__'; + + @override + Widget build(BuildContext context) { + final bool hasSelected = selectedModelId != null && + selectedModelName != null && + selectedModelName!.isNotEmpty; + final bool hasModels = hosted.isNotEmpty || self.isNotEmpty; + final allOptions = [...hosted, ...self]; + final palette = AppThemePalette(isLight: isLight); + + final Color triggerText = palette.textMain; + final Color mutedText = palette.textSub; + final Color menuBg = palette.menuBg; + + return PopupMenuButton( + tooltip: '模型选择', + enabled: !isLoading, + color: menuBg, + elevation: 12, + offset: const Offset(0, 10), + constraints: const BoxConstraints(minWidth: 274, maxWidth: 320), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide(color: palette.subtleOverlay), + ), + padding: EdgeInsets.zero, + onSelected: (value) { + if (value == _apiSettingsValue) { + onPickSettings(); + return; + } + + WorkspaceModelOption? selected; + for (final option in allOptions) { + if (option.optionId == value) { + selected = option; + break; + } + } + + if (selected == null) { + onPickEmpty(); + return; + } + + if (selected.isHostedSource) { + onPickHosted(selected); + } else { + onPickSelf(selected); + } + }, + itemBuilder: (context) { + if (isLoading) { + return [ + PopupMenuItem( + enabled: false, + height: 52, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 10), + Text( + '模型加载中...', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ]; + } + + if (!hasModels) { + return [ + PopupMenuItem( + enabled: false, + height: 56, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Text( + modelError ?? '当前暂无可用模型', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: triggerText, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + _buildDivider(palette), + PopupMenuItem( + value: _apiSettingsValue, + height: 46, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Icon(Icons.tune_rounded, size: 18, color: mutedText), + const SizedBox(width: 12), + Expanded( + child: Text( + 'API 设置', + style: TextStyle( + color: triggerText, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ]; + } + + return [ + _buildSectionTitle('平台托管', mutedText), + for (final item in hosted) + _buildModelItem( + option: item, + palette: palette, + selected: selectedModelId == item.optionId && + selectedSource == _ModelSource.hosted, + showDefault: item.isDefault, + ), + _buildDivider(palette), + _buildSectionTitle('自己设置', mutedText), + for (final item in self) + _buildModelItem( + option: item, + palette: palette, + selected: selectedModelId == item.optionId && + selectedSource == _ModelSource.self, + showDefault: false, + ), + _buildDivider(palette), + PopupMenuItem( + value: _apiSettingsValue, + height: 46, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + Icon(Icons.tune_rounded, size: 18, color: mutedText), + const SizedBox(width: 12), + Expanded( + child: Text( + 'API 设置', + style: TextStyle( + color: triggerText, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + ]; + }, + child: Container( + height: 32, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: panelBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: border), + boxShadow: [ + if (!isLight) + BoxShadow( + color: Colors.black.withOpacity(0.18), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 9), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 70), + child: Text( + hasSelected ? (selectedModelName ?? '选择模型') : '选择模型', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: triggerText, + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ), + if (selectedSource != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: isLight + ? Colors.black.withOpacity(0.05) + : Colors.white.withOpacity(0.07), + borderRadius: BorderRadius.circular(7), + ), + child: Tooltip( + message: + selectedSource == _ModelSource.hosted ? '平台托管' : '自己设置', + child: Icon( + selectedSource == _ModelSource.hosted + ? Icons.business_rounded + : Icons.person_rounded, + color: mutedText, + size: 12, + ), + ), + ), + ], + Icon(Icons.keyboard_arrow_down_rounded, size: 17, color: mutedText), + ], + ), + ), + ); + } + + PopupMenuItem _buildSectionTitle(String title, Color color) { + return PopupMenuItem( + enabled: false, + height: 36, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + title, + style: TextStyle( + color: color, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + PopupMenuItem _buildModelItem({ + required WorkspaceModelOption option, + required AppThemePalette palette, + required bool selected, + required bool showDefault, + }) { + final Color textColor = palette.textMain; + final Color mutedText = palette.textSub; + final Color selectedBg = palette.selectedBg; + + return PopupMenuItem( + value: option.optionId, + height: 44, + padding: EdgeInsets.zero, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: selected ? selectedBg : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Expanded( + child: Text( + option.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: textColor, + fontSize: 14, + fontWeight: selected ? FontWeight.w800 : FontWeight.w500, + ), + ), + ), + if (showDefault) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: palette.badgeBg, + borderRadius: BorderRadius.circular(7), + ), + child: Text( + '默认', + style: TextStyle( + color: mutedText, + fontSize: 11, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + ), + ], + ], + ), + ), + ); + } + + PopupMenuItem _buildDivider(AppThemePalette palette) { + return PopupMenuItem( + enabled: false, + height: 12, + padding: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Divider(height: 1, thickness: 1, color: palette.divider), + ), + ); + } +} + +class _SplitHistoryButton extends StatelessWidget { + const _SplitHistoryButton({ + required this.isLight, + required this.panelBg, + required this.border, + required this.helperText, + required this.compact, + required this.onTap, + }); + + final bool isLight; + final Color panelBg; + final Color border; + final Color helperText; + final bool compact; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final textColor = AppThemePalette.of(context).textMain; + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Ink( + height: 32, + padding: EdgeInsets.symmetric(horizontal: compact ? 8 : 10), + decoration: BoxDecoration( + color: panelBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: border), + boxShadow: [ + if (!isLight) + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.history_rounded, size: 17, color: helperText), + if (!compact) ...[ + const SizedBox(width: 7), + Text( + '分割历史', + style: TextStyle( + color: textColor, + fontSize: 13, + fontWeight: FontWeight.w800, + ), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/smart_input_process_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/smart_input_process_page.dart new file mode 100644 index 00000000..56d5294c --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/smart_input_process_page.dart @@ -0,0 +1,3104 @@ +import 'dart:async'; +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../../core/widgets/markdown_math_text.dart'; +import '../../data/workspace_api.dart'; +import '../../data/workspace_project_store.dart'; + +/// 智能录入处理流程页。 +class SmartInputProcessPage extends StatefulWidget { + const SmartInputProcessPage({ + super.key, + required this.files, + required this.enableQuestionSplit, + this.splitRequest, + this.initialEraseHandwriting = true, + this.onConfirmSplit, + }); + + /// 已上传文件。试卷流程使用 file_key,笔记整理预览会复用原始 bytes。 + final List files; + + /// 仅试卷分割可开启擦除。笔记整理模式会强制走 OCR 流程。 + /// true:擦除中 -> 擦除完成 -> OCR 中 -> OCR 完成 + /// false:OCR 中 -> OCR 完成 + final bool initialEraseHandwriting; + + /// 仅试卷分割模式开启;笔记整理模式暂不调用 split。 + final bool enableQuestionSplit; + + /// /api/split 使用的模型参数。 + final SplitRequest? splitRequest; + + /// OCR 完成后点击「确认并分割」 + final VoidCallback? onConfirmSplit; + + @override + State createState() => _SmartInputProcessPageState(); +} + +class SmartInputUploadedFile { + const SmartInputUploadedFile({ + required this.fileKey, + required this.name, + required this.bytes, + }); + + final String fileKey; + final String name; + final List bytes; +} + +enum _SmartInputProcessStage { + erasing, + erasePreview, + ocrProcessing, + ocrPreview, + noteOrganizing, + notePreview, + splitting, + splitPreview, +} + +class _SmartInputProcessPageState extends State + with TickerProviderStateMixin { + static const List _stepsWithErase = ['上传', '擦除', 'OCR', '分割', '导出']; + static const List _examStepsWithoutErase = ['上传', 'OCR', '分割', '导出']; + static const List _noteSteps = ['上传', 'OCR', '整理', '保存']; + + late final WorkspaceApi _workspaceApi; + late final AnimationController _pulseController; + late final AnimationController _loadingBarController; + late _SmartInputProcessStage _stage; + + final PageController _pageController = PageController(); + final List<_DisplayItem> _displayItems = []; + final List _ocrPages = []; + final List _splitQuestions = []; + final List _splitWarnings = []; + final Map> _imageFutures = {}; + final Set _selectedQuestionIds = {}; + NotePreview? _notePreview; + String? _splitRunId; + + int _currentPage = 0; + String? _errorMessage; + bool _isBusy = false; + bool _isImporting = false; + bool _didRequestUploadReset = false; + + @override + void initState() { + super.initState(); + + _workspaceApi = WorkspaceApi(); + _stage = _usesErase + ? _SmartInputProcessStage.erasing + : _SmartInputProcessStage.ocrProcessing; + + _pulseController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + )..repeat(); + + _loadingBarController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1500), + )..repeat(reverse: true); + + _resetDisplayItems(); + _startByStage(); + } + + @override + void dispose() { + unawaited(_resetUploadSessionOnExit()); + _pageController.dispose(); + _pulseController.dispose(); + _loadingBarController.dispose(); + super.dispose(); + } + + Future _resetUploadSessionOnExit() async { + if (_didRequestUploadReset) { + return; + } + _didRequestUploadReset = true; + + try { + await _workspaceApi.resetUploadSession(); + } catch (_) { + // 离开流程页时的清理失败不阻断页面关闭。 + } + } + + void _resetDisplayItems() { + _displayItems + ..clear() + ..addAll( + widget.files.map( + (file) => _DisplayItem(fileKey: file.fileKey, fileName: file.name), + ), + ); + + if (_displayItems.isEmpty) { + _displayItems.add(const _DisplayItem(fileKey: '', fileName: '待处理文件')); + } + _currentPage = 0; + } + + void _startByStage() { + if (_stage == _SmartInputProcessStage.erasing) { + unawaited(_startEraseProcess()); + } else if (_stage == _SmartInputProcessStage.ocrProcessing) { + unawaited(_startOcrProcess()); + } + } + + void _setPageTo0() { + _currentPage = 0; + if (_pageController.hasClients) { + _pageController.jumpToPage(0); + } + } + + int get _totalImages => _displayItems.isEmpty ? 1 : _displayItems.length; + + int get _ocrPreviewCount => _ocrPages.isEmpty ? 1 : _ocrPages.length; + + _DisplayItem _itemAt(int index) { + if (_displayItems.isEmpty) { + return const _DisplayItem(fileKey: '', fileName: '待处理文件'); + } + final safeIndex = index.clamp(0, _displayItems.length - 1); + return _displayItems[safeIndex]; + } + + OcrPage? _ocrPageAt(int index) { + if (_ocrPages.isEmpty) { + return null; + } + final safeIndex = index.clamp(0, _ocrPages.length - 1); + return _ocrPages[safeIndex]; + } + + bool get _usesErase => + widget.enableQuestionSplit && widget.initialEraseHandwriting; + bool get _isNoteProcess => !widget.enableQuestionSplit; + + int get _activeStepIndex { + if (_usesErase) { + return switch (_stage) { + _SmartInputProcessStage.erasing || + _SmartInputProcessStage.erasePreview => + 1, + _SmartInputProcessStage.ocrProcessing || + _SmartInputProcessStage.ocrPreview => + 2, + _SmartInputProcessStage.splitting => 3, + _SmartInputProcessStage.splitPreview => 4, + _SmartInputProcessStage.noteOrganizing || + _SmartInputProcessStage.notePreview => + 0, + }; + } + + if (_isNoteProcess) { + return switch (_stage) { + _SmartInputProcessStage.ocrProcessing => 1, + _SmartInputProcessStage.ocrPreview => 1, + _SmartInputProcessStage.noteOrganizing => 2, + _SmartInputProcessStage.notePreview => 3, + _ => 0, + }; + } + + return switch (_stage) { + _SmartInputProcessStage.ocrProcessing || + _SmartInputProcessStage.ocrPreview => + 1, + _SmartInputProcessStage.splitting => 2, + _SmartInputProcessStage.splitPreview => 3, + _ => 0, + }; + } + + List get _steps { + if (_isNoteProcess) { + return _noteSteps; + } + return _usesErase ? _stepsWithErase : _examStepsWithoutErase; + } + + Set get _doneStepIndexes { + if (_isNoteProcess) { + return switch (_stage) { + _SmartInputProcessStage.ocrProcessing => const {0}, + _SmartInputProcessStage.ocrPreview => const {0}, + _SmartInputProcessStage.noteOrganizing => const {0, 1}, + _SmartInputProcessStage.notePreview => const {0, 1, 2}, + _ => const {0}, + }; + } + + if (!_usesErase) { + return switch (_stage) { + _SmartInputProcessStage.ocrProcessing => const {0}, + _SmartInputProcessStage.ocrPreview => const {0, 1}, + _SmartInputProcessStage.splitting => const {0, 1}, + _SmartInputProcessStage.splitPreview => const {0, 1, 2}, + _ => const {0}, + }; + } + + return switch (_stage) { + _SmartInputProcessStage.erasing => const {0}, + _SmartInputProcessStage.erasePreview => const {0, 1}, + _SmartInputProcessStage.ocrProcessing => const {0, 1}, + _SmartInputProcessStage.ocrPreview => const {0, 1, 2}, + _SmartInputProcessStage.splitting => const {0, 1, 2}, + _SmartInputProcessStage.splitPreview => const {0, 1, 2, 3}, + _SmartInputProcessStage.noteOrganizing || + _SmartInputProcessStage.notePreview => + const {0}, + }; + } + + String get _pageTitle { + return switch (_stage) { + _SmartInputProcessStage.erasing => '智能录入与分析', + _SmartInputProcessStage.erasePreview => '擦除结果预览', + _SmartInputProcessStage.ocrProcessing => 'OCR 识别', + _SmartInputProcessStage.ocrPreview => 'OCR 预览', + _SmartInputProcessStage.noteOrganizing => '笔记整理', + _SmartInputProcessStage.notePreview => '保存笔记', + _SmartInputProcessStage.splitting => '题目分割', + _SmartInputProcessStage.splitPreview => '分割结果预览', + }; + } + + Future _startEraseProcess() async { + _resetDisplayItems(); + _setPageTo0(); + if (!mounted) { + return; + } + + setState(() { + _isBusy = true; + _errorMessage = null; + _stage = _SmartInputProcessStage.erasing; + }); + + try { + final response = await _workspaceApi.eraseUploadedFiles(); + + if (!mounted) { + return; + } + + if (!response.success) { + setState(() { + _isBusy = false; + _errorMessage = response.message; + }); + return; + } + + final merged = _mergeEraseResult(response); + setState(() { + _isBusy = false; + _errorMessage = null; + _displayItems + ..clear() + ..addAll(merged); + _stage = _SmartInputProcessStage.erasePreview; + _setPageTo0(); + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = error.message; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = '擦除调用失败,请稍后重试'; + }); + } + } + + List<_DisplayItem> _mergeEraseResult(EraseResponse response) { + if (widget.files.isEmpty) { + return const [_DisplayItem(fileKey: '', fileName: '待处理文件')]; + } + + final merged = <_DisplayItem>[]; + for (final input in widget.files) { + final result = _findErasedResult(input.fileKey, response.files); + merged.add( + _DisplayItem( + fileKey: input.fileKey, + fileName: input.name, + beforeImageUrl: _resolveImageUrl(result?.beforeImageUrl), + afterImageUrl: _resolveImageUrl(result?.afterImageUrl), + ), + ); + } + + if (merged.every( + (item) => item.beforeImageUrl == null && item.afterImageUrl == null, + )) { + for (var i = 0; i < response.files.length && i < merged.length; i++) { + final result = response.files[i]; + merged[i] = _DisplayItem( + fileKey: merged[i].fileKey, + fileName: merged[i].fileName, + beforeImageUrl: _resolveImageUrl(result.beforeImageUrl), + afterImageUrl: _resolveImageUrl(result.afterImageUrl), + ); + } + } + + return merged; + } + + EraseResultFile? _findErasedResult( + String fileKey, + List files, + ) { + for (final item in files) { + if (item.fileKey == fileKey) { + return item; + } + } + + for (final item in files) { + if (item.beforeFileKey == fileKey || item.afterFileKey == fileKey) { + return item; + } + } + + return null; + } + + Future _startOcrProcess() async { + _setPageTo0(); + + setState(() { + _isBusy = true; + _errorMessage = null; + _stage = _SmartInputProcessStage.ocrProcessing; + _ocrPages.clear(); + }); + + try { + final response = await _workspaceApi.runOcr(); + + if (!mounted) { + return; + } + + if (!response.success || response.pages.isEmpty) { + setState(() { + _isBusy = false; + _errorMessage = response.message; + }); + return; + } + + setState(() { + _isBusy = false; + _errorMessage = null; + _ocrPages + ..clear() + ..addAll(response.pages); + _stage = _SmartInputProcessStage.ocrPreview; + _setPageTo0(); + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = error.message; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = 'OCR 调用失败,请稍后重试'; + }); + } + } + + void _restartErase() { + unawaited(_startEraseProcess()); + } + + void _startOcr() { + unawaited(_startOcrProcess()); + } + + void _restartOcr() { + unawaited(_startOcrProcess()); + } + + Future _startNoteOrganizeProcess() async { + final request = widget.splitRequest; + if (request == null) { + setState(() { + _stage = _SmartInputProcessStage.noteOrganizing; + _isBusy = false; + _errorMessage = '未检测到可用模型,请重新选择模型后再试'; + }); + return; + } + + final files = widget.files + .where((file) => file.bytes.isNotEmpty) + .map((file) => UploadFileItem(filename: file.name, bytes: file.bytes)) + .toList(growable: false); + + if (files.isEmpty) { + setState(() { + _stage = _SmartInputProcessStage.noteOrganizing; + _isBusy = false; + _errorMessage = '缺少原始图片内容,请重新上传后再试'; + }); + return; + } + + setState(() { + _stage = _SmartInputProcessStage.noteOrganizing; + _isBusy = true; + _errorMessage = null; + _notePreview = null; + }); + + try { + final response = await _workspaceApi.organizeNotePreview( + files: files, + modelRequest: request, + ); + + if (!mounted) { + return; + } + + final preview = response.notePreview; + if (!response.success || preview == null) { + setState(() { + _isBusy = false; + _errorMessage = '笔记整理失败,请稍后重试'; + }); + return; + } + + setState(() { + _isBusy = false; + _errorMessage = null; + _notePreview = preview; + _stage = _SmartInputProcessStage.notePreview; + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = error.message; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = '笔记整理失败,请稍后重试'; + }); + } + } + + Future _startSplitProcess() async { + if (!widget.enableQuestionSplit) { + _finishProcess(); + return; + } + + final request = widget.splitRequest; + if (request == null) { + setState(() { + _stage = _SmartInputProcessStage.splitting; + _isBusy = false; + _errorMessage = '未检测到可用模型,请重新选择模型后再试'; + }); + return; + } + + setState(() { + _stage = _SmartInputProcessStage.splitting; + _isBusy = true; + _errorMessage = null; + _splitQuestions.clear(); + _splitWarnings.clear(); + _selectedQuestionIds.clear(); + _splitRunId = null; + }); + + try { + final response = await _workspaceApi.splitQuestions(request: request); + + if (!mounted) { + return; + } + + if (!response.success || response.questions.isEmpty) { + setState(() { + _isBusy = false; + _errorMessage = response.message; + }); + return; + } + + setState(() { + _isBusy = false; + _errorMessage = null; + _splitRunId = response.runId; + _splitQuestions + ..clear() + ..addAll(response.questions); + _splitWarnings + ..clear() + ..addAll(response.warnings); + _selectedQuestionIds + ..clear() + ..addAll( + response.questions.indexed.map( + (entry) => _questionSelectionId(entry.$2, entry.$1), + ), + ); + _stage = _SmartInputProcessStage.splitPreview; + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = error.message; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _isBusy = false; + _errorMessage = '题目分割失败,请稍后重试'; + }); + } + } + + void _confirmSplit() { + if (_isNoteProcess) { + unawaited(_startNoteOrganizeProcess()); + return; + } + unawaited(_startSplitProcess()); + } + + void _finishProcess() { + if (widget.onConfirmSplit != null) { + widget.onConfirmSplit!(); + return; + } + if (!mounted) { + return; + } + Navigator.of(context).pop(); + } + + String _questionSelectionId(SplitQuestion question, int index) { + if (question.uid.trim().isNotEmpty) { + return question.uid.trim(); + } + if (question.questionId.trim().isNotEmpty) { + return question.questionId.trim(); + } + return index.toString(); + } + + void _toggleQuestionSelection(SplitQuestion question, int index) { + final id = _questionSelectionId(question, index); + setState(() { + if (_selectedQuestionIds.contains(id)) { + _selectedQuestionIds.remove(id); + } else { + _selectedQuestionIds.add(id); + } + }); + } + + Future _openImportDialog() async { + final runId = _splitRunId; + final selectedIds = _selectedQuestionIds.toList(growable: false); + + if (selectedIds.isEmpty) { + _showMessage('请先选择要导入的题目'); + return; + } + if (runId == null || runId.isEmpty) { + _showMessage('缺少分割任务 ID,请重新分割后再导入'); + return; + } + + setState(() => _isImporting = true); + await WorkspaceProjectStore.instance.ensureLoaded(); + if (!mounted) { + return; + } + setState(() => _isImporting = false); + + final store = WorkspaceProjectStore.instance; + if (store.questionProjects.isEmpty) { + _showMessage(store.errorMessage ?? '暂无可导入的错题库'); + return; + } + + final palette = AppThemePalette.of(context); + final project = await showDialog( + context: context, + barrierDismissible: !_isImporting, + builder: (context) => _ImportQuestionBankDialog( + palette: palette, + projects: store.questionProjects, + selectedCount: selectedIds.length, + ), + ); + + if (project == null) { + return; + } + + await _saveSelectedQuestions(project, runId, selectedIds); + } + + Future _saveSelectedQuestions( + WorkspaceProject project, + String runId, + List selectedIds, + ) async { + setState(() => _isImporting = true); + + try { + final response = await _workspaceApi.saveSplitQuestionsToDb( + runId: runId, + projectId: project.id, + selectedIds: selectedIds, + ); + + if (!mounted) { + return; + } + + if (!response.success) { + _showMessage(response.message); + return; + } + + await WorkspaceProjectStore.instance.refresh(); + if (!mounted) { + return; + } + + _showMessage(response.message.isEmpty ? '导入成功' : response.message); + _finishProcess(); + } on ApiException catch (error) { + if (mounted) { + _showMessage(error.message); + } + } catch (_) { + if (mounted) { + _showMessage('导入失败,请稍后重试'); + } + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } + + Future _openSaveNoteDialog() async { + final preview = _notePreview; + if (preview == null) { + _showMessage('缺少笔记预览,请重新整理后再保存'); + return; + } + + setState(() => _isImporting = true); + await WorkspaceProjectStore.instance.ensureLoaded(); + if (!mounted) { + return; + } + setState(() => _isImporting = false); + + final store = WorkspaceProjectStore.instance; + if (store.noteProjects.isEmpty) { + _showMessage(store.errorMessage ?? '暂无可保存的笔记本'); + return; + } + + final palette = AppThemePalette.of(context); + final project = await showDialog( + context: context, + barrierDismissible: !_isImporting, + builder: (context) => _SaveNoteDialog( + palette: palette, + projects: store.noteProjects, + preview: preview, + ), + ); + + if (project == null) { + return; + } + + await _saveOrganizedNote(project, preview); + } + + Future _saveOrganizedNote( + WorkspaceProject project, + NotePreview preview, + ) async { + setState(() => _isImporting = true); + + try { + final response = await _workspaceApi.saveOrganizedNote( + projectId: project.id, + preview: preview, + ); + + if (!mounted) { + return; + } + + if (!response.success) { + _showMessage('保存失败,请稍后重试'); + return; + } + + await WorkspaceProjectStore.instance.refresh(); + if (!mounted) { + return; + } + + _showMessage('笔记已保存'); + _finishProcess(); + } on ApiException catch (error) { + if (mounted) { + _showMessage(error.message); + } + } catch (_) { + if (mounted) { + _showMessage('保存失败,请稍后重试'); + } + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } + + void _clearQuestionSelection() { + setState(_selectedQuestionIds.clear); + } + + void _showMessage(String message) { + showAppSnackBar(context, message); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + backgroundColor: palette.pageBg, + body: SafeArea( + child: Column( + children: [ + _buildAppHeader(palette: palette), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), + child: _buildAppStepper(palette: palette), + ), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 260), + child: _buildStageBody(palette: palette), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAppHeader({required AppThemePalette palette}) { + return Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + iconSize: 14, + ), + Expanded( + child: Text( + _pageTitle, + style: TextStyle( + color: palette.textMain, + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ); + } + + Widget _buildAppStepper({required AppThemePalette palette}) { + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + child: Wrap( + spacing: 10, + runSpacing: 10, + crossAxisAlignment: WrapCrossAlignment.center, + children: List.generate(_steps.length, (index) { + final isDone = _doneStepIndexes.contains(index); + final isActive = _activeStepIndex == index; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildStepDot( + index, + isDone: isDone, + isActive: isActive, + palette: palette, + ), + const SizedBox(width: 6), + Text( + _steps[index], + style: TextStyle( + color: + isActive || isDone ? palette.textMain : palette.textSub, + fontWeight: + isActive || isDone ? FontWeight.w800 : FontWeight.w600, + fontSize: 12, + ), + ), + if (index != _steps.length - 1) ...[ + const SizedBox(width: 10), + Container( + width: 18, + height: 1, + color: isDone + ? palette.primary.withOpacity(0.9) + : palette.textSub.withOpacity(0.22), + ), + ], + ], + ); + }), + ), + ); + } + + Widget _buildStepDot( + int index, { + required bool isDone, + required bool isActive, + required AppThemePalette palette, + }) { + return AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 24, + height: 24, + decoration: BoxDecoration( + color: isActive || isDone ? palette.primary : null, + borderRadius: BorderRadius.circular(11), + boxShadow: isActive + ? [ + BoxShadow( + color: palette.primary.withOpacity(0.38), + blurRadius: 12, + spreadRadius: 1, + ), + ] + : null, + ), + alignment: Alignment.center, + child: isDone + ? Icon(Icons.check_rounded, color: Colors.white, size: 16) + : Text( + '${index + 1}', + style: TextStyle( + color: isActive ? Colors.white : palette.textSub, + fontWeight: FontWeight.w800, + fontSize: 12, + ), + ), + ); + } + + Widget _buildStageBody({required AppThemePalette palette}) { + switch (_stage) { + case _SmartInputProcessStage.erasing: + return _buildProcessingView( + key: const ValueKey('erasing'), + title: '正在擦除手写笔迹', + subtitle: 'EnsExam 正在识别并移除手写内容', + icon: Icons.auto_fix_high_rounded, + palette: palette, + onRetry: _isBusy ? null : _startEraseProcess, + ); + + case _SmartInputProcessStage.erasePreview: + return _buildErasePreviewView( + key: const ValueKey('erasePreview'), + palette: palette, + ); + + case _SmartInputProcessStage.ocrProcessing: + return _buildProcessingView( + key: const ValueKey('ocrProcessing'), + title: '正在执行 OCR 识别', + subtitle: 'PaddleOCR 正在解析文档结构与文字内容', + icon: Icons.auto_awesome, + palette: palette, + onRetry: _isBusy ? null : _startOcr, + ); + + case _SmartInputProcessStage.ocrPreview: + return _buildOcrPreviewView( + key: const ValueKey('ocrPreview'), + palette: palette, + ); + + case _SmartInputProcessStage.noteOrganizing: + return _buildProcessingView( + key: const ValueKey('noteOrganizing'), + title: '正在整理笔记', + subtitle: 'AI 正在根据 OCR 结果生成结构化笔记预览', + icon: Icons.menu_book_rounded, + palette: palette, + onRetry: _isBusy ? null : _startNoteOrganizeProcess, + ); + + case _SmartInputProcessStage.notePreview: + return _buildNotePreviewView( + key: const ValueKey('notePreview'), + palette: palette, + ); + + case _SmartInputProcessStage.splitting: + return _buildProcessingView( + key: const ValueKey('splitting'), + title: '正在分割题目', + subtitle: 'AI 正在根据 OCR 结果拆分题目并整理知识点', + icon: Icons.account_tree_rounded, + palette: palette, + onRetry: _isBusy ? null : _confirmSplit, + ); + + case _SmartInputProcessStage.splitPreview: + return _buildSplitPreviewView( + key: const ValueKey('splitPreview'), + palette: palette, + ); + } + } + + Widget _buildProcessingView({ + required Key key, + required String title, + required String subtitle, + required AppThemePalette palette, + required IconData icon, + required VoidCallback? onRetry, + }) { + return SizedBox( + key: key, + width: double.infinity, + height: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), + child: Container(color: Colors.black.withOpacity(0)), + ), + ), + Column( + children: [ + const SizedBox(height: 100), + SizedBox( + width: 156, + height: 156, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: _pulseController, + builder: (context, child) { + final t = _pulseController.value; + return CustomPaint( + size: const Size(156, 156), + painter: _PulseRingPainter( + t: t, + color: palette.primary, + ), + ); + }, + ), + Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: palette.primaryDeep, + borderRadius: BorderRadius.circular(22), + gradient: LinearGradient( + colors: [ + palette.primary, + palette.primary.withOpacity(0.55), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: palette.primary.withOpacity(0.48), + blurRadius: 28, + spreadRadius: 2, + ), + ], + ), + child: Icon(icon, color: Colors.white, size: 34), + ), + ], + ), + ), + const SizedBox(height: 30), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textMain, + fontSize: 23, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 12), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textSub, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 30), + _buildMovingProgressBar(palette), + const SizedBox(height: 16), + if (_errorMessage != null) ...[ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + _errorMessage!, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.errorText, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(height: 12), + if (onRetry != null) + OutlinedButton( + onPressed: _isBusy ? null : onRetry, + style: OutlinedButton.styleFrom( + foregroundColor: palette.primary, + side: BorderSide(color: palette.primary), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('重试'), + ), + ], + ], + ), + ], + ), + ); + } + + Widget _buildMovingProgressBar(AppThemePalette palette) { + return SizedBox( + width: 260, + height: 10, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + const barWidth = 86.0; + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: palette.textSub.withOpacity(0.1), + ), + child: AnimatedBuilder( + animation: _loadingBarController, + builder: (context, _) { + final left = (width - barWidth).clamp(0.0, width).toDouble() * + _loadingBarController.value; + return Stack( + children: [ + Positioned( + left: left, + top: 0, + bottom: 0, + child: Container( + width: barWidth, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: LinearGradient( + colors: [ + palette.primary.withOpacity(0.9), + palette.primaryLight.withOpacity(0.9), + Colors.white.withOpacity(0.2), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } + + Widget _buildErasePreviewView({ + required Key key, + required AppThemePalette palette, + }) { + return Column( + key: key, + children: [ + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: _totalImages, + onPageChanged: (index) => setState(() => _currentPage = index), + itemBuilder: (context, index) { + final item = _itemAt(index); + return EraseImageCompareViewer( + beforeImage: _loadProtectedImage(item.beforeImageUrl), + afterImage: _loadProtectedImage(item.afterImageUrl), + placeholderText: item.fileName, + backgroundColor: palette.imageBg, + dividerColor: palette.compareLine, + textColor: palette.textSub, + ); + }, + ), + ), + if (_totalImages > 1) _buildPageSwitcher(palette), + const SizedBox(height: 10), + _buildBottomActions( + palette: palette, + leftText: '重新擦除', + rightText: '开始 OCR', + onLeft: _restartErase, + onRight: _startOcr, + ), + ], + ); + } + + Widget _buildOcrPreviewView({ + required Key key, + required AppThemePalette palette, + }) { + return Column( + key: key, + children: [ + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: _ocrPreviewCount, + onPageChanged: (index) => setState(() => _currentPage = index), + itemBuilder: (context, index) { + final page = _ocrPageAt(index); + final item = _itemAt(index); + final imageUrl = _resolveImageUrl(page?.imageUrl) ?? + item.afterImageUrl ?? + item.beforeImageUrl; + final image = _loadProtectedImage(imageUrl); + return _OcrAnnotatedFrame( + image: image, + title: + page == null ? item.fileName : '第 ${page.pageIndex + 1} 页', + page: page, + fallback: _buildEmptyPaper(palette: palette), + palette: palette, + ); + }, + ), + ), + if (_ocrPreviewCount > 1) + _buildPageIndicator(palette, count: _ocrPreviewCount), + const SizedBox(height: 10), + _buildBottomActions( + palette: palette, + leftText: '重新识别', + rightText: widget.enableQuestionSplit ? '确认并分割' : '确认并整理', + onLeft: _restartOcr, + onRight: _confirmSplit, + ), + ], + ); + } + + Widget _buildSplitPreviewView({ + required Key key, + required AppThemePalette palette, + }) { + return Column( + key: key, + children: [ + Expanded( + child: ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + itemCount: _splitQuestions.length + 1, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + if (index == 0) { + return _buildSplitSummary(palette); + } + + return _buildSplitQuestionCard( + question: _splitQuestions[index - 1], + index: index - 1, + palette: palette, + selected: _selectedQuestionIds.contains( + _questionSelectionId(_splitQuestions[index - 1], index - 1), + ), + onToggle: () => _toggleQuestionSelection( + _splitQuestions[index - 1], + index - 1, + ), + ); + }, + ), + ), + _buildExportBottomBar(palette: palette), + ], + ); + } + + Widget _buildNotePreviewView({ + required Key key, + required AppThemePalette palette, + }) { + final preview = _notePreview; + if (preview == null) { + return _buildProcessingView( + key: key, + title: '笔记预览不可用', + subtitle: '请返回上一步重新整理', + icon: Icons.error_outline_rounded, + palette: palette, + onRetry: _startNoteOrganizeProcess, + ); + } + + return Column( + key: key, + children: [ + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 16), + children: [ + _buildNotePreviewHeader(preview, palette), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: MarkdownMathText( + text: preview.contentMarkdown, + palette: palette, + style: TextStyle( + color: palette.textMain, + fontSize: 14, + height: 1.55, + fontWeight: FontWeight.w600, + ), + imageBuilder: (context, alt, url) => + _buildMarkdownImage(alt, url, palette), + ), + ), + ], + ), + ), + _buildNoteSaveBottomBar(palette: palette), + ], + ); + } + + Widget _buildNotePreviewHeader(NotePreview preview, AppThemePalette palette) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.check_circle_rounded, + color: palette.primary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '笔记整理完成', + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + preview.displayTitle, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _buildTag(preview.displaySubject, palette), + ...preview.knowledgeTags.map((tag) => _buildTag(tag, palette)), + ], + ), + ], + ), + ); + } + + Widget _buildMarkdownImage(String alt, String url, AppThemePalette palette) { + final image = _loadProtectedImage(_resolveImageUrl(url)); + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 120, maxHeight: 260), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: palette.imageBg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: palette.panelBorder), + ), + child: image == null + ? _CompareImagePlaceholder( + text: alt.trim().isEmpty ? '图片' : alt.trim(), + textColor: palette.textSub, + ) + : _ProtectedImage( + image: image, + fit: BoxFit.contain, + placeholderText: alt.trim().isEmpty ? '图片' : alt.trim(), + textColor: palette.textSub, + ), + ); + } + + Widget _buildSplitSummary(AppThemePalette palette) { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.check_circle_rounded, + color: palette.primary, + size: 18, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '成功分割 ${_splitQuestions.length} 道题目', + style: TextStyle( + color: palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + if (_splitWarnings.isNotEmpty) ...[ + const SizedBox(height: 8), + ..._splitWarnings.map( + (warning) => Text( + warning, + style: TextStyle( + color: palette.errorText, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildSplitQuestionCard({ + required SplitQuestion question, + required int index, + required AppThemePalette palette, + required bool selected, + required VoidCallback onToggle, + }) { + final imageUrls = _imageUrlsForQuestion(question); + + return GestureDetector( + onTap: onToggle, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: selected + ? palette.primary.withOpacity(palette.isLight ? 0.07 : 0.11) + : palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected + ? palette.primary.withOpacity(0.45) + : palette.panelBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 30, + height: 30, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.14), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + question.questionId.isEmpty + ? '${index + 1}' + : question.questionId, + style: TextStyle( + color: palette.primary, + fontWeight: FontWeight.w900, + fontSize: 12, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + question.questionType ?? '题目', + style: TextStyle( + color: palette.textMain, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + if (question.sectionTitle != null && + question.sectionTitle!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + question.sectionTitle!, + style: TextStyle( + color: palette.textSub, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + _SelectionBadge(selected: selected, palette: palette), + ], + ), + const SizedBox(height: 12), + ...question.contentBlocks + .where((block) => block.content.trim().isNotEmpty) + .map( + (block) => block.isImage + ? Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildMarkdownImage( + '题目图片', + block.content, + palette, + ), + ) + : _buildSplitTextBlock(block, palette), + ), + if (imageUrls.isNotEmpty) ...[ + const SizedBox(height: 10), + _buildSplitImages(imageUrls, palette), + ], + if (question.options.isNotEmpty) ...[ + const SizedBox(height: 10), + ...List.generate( + question.options.length, + (optionIndex) => _buildSplitOption( + option: question.options[optionIndex], + optionImage: optionIndex < question.optionImages.length + ? question.optionImages[optionIndex] + : null, + palette: palette, + ), + ), + ], + if (question.knowledgeTags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: question.knowledgeTags + .map((tag) => _buildTag(tag, palette)) + .toList(), + ), + ], + ], + ), + ), + ); + } + + Widget _buildSplitTextBlock( + SplitQuestionBlock block, + AppThemePalette palette, + ) { + if (block.content.trim().isEmpty) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: MarkdownMathText( + text: block.content, + palette: palette, + style: TextStyle( + color: palette.textMain, + fontSize: 13, + height: 1.55, + fontWeight: FontWeight.w600, + ), + imageBuilder: (context, alt, url) => + _buildMarkdownImage(alt, url, palette), + ), + ); + } + + Widget _buildSplitOption({ + required String option, + required String? optionImage, + required AppThemePalette palette, + }) { + final hasImage = optionImage != null && optionImage.trim().isNotEmpty; + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 7), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: palette.badgeBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasImage) ...[ + SizedBox( + width: 136, + child: _buildSplitImages([optionImage], palette), + ), + const SizedBox(width: 10), + ], + Expanded( + child: MarkdownMathText( + text: option, + palette: palette, + style: TextStyle( + color: palette.textMain, + fontSize: 12, + height: 1.45, + fontWeight: FontWeight.w600, + ), + imageBuilder: (context, alt, url) => + _buildMarkdownImage(alt, url, palette), + ), + ), + ], + ), + ); + } + + Widget _buildSplitImages(List imageUrls, AppThemePalette palette) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: imageUrls + .map( + (url) => Container( + width: 128, + height: 96, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: palette.imageBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: palette.panelBorder), + ), + child: _ProtectedImage( + image: _loadProtectedImage(_resolveImageUrl(url))!, + fit: BoxFit.contain, + placeholderText: '图片', + textColor: palette.textSub, + ), + ), + ) + .toList(), + ); + } + + Widget _buildTag(String tag, AppThemePalette palette) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + tag, + style: TextStyle( + color: palette.primary, + fontSize: 11, + fontWeight: FontWeight.w700, + ), + ), + ); + } + + List _imageUrlsForQuestion(SplitQuestion question) { + final urls = []; + final embeddedUrls = _embeddedImageUrlsForQuestion(question); + + void add(String? url) { + if (url == null || url.trim().isEmpty) { + return; + } + final resolvedUrl = _resolveImageUrl(url) ?? url.trim(); + final duplicated = urls.any((existing) { + return (_resolveImageUrl(existing) ?? existing.trim()) == resolvedUrl; + }); + if (duplicated || embeddedUrls.contains(resolvedUrl)) { + return; + } + urls.add(url); + } + + if (embeddedUrls.isEmpty) { + for (final url in question.imageRefs) { + add(url); + } + for (final url in question.optionImages) { + add(url); + } + } + return urls; + } + + Set _embeddedImageUrlsForQuestion(SplitQuestion question) { + final urls = {}; + final imagePattern = RegExp( + r"""]*\bsrc\s*=\s*(['"])(.*?)\1""", + caseSensitive: false, + dotAll: true, + ); + final markdownImagePattern = RegExp(r'!\[[^\]]*\]\(([^)]+)\)'); + + void add(String? value) { + if (value == null || value.trim().isEmpty) { + return; + } + urls.add(_resolveImageUrl(value) ?? value.trim()); + } + + for (final block in question.contentBlocks) { + if (block.isImage) { + add(block.content); + } + for (final match in imagePattern.allMatches(block.content)) { + add(match.group(2)); + } + for (final match in markdownImagePattern.allMatches(block.content)) { + add(match.group(1)); + } + } + if (question.options.isNotEmpty) { + for (final url in question.optionImages) { + add(url); + } + } + return urls; + } + + Widget _buildPageIndicator(AppThemePalette palette, {int? count}) { + final itemCount = count ?? _totalImages; + return Container( + alignment: Alignment.center, + margin: const EdgeInsets.only(top: 6), + child: Wrap( + spacing: 6, + children: List.generate(itemCount, (index) { + final active = index == _currentPage; + return InkWell( + borderRadius: BorderRadius.circular(999), + onTap: () => _goToPage(index, count: itemCount), + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: active ? 32 : 12, + height: 12, + decoration: BoxDecoration( + color: + active ? palette.primary : palette.textSub.withOpacity(0.3), + borderRadius: BorderRadius.circular(999), + ), + ), + ); + }), + ), + ); + } + + Widget _buildPageSwitcher(AppThemePalette palette, {int? count}) { + final itemCount = count ?? _totalImages; + return _buildPageIndicator(palette, count: itemCount); + } + + void _goToPage(int index, {int? count}) { + final itemCount = count ?? _totalImages; + if (itemCount <= 0) { + return; + } + + final nextPage = index.clamp(0, itemCount - 1); + setState(() => _currentPage = nextPage); + + if (_pageController.hasClients) { + _pageController.animateToPage( + nextPage, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + } + + Widget _buildExportBottomBar({required AppThemePalette palette}) { + final selectedCount = _selectedQuestionIds.length; + final canImport = + selectedCount > 0 && _splitRunId != null && _isImporting == false; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.panelBorder)), + ), + alignment: Alignment.center, + child: Container( + constraints: const BoxConstraints(maxWidth: 560), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: palette.isLight + ? Colors.white.withOpacity(0.88) + : const Color(0xFF18191E).withOpacity(0.96), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: palette.panelBorder), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(palette.isLight ? 0.08 : 0.24), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary, + shape: BoxShape.circle, + ), + child: Text( + '$selectedCount', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w900, + fontSize: 15, + ), + ), + ), + Container( + height: 34, + width: 1, + margin: const EdgeInsets.symmetric(horizontal: 16), + color: palette.panelBorder, + ), + ElevatedButton.icon( + onPressed: canImport ? _openImportDialog : null, + icon: _isImporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.storage_rounded, size: 18), + label: Text(_isImporting ? '处理中' : '导入错题库'), + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + disabledBackgroundColor: palette.primary.withOpacity(0.35), + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white.withOpacity(0.68), + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle(fontWeight: FontWeight.w900), + ), + ), + const SizedBox(width: 10), + OutlinedButton( + onPressed: selectedCount == 0 || _isImporting + ? null + : _clearQuestionSelection, + style: OutlinedButton.styleFrom( + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorder), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + '清除', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNoteSaveBottomBar({required AppThemePalette palette}) { + final canSave = _notePreview != null && !_isImporting; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.panelBorder)), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isImporting ? null : _startNoteOrganizeProcess, + style: OutlinedButton.styleFrom( + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorder), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + ), + child: const Text( + '重新整理', + style: TextStyle(fontWeight: FontWeight.w800), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: canSave ? _openSaveNoteDialog : null, + icon: _isImporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.menu_book_rounded, size: 18), + label: Text(_isImporting ? '保存中' : '保存到笔记本'), + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + disabledBackgroundColor: palette.primary.withOpacity(0.35), + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white.withOpacity(0.68), + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + textStyle: const TextStyle(fontWeight: FontWeight.w900), + ), + ), + ), + ], + ), + ); + } + + Widget _buildBottomActions({ + required AppThemePalette palette, + required String leftText, + required String rightText, + required VoidCallback onLeft, + required VoidCallback onRight, + }) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.panelBorder)), + ), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onLeft, + style: OutlinedButton.styleFrom( + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorder), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + ), + child: Text( + leftText, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: onRight, + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 14), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(13), + ), + ), + child: Text( + rightText, + style: const TextStyle(fontWeight: FontWeight.w900), + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyPaper({required AppThemePalette palette}) { + return Container( + width: 260, + height: 360, + decoration: BoxDecoration( + color: palette.emptyPaper, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.all(22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate(12, (i) { + return Container( + margin: const EdgeInsets.only(bottom: 14), + width: i % 3 == 0 ? 180 : 220, + height: 8, + color: palette.emptyPaperLine, + ); + }), + ), + ); + } + + Future? _loadProtectedImage(String? url) { + if (url == null || url.trim().isEmpty) { + return null; + } + return _imageFutures.putIfAbsent( + url, + () => _workspaceApi.loadProtectedImage(url), + ); + } + + String? _resolveImageUrl(String? raw) { + if (raw == null) { + return null; + } + + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + + final base = ApiClient.defaultBaseUrl; + if (base.isEmpty) { + return null; + } + + final normalizedBase = + base.endsWith('/') ? base.substring(0, base.length - 1) : base; + if (trimmed.contains('\\') || RegExp(r'^[A-Za-z]:').hasMatch(trimmed)) { + final filename = trimmed.split(RegExp(r'[\\/]+')).last; + if (filename.isEmpty) { + return null; + } + return '$normalizedBase/api/image/$filename'; + } + if (trimmed.startsWith('/')) { + return '$normalizedBase$trimmed'; + } + return '$normalizedBase/$trimmed'; + } +} + +class _SelectionBadge extends StatelessWidget { + const _SelectionBadge({required this.selected, required this.palette}); + + final bool selected; + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selected ? '已选择' : '未选择', + style: TextStyle( + color: selected ? palette.primaryLight : palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(width: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 26, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? palette.primary : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: selected ? palette.primary : palette.panelBorderStrong, + ), + ), + child: selected + ? const Icon(Icons.check_rounded, color: Colors.white, size: 18) + : null, + ), + ], + ); + } +} + +class _ImportQuestionBankDialog extends StatefulWidget { + const _ImportQuestionBankDialog({ + required this.palette, + required this.projects, + required this.selectedCount, + }); + + final AppThemePalette palette; + final List projects; + final int selectedCount; + + @override + State<_ImportQuestionBankDialog> createState() => + _ImportQuestionBankDialogState(); +} + +class _ImportQuestionBankDialogState extends State<_ImportQuestionBankDialog> { + late WorkspaceProject _selectedProject; + + @override + void initState() { + super.initState(); + _selectedProject = widget.projects.firstWhere( + (project) => project.isDefault, + orElse: () => widget.projects.first, + ); + } + + @override + Widget build(BuildContext context) { + final palette = widget.palette; + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24), + backgroundColor: Colors.transparent, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Container( + decoration: BoxDecoration( + color: palette.isLight + ? Colors.white + : const Color(0xFF18191D).withOpacity(0.98), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: palette.panelBorderStrong), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(30, 24, 24, 18), + child: Row( + children: [ + Container( + width: 46, + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.18), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.storage_rounded, + color: palette.primaryLight, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Text( + '导入错题库', + style: TextStyle( + color: palette.textMain, + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.close_rounded, color: palette.textSub), + ), + ], + ), + ), + Divider(height: 1, color: palette.panelBorderStrong), + Padding( + padding: const EdgeInsets.fromLTRB(30, 16, 30, 12), + child: Text( + '将 ${widget.selectedCount} 道已选题目导入到:', + style: TextStyle( + color: palette.textSub, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(30, 0, 30, 16), + itemCount: widget.projects.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final project = widget.projects[index]; + final selected = project.id == _selectedProject.id; + return _buildProjectOption(project, selected, palette); + }, + ), + ), + Divider(height: 1, color: palette.panelBorderStrong), + Padding( + padding: const EdgeInsets.fromLTRB(30, 20, 30, 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorderStrong), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + '取消', + style: TextStyle(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(_selectedProject), + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + '确认导入', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildProjectOption( + WorkspaceProject project, + bool selected, + AppThemePalette palette, + ) { + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => setState(() => _selectedProject = project), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: selected + ? palette.primary.withOpacity(palette.isLight ? 0.11 : 0.16) + : (palette.isLight + ? const Color(0xFFF7F7FB) + : const Color(0xFF202126)), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? palette.primary.withOpacity(0.72) + : palette.panelBorderStrong, + ), + ), + child: Row( + children: [ + Icon( + Icons.storage_rounded, + color: selected ? palette.primaryLight : palette.textSub, + size: 20, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + project.displayName, + style: TextStyle( + color: selected ? palette.primaryLight : palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + if (selected) + Icon(Icons.check_rounded, color: palette.primaryLight, size: 20), + ], + ), + ), + ); + } +} + +class _SaveNoteDialog extends StatefulWidget { + const _SaveNoteDialog({ + required this.palette, + required this.projects, + required this.preview, + }); + + final AppThemePalette palette; + final List projects; + final NotePreview preview; + + @override + State<_SaveNoteDialog> createState() => _SaveNoteDialogState(); +} + +class _SaveNoteDialogState extends State<_SaveNoteDialog> { + late WorkspaceProject _selectedProject; + + @override + void initState() { + super.initState(); + _selectedProject = widget.projects.firstWhere( + (project) => project.isDefault, + orElse: () => widget.projects.first, + ); + } + + @override + Widget build(BuildContext context) { + final palette = widget.palette; + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24), + backgroundColor: Colors.transparent, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Container( + decoration: BoxDecoration( + color: palette.isLight + ? Colors.white + : const Color(0xFF18191D).withOpacity(0.98), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: palette.panelBorderStrong), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(30, 24, 24, 18), + child: Row( + children: [ + Container( + width: 46, + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withOpacity(0.18), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.menu_book_rounded, + color: palette.primaryLight, + size: 24, + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '保存笔记', + style: TextStyle( + color: palette.textMain, + fontSize: 24, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + widget.preview.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.close_rounded, color: palette.textSub), + ), + ], + ), + ), + Divider(height: 1, color: palette.panelBorderStrong), + Padding( + padding: const EdgeInsets.fromLTRB(30, 16, 30, 12), + child: Text( + '选择要保存到的笔记本:', + style: TextStyle( + color: palette.textSub, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(30, 0, 30, 16), + itemCount: widget.projects.length, + separatorBuilder: (_, __) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final project = widget.projects[index]; + final selected = project.id == _selectedProject.id; + return _buildProjectOption(project, selected, palette); + }, + ), + ), + Divider(height: 1, color: palette.panelBorderStrong), + Padding( + padding: const EdgeInsets.fromLTRB(30, 20, 30, 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorderStrong), + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + '取消', + style: TextStyle(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 12), + ElevatedButton( + onPressed: () => + Navigator.of(context).pop(_selectedProject), + style: ElevatedButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + elevation: 0, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 14, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: const Text( + '确认保存', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildProjectOption( + WorkspaceProject project, + bool selected, + AppThemePalette palette, + ) { + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => setState(() => _selectedProject = project), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: selected + ? palette.primary.withOpacity(palette.isLight ? 0.11 : 0.16) + : (palette.isLight + ? const Color(0xFFF7F7FB) + : const Color(0xFF202126)), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? palette.primary.withOpacity(0.72) + : palette.panelBorderStrong, + ), + ), + child: Row( + children: [ + Icon( + Icons.menu_book_rounded, + color: selected ? palette.primaryLight : palette.textSub, + size: 20, + ), + const SizedBox(width: 14), + Expanded( + child: Text( + project.displayName, + style: TextStyle( + color: selected ? palette.primaryLight : palette.textMain, + fontSize: 15, + fontWeight: FontWeight.w900, + ), + ), + ), + if (selected) + Icon(Icons.check_rounded, color: palette.primaryLight, size: 20), + ], + ), + ), + ); + } +} + +class _DisplayItem { + const _DisplayItem({ + required this.fileKey, + required this.fileName, + this.beforeImageUrl, + this.afterImageUrl, + }); + + final String fileKey; + final String fileName; + final String? beforeImageUrl; + final String? afterImageUrl; +} + +/// 擦除完成预览对比图(左右拖拽) +class EraseImageCompareViewer extends StatefulWidget { + const EraseImageCompareViewer({ + super.key, + required this.beforeImage, + required this.afterImage, + required this.placeholderText, + this.backgroundColor = const Color(0xFF050517), + this.dividerColor = const Color(0xFF8C78FF), + this.textColor = Colors.white70, + }); + + final Future? beforeImage; + final Future? afterImage; + final String placeholderText; + final Color backgroundColor; + final Color dividerColor; + final Color textColor; + + @override + State createState() => + _EraseImageCompareViewerState(); +} + +class _EraseImageCompareViewerState extends State { + late double _value; + + @override + void initState() { + super.initState(); + _value = 0.5; + } + + void _updateValue(Offset localPosition, double width) { + setState(() { + _value = (localPosition.dx / width).clamp(0.0, 1.0); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + color: widget.backgroundColor, + width: double.infinity, + height: double.infinity, + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + final height = constraints.maxHeight; + final dividerX = width * _value; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + _updateValue(details.localPosition, width); + }, + onHorizontalDragUpdate: (details) { + _updateValue(details.localPosition, width); + }, + child: Stack( + alignment: Alignment.center, + children: [ + _CompareImageLayer( + image: widget.beforeImage, + placeholderText: widget.placeholderText, + textColor: widget.textColor, + ), + Positioned.fill( + child: ClipPath( + clipper: _RightSideImageClipper(dividerX), + child: _CompareImageLayer( + image: widget.afterImage, + placeholderText: widget.placeholderText, + textColor: widget.textColor, + ), + ), + ), + Positioned( + left: dividerX - 1, + top: 0, + bottom: 0, + child: Container(width: 2, color: widget.dividerColor), + ), + Positioned( + left: dividerX - 28, + top: height * 0.5 - 28, + child: _DragHandle(color: widget.dividerColor), + ), + ], + ), + ); + }, + ), + ); + } +} + +class _RightSideImageClipper extends CustomClipper { + const _RightSideImageClipper(this.dividerX); + + final double dividerX; + + @override + Path getClip(Size size) { + return Path()..addRect(Rect.fromLTRB(dividerX, 0, size.width, size.height)); + } + + @override + bool shouldReclip(covariant _RightSideImageClipper oldClipper) { + return oldClipper.dividerX != dividerX; + } +} + +class _CompareImageLayer extends StatelessWidget { + const _CompareImageLayer({ + required this.image, + required this.placeholderText, + required this.textColor, + }); + + final Future? image; + final String placeholderText; + final Color textColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(36, 36, 36, 80), + child: Center( + child: image == null + ? _CompareImagePlaceholder( + text: placeholderText, + textColor: textColor, + ) + : _ProtectedImage( + image: image!, + fit: BoxFit.contain, + placeholderText: placeholderText, + textColor: textColor, + ), + ), + ); + } +} + +class _CompareImagePlaceholder extends StatelessWidget { + const _CompareImagePlaceholder({required this.text, required this.textColor}); + + final String text; + final Color textColor; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints.expand(), + alignment: Alignment.center, + color: const Color(0xFF050517).withOpacity(0.55), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF11131A).withOpacity(0.8), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.white.withOpacity(0.2)), + ), + child: Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12).copyWith(color: textColor), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ); + } +} + +class _ProtectedImage extends StatelessWidget { + const _ProtectedImage({ + required this.image, + required this.fit, + required this.placeholderText, + required this.textColor, + }); + + final Future image; + final BoxFit fit; + final String placeholderText; + final Color textColor; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: image, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Image.memory(snapshot.data!, fit: fit); + } + + if (snapshot.hasError) { + return _CompareImagePlaceholder(text: '图片加载失败', textColor: textColor); + } + + return Stack( + alignment: Alignment.center, + children: [ + _CompareImagePlaceholder( + text: placeholderText, + textColor: textColor, + ), + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ); + }, + ); + } +} + +class _DragHandle extends StatelessWidget { + const _DragHandle({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: color.withOpacity(0.35), + blurRadius: 24, + spreadRadius: 4, + ), + ], + ), + child: const Icon( + Icons.swap_horiz_rounded, + color: Colors.white, + size: 28, + ), + ); + } +} + +/// OCR 完成页面里的 OCR 标注图区域 +class _OcrAnnotatedFrame extends StatelessWidget { + const _OcrAnnotatedFrame({ + required this.image, + required this.title, + required this.page, + required this.fallback, + required this.palette, + }); + + final Future? image; + final String title; + final OcrPage? page; + final Widget fallback; + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + final pageWidth = page?.pageWidth ?? 1; + final pageHeight = page?.pageHeight ?? 1; + final aspectRatio = + pageWidth > 0 && pageHeight > 0 ? pageWidth / pageHeight : 0.74; + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 18, 12, 58), + child: Center( + child: AspectRatio( + aspectRatio: aspectRatio, + child: Stack( + fit: StackFit.expand, + children: [ + image == null + ? FittedBox(fit: BoxFit.contain, child: fallback) + : _ProtectedImage( + image: image!, + fit: BoxFit.contain, + placeholderText: title, + textColor: palette.textSub, + ), + Positioned.fill( + child: CustomPaint( + painter: _OcrBoxPainter(isLight: palette.isLight, page: page), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _OcrBoxPainter extends CustomPainter { + const _OcrBoxPainter({required this.isLight, required this.page}); + + final bool isLight; + final OcrPage? page; + + @override + void paint(Canvas canvas, Size size) { + final ocrPage = page; + if (ocrPage == null || + ocrPage.blocks.isEmpty || + ocrPage.pageWidth <= 0 || + ocrPage.pageHeight <= 0) { + return; + } + + final scaleX = size.width / ocrPage.pageWidth; + final scaleY = size.height / ocrPage.pageHeight; + + void rect({ + required Rect rect, + required Color color, + required String label, + }) { + final fill = Paint()..color = color.withOpacity(0.10); + final stroke = Paint() + ..color = color.withOpacity(0.9) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + + canvas.drawRect(rect, fill); + canvas.drawRect(rect, stroke); + + final textPainter = TextPainter( + text: TextSpan( + text: label, + style: TextStyle( + color: Colors.white, + backgroundColor: color.withOpacity(0.95), + fontSize: 9, + fontWeight: FontWeight.w800, + ), + ), + textDirection: TextDirection.ltr, + )..layout(); + + textPainter.paint(canvas, Offset(rect.left, rect.top - 13)); + } + + for (final block in ocrPage.blocks) { + if (!block.hasValidBox) { + continue; + } + + final box = block.bbox; + final left = box[0] * scaleX; + final top = box[1] * scaleY; + final right = box[2] * scaleX; + final bottom = box[3] * scaleY; + rect( + rect: Rect.fromLTRB(left, top, right, bottom), + color: _colorForLabel(block.label), + label: block.label, + ); + } + } + + Color _colorForLabel(String label) { + final normalized = label.toLowerCase(); + if (normalized.contains('table')) { + return isLight ? const Color(0xFFFF5CA6) : const Color(0xFFFF66B3); + } + if (normalized.contains('image')) { + return isLight ? const Color(0xFF23C882) : const Color(0xFF35D98B); + } + if (normalized.contains('formula')) { + return isLight ? const Color(0xFF8090A4) : const Color(0xFF9EA6B8); + } + if (normalized.contains('title')) { + return isLight ? const Color(0xFF8C6CFF) : const Color(0xFFB39DFF); + } + if (normalized.contains('number')) { + return isLight ? const Color(0xFFFF8A3D) : const Color(0xFFFFA15C); + } + if (normalized.contains('header') || normalized.contains('aside')) { + return isLight ? const Color(0xFF607D8B) : const Color(0xFF90A4AE); + } + return isLight ? const Color(0xFF3B77F7) : const Color(0xFF4C8DFF); + } + + @override + bool shouldRepaint(covariant _OcrBoxPainter oldDelegate) { + return oldDelegate.isLight != isLight || oldDelegate.page != page; + } +} + +class _PulseRingPainter extends CustomPainter { + const _PulseRingPainter({required this.t, required this.color}); + + final double t; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + + for (int i = 0; i < 3; i++) { + final localT = (t + i * 0.24) % 1.0; + final radius = 38 + localT * 48; + final opacity = (1 - localT).clamp(0.0, 1.0); + + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + ..color = color.withOpacity(0.34 * opacity); + + canvas.drawCircle(center, radius, paint); + } + } + + @override + bool shouldRepaint(covariant _PulseRingPainter oldDelegate) { + return oldDelegate.t != t || oldDelegate.color != color; + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/split_history_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/split_history_page.dart new file mode 100644 index 00000000..6edbb766 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/split_history_page.dart @@ -0,0 +1,2000 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/network/api_client.dart'; +import '../../../../core/widgets/app_snack_bar.dart'; +import '../../../../core/widgets/markdown_math_text.dart'; +import '../../../../core/widgets/protected_image.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../data/workspace_api.dart'; +import '../../data/workspace_project_store.dart'; + +class SplitHistoryPage extends StatefulWidget { + const SplitHistoryPage({super.key, this.workspaceApi}); + + final WorkspaceApi? workspaceApi; + + @override + State createState() => _SplitHistoryPageState(); +} + +class _SplitHistoryPageState extends State { + late final WorkspaceApi _workspaceApi; + + List _records = const []; + bool _loading = true; + String? _error; + + @override + void initState() { + super.initState(); + _workspaceApi = widget.workspaceApi ?? WorkspaceApi(); + unawaited(_loadRecords()); + } + + Future _loadRecords() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final response = await _workspaceApi.getSplitRecords(limit: 20); + if (!mounted) { + return; + } + setState(() { + _records = response.records; + _loading = false; + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error.message; + _loading = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _error = '分割历史加载失败'; + _loading = false; + }); + } + } + + Future _openRecordDetail(SplitRecord record) async { + final importedCount = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => SplitRecordDetailPage( + recordSummary: record, + workspaceApi: _workspaceApi, + ), + ), + ); + if (!mounted || importedCount == null || importedCount <= 0) { + return; + } + showAppSnackBar(context, '已导入 $importedCount 道题目'); + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _SplitHistoryHeader( + palette: palette, + recordCount: _records.length, + loading: _loading, + onBack: () => Navigator.of(context).maybePop(), + onRefresh: () => unawaited(_loadRecords()), + ), + Expanded(child: _buildBody(palette)), + ], + ), + ), + ), + ); + } + + Widget _buildBody(AppThemePalette palette) { + if (_loading) { + return Center( + child: CircularProgressIndicator( + color: palette.primaryLight, + strokeWidth: 2, + ), + ); + } + + if (_error != null) { + return _SplitHistoryStateView( + palette: palette, + icon: Icons.error_outline_rounded, + title: '加载失败', + message: _error!, + actionText: '重试', + onAction: () => unawaited(_loadRecords()), + ); + } + + if (_records.isEmpty) { + return RefreshIndicator( + color: palette.primary, + onRefresh: _loadRecords, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(24, 80, 24, 24), + children: [ + _SplitHistoryStateView( + palette: palette, + icon: Icons.history_rounded, + title: '暂无分割历史', + message: '完成试卷分割后会显示在这里', + ), + ], + ), + ); + } + + return RefreshIndicator( + color: palette.primary, + onRefresh: _loadRecords, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(18, 14, 18, 24), + itemCount: _records.length, + itemBuilder: (context, index) { + final record = _records[index]; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _SplitRecordTile( + palette: palette, + record: record, + onTap: () => unawaited(_openRecordDetail(record)), + ), + ), + ); + }, + ), + ); + } +} + +class SplitRecordDetailPage extends StatefulWidget { + const SplitRecordDetailPage({ + super.key, + required this.recordSummary, + required this.workspaceApi, + }); + + final SplitRecord recordSummary; + final WorkspaceApi workspaceApi; + + @override + State createState() => _SplitRecordDetailPageState(); +} + +class _SplitRecordDetailPageState extends State { + final Set _selectedQuestionIds = {}; + + SplitRecord? _record; + bool _loading = true; + bool _isImporting = false; + bool _didInitSelection = false; + String? _error; + + SplitRecord get _displayRecord => _record ?? widget.recordSummary; + + List get _questions => _displayRecord.questions; + + List get _orderedQuestionIds => _questions.indexed + .map((entry) => _questionSelectionId(entry.$2, entry.$1)) + .toList(growable: false); + + List get _selectedIdsInQuestionOrder => _orderedQuestionIds + .where(_selectedQuestionIds.contains) + .toList(growable: false); + + int get _selectedCount => _selectedIdsInQuestionOrder.length; + + bool get _allSelected => + _questions.isNotEmpty && _selectedCount == _questions.length; + + @override + void initState() { + super.initState(); + unawaited(_loadRecord()); + } + + Future _loadRecord() async { + setState(() { + _loading = true; + _error = null; + }); + + try { + final response = await widget.workspaceApi.getSplitRecordDetail( + recordId: widget.recordSummary.id, + ); + if (!mounted) { + return; + } + + if (!response.success || response.record == null) { + setState(() { + _error = response.message.isEmpty ? '分割详情加载失败' : response.message; + _loading = false; + }); + return; + } + + final record = response.record!; + setState(() { + _record = record.questions.isEmpty && + widget.recordSummary.questions.isNotEmpty + ? widget.recordSummary + : record; + _syncInitialSelection(_record!.questions); + _loading = false; + }); + } on ApiException catch (error) { + if (!mounted) { + return; + } + setState(() { + _error = error.message; + _loading = false; + }); + } catch (_) { + if (!mounted) { + return; + } + setState(() { + _error = '分割详情加载失败'; + _loading = false; + }); + } + } + + void _syncInitialSelection(List questions) { + if (_didInitSelection) { + return; + } + _selectedQuestionIds + ..clear() + ..addAll( + questions.indexed.map( + (entry) => _questionSelectionId(entry.$2, entry.$1), + ), + ); + _didInitSelection = true; + } + + String _questionSelectionId(SplitQuestion question, int index) { + if (question.uid.trim().isNotEmpty) { + return question.uid.trim(); + } + if (question.questionId.trim().isNotEmpty) { + return question.questionId.trim(); + } + return index.toString(); + } + + void _toggleQuestionSelection(SplitQuestion question, int index) { + final id = _questionSelectionId(question, index); + setState(() { + if (_selectedQuestionIds.contains(id)) { + _selectedQuestionIds.remove(id); + } else { + _selectedQuestionIds.add(id); + } + }); + } + + void _selectAllQuestions() { + setState(() { + _selectedQuestionIds + ..clear() + ..addAll(_orderedQuestionIds); + }); + } + + void _clearQuestionSelection() { + if (_selectedQuestionIds.isEmpty) { + return; + } + setState(() { + _selectedQuestionIds.clear(); + }); + } + + Future _openImportDialog() async { + final selectedIds = _selectedIdsInQuestionOrder; + + if (selectedIds.isEmpty) { + showAppSnackBar(context, '请先选择题目'); + return; + } + + setState(() => _isImporting = true); + await WorkspaceProjectStore.instance.ensureLoaded(); + if (!mounted) { + return; + } + setState(() => _isImporting = false); + + final store = WorkspaceProjectStore.instance; + if (store.questionProjects.isEmpty) { + showAppSnackBar(context, store.errorMessage ?? '暂无可导入的错题库'); + return; + } + + final palette = AppThemePalette.of(context); + final project = await showDialog( + context: context, + barrierDismissible: !_isImporting, + builder: (context) => _SplitImportQuestionBankDialog( + palette: palette, + projects: store.questionProjects, + selectedCount: selectedIds.length, + ), + ); + + if (project == null) { + return; + } + + await _saveSelectedQuestions(project, widget.recordSummary.id, selectedIds); + } + + Future _saveSelectedQuestions( + WorkspaceProject project, + int id, + List selectedIds, + ) async { + setState(() => _isImporting = true); + + try { + final response = await widget.workspaceApi.saveSplitQuestionsToDb( + splitRecordId: id, + projectId: project.id, + selectedIds: selectedIds, + ); + if (!mounted) { + return; + } + + if (!response.success) { + showAppSnackBar( + context, + response.message.isEmpty ? '导入失败' : response.message, + ); + return; + } + + await WorkspaceProjectStore.instance.refresh(); + if (!mounted) { + return; + } + + Navigator.of(context).pop(selectedIds.length); + } on ApiException catch (error) { + if (mounted) { + showAppSnackBar(context, error.message); + } + } catch (_) { + if (mounted) { + showAppSnackBar(context, '导入失败,请稍后重试'); + } + } finally { + if (mounted) { + setState(() => _isImporting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + child: Column( + children: [ + _SplitRecordDetailHeader( + palette: palette, + record: _displayRecord, + loading: _loading, + importing: _isImporting, + onBack: () => Navigator.of(context).maybePop(), + ), + Expanded(child: _buildDetailBody(palette)), + if (!_loading && _error == null && _questions.isNotEmpty) + _SplitRecordBottomBar( + palette: palette, + selectedCount: _selectedCount, + allSelected: _allSelected, + importing: _isImporting, + onToggleSelection: _allSelected + ? _clearQuestionSelection + : _selectAllQuestions, + onImportSelected: () => unawaited(_openImportDialog()), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDetailBody(AppThemePalette palette) { + if (_loading) { + return Center( + child: CircularProgressIndicator( + color: palette.primaryLight, + strokeWidth: 2, + ), + ); + } + + if (_error != null) { + return _SplitHistoryStateView( + palette: palette, + icon: Icons.error_outline_rounded, + title: '加载失败', + message: _error!, + actionText: '重试', + onAction: () => unawaited(_loadRecord()), + ); + } + + if (_questions.isEmpty) { + return _SplitHistoryStateView( + palette: palette, + icon: Icons.assignment_outlined, + title: '暂无题目', + message: '这条分割记录没有返回题目详情', + ); + } + + return ListView.separated( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(18, 14, 18, 28), + itemCount: _questions.length, + separatorBuilder: (context, index) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final question = _questions[index]; + final selected = _selectedQuestionIds.contains( + _questionSelectionId(question, index), + ); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _SplitQuestionCard( + palette: palette, + question: question, + index: index, + selected: selected, + imageUrls: _imageUrlsForQuestion(question), + resolveImageUrl: _resolveImageUrl, + loadImageBytes: widget.workspaceApi.loadProtectedImage, + onToggle: () => _toggleQuestionSelection(question, index), + ), + ), + ); + }, + ); + } + + List _imageUrlsForQuestion(SplitQuestion question) { + final urls = []; + final embeddedUrls = _embeddedImageUrlsForQuestion(question); + + void add(String? value) { + final url = _resolveImageUrl(value); + if (url == null || urls.contains(url) || embeddedUrls.contains(url)) { + return; + } + urls.add(url); + } + + if (embeddedUrls.isEmpty) { + for (final url in question.imageRefs) { + add(url); + } + for (final url in question.optionImages) { + add(url); + } + } + return urls; + } + + Set _embeddedImageUrlsForQuestion(SplitQuestion question) { + final urls = {}; + final imagePattern = RegExp( + r"""]*\bsrc\s*=\s*(['"])(.*?)\1""", + caseSensitive: false, + dotAll: true, + ); + final markdownImagePattern = RegExp(r'!\[[^\]]*\]\(([^)]+)\)'); + + void add(String? value) { + final url = _resolveImageUrl(value); + if (url != null) { + urls.add(url); + } + } + + for (final block in question.contentBlocks) { + if (block.isImage) { + add(block.content); + } + for (final match in imagePattern.allMatches(block.content)) { + add(match.group(2)); + } + for (final match in markdownImagePattern.allMatches(block.content)) { + add(match.group(1)); + } + } + if (question.options.isNotEmpty) { + for (final url in question.optionImages) { + add(url); + } + } + return urls; + } + + String? _resolveImageUrl(String? raw) { + if (raw == null) { + return null; + } + + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) { + return trimmed; + } + + final base = widget.workspaceApi.baseUrl; + if (base.isEmpty) { + return null; + } + + final normalizedBase = + base.endsWith('/') ? base.substring(0, base.length - 1) : base; + if (trimmed.contains('\\') || RegExp(r'^[A-Za-z]:').hasMatch(trimmed)) { + final filename = trimmed.split(RegExp(r'[\\/]+')).last; + if (filename.isEmpty) { + return null; + } + return '$normalizedBase/api/image/$filename'; + } + if (trimmed.startsWith('/')) { + return '$normalizedBase$trimmed'; + } + return '$normalizedBase/$trimmed'; + } +} + +class _SplitHistoryHeader extends StatelessWidget { + const _SplitHistoryHeader({ + required this.palette, + required this.recordCount, + required this.loading, + required this.onBack, + required this.onRefresh, + }); + + final AppThemePalette palette; + final int recordCount; + final bool loading; + final VoidCallback onBack; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.divider)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 8, 10), + child: Row( + children: [ + IconButton( + tooltip: '返回', + onPressed: onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + ), + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.history_rounded, + color: palette.primaryLight, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '分割历史', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 5), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _SplitMetaChip( + palette: palette, + icon: Icons.segment_rounded, + label: '最近记录', + ), + _SplitMetaChip( + palette: palette, + icon: loading + ? Icons.sync_rounded + : Icons.format_list_numbered_rounded, + label: loading ? '同步中' : '$recordCount 条', + ), + ], + ), + ], + ), + ), + IconButton( + tooltip: '刷新', + onPressed: loading ? null : onRefresh, + icon: const Icon(Icons.refresh_rounded), + color: palette.textSub, + ), + ], + ), + ), + ); + } +} + +class _SplitMetaChip extends StatelessWidget { + const _SplitMetaChip({ + required this.palette, + required this.label, + this.icon, + this.highlighted = false, + }); + + final AppThemePalette palette; + final String label; + final IconData? icon; + final bool highlighted; + + @override + Widget build(BuildContext context) { + final color = highlighted ? palette.primary : palette.textSub; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: highlighted + ? palette.primary.withValues(alpha: 0.12) + : palette.badgeBg, + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: highlighted + ? palette.primary.withValues(alpha: 0.22) + : palette.panelBorder, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, color: color, size: 12), + const SizedBox(width: 4), + ], + Text( + label, + style: TextStyle( + color: color, + fontSize: 11, + height: 1, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ); + } +} + +class _SplitRecordTile extends StatelessWidget { + const _SplitRecordTile({ + required this.palette, + required this.record, + required this.onTap, + }); + + final AppThemePalette palette; + final SplitRecord record; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final provider = record.modelProvider?.trim(); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.fromLTRB(16, 14, 14, 14), + decoration: BoxDecoration( + color: palette.cardBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.13), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.description_rounded, + color: palette.primaryLight, + size: 18, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.displaySubject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 15, + height: 1.2, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 7, + runSpacing: 7, + children: [ + _SplitMetaChip( + palette: palette, + icon: Icons.attach_file_rounded, + label: '${record.fileNames.length} 个文件', + ), + _SplitMetaChip( + palette: palette, + icon: Icons.schedule_rounded, + label: _formatRecordTime(record.createdAt), + ), + if (provider != null && provider.isNotEmpty) + _SplitMetaChip( + palette: palette, + icon: Icons.memory_rounded, + label: provider, + ), + ], + ), + ], + ), + ), + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '${record.questionCount} 题', + style: TextStyle( + color: palette.primaryLight, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + color: palette.textSub, + size: 20, + ), + ], + ), + ), + ), + ); + } +} + +class _SplitRecordDetailHeader extends StatelessWidget { + const _SplitRecordDetailHeader({ + required this.palette, + required this.record, + required this.loading, + required this.importing, + required this.onBack, + }); + + final AppThemePalette palette; + final SplitRecord record; + final bool loading; + final bool importing; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: palette.divider)), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + IconButton( + tooltip: '返回', + onPressed: loading || importing ? null : onBack, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + color: palette.textMain, + ), + Container( + width: 42, + height: 42, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.description_rounded, + color: palette.primaryLight, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.displaySubject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textMain, + fontSize: 18, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 5), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _SplitMetaChip( + palette: palette, + icon: Icons.format_list_numbered_rounded, + label: '${record.questionCount} 题', + highlighted: true, + ), + _SplitMetaChip( + palette: palette, + icon: Icons.attach_file_rounded, + label: '${record.fileNames.length} 个文件', + ), + _SplitMetaChip( + palette: palette, + icon: Icons.schedule_rounded, + label: _formatRecordTime(record.createdAt), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _SplitRecordBottomBar extends StatelessWidget { + const _SplitRecordBottomBar({ + required this.palette, + required this.selectedCount, + required this.allSelected, + required this.importing, + required this.onToggleSelection, + required this.onImportSelected, + }); + + final AppThemePalette palette; + final int selectedCount; + final bool allSelected; + final bool importing; + final VoidCallback onToggleSelection; + final VoidCallback onImportSelected; + + @override + Widget build(BuildContext context) { + final disabled = importing; + + return Container( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 14), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: palette.panelBorder)), + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + children: [ + Expanded( + flex: 4, + child: _SplitActionButton( + palette: palette, + label: allSelected ? '取消' : '全选', + icon: allSelected + ? Icons.check_box_outline_blank_rounded + : Icons.select_all_rounded, + onPressed: disabled ? null : onToggleSelection, + ), + ), + const SizedBox(width: 8), + Expanded( + flex: 7, + child: _SplitPrimaryActionButton( + palette: palette, + label: importing ? '导入中' : '导入错题库', + selectedCount: selectedCount, + importing: importing, + onPressed: disabled || selectedCount == 0 + ? null + : onImportSelected, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SplitActionButton extends StatelessWidget { + const _SplitActionButton({ + required this.palette, + required this.label, + required this.icon, + required this.onPressed, + }); + + final AppThemePalette palette; + final String label; + final IconData icon; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final compact = MediaQuery.sizeOf(context).width < 420; + return OutlinedButton.icon( + onPressed: onPressed, + icon: Icon(icon, size: 16), + label: Text(label), + style: OutlinedButton.styleFrom( + minimumSize: const Size(0, 38), + padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 10), + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorderStrong), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w900), + ), + ); + } +} + +class _SplitPrimaryActionButton extends StatelessWidget { + const _SplitPrimaryActionButton({ + required this.palette, + required this.label, + required this.selectedCount, + required this.importing, + required this.onPressed, + }); + + final AppThemePalette palette; + final String label; + final int selectedCount; + final bool importing; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final compact = MediaQuery.sizeOf(context).width < 420; + return FilledButton.icon( + onPressed: onPressed, + icon: importing + ? const SizedBox( + width: 15, + height: 15, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.storage_rounded, size: 17), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text('$label $selectedCount'), + ), + style: FilledButton.styleFrom( + minimumSize: const Size(0, 38), + padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 10), + backgroundColor: palette.primary, + disabledBackgroundColor: palette.primary.withValues(alpha: 0.35), + foregroundColor: Colors.white, + disabledForegroundColor: Colors.white.withValues(alpha: 0.68), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + textStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w900), + ), + ); + } +} + +class _SplitImportQuestionBankDialog extends StatefulWidget { + const _SplitImportQuestionBankDialog({ + required this.palette, + required this.projects, + required this.selectedCount, + }); + + final AppThemePalette palette; + final List projects; + final int selectedCount; + + @override + State<_SplitImportQuestionBankDialog> createState() => + _SplitImportQuestionBankDialogState(); +} + +class _SplitImportQuestionBankDialogState + extends State<_SplitImportQuestionBankDialog> { + late WorkspaceProject _selectedProject; + + @override + void initState() { + super.initState(); + _selectedProject = widget.projects.firstWhere( + (project) => project.isDefault, + orElse: () => widget.projects.first, + ); + } + + @override + Widget build(BuildContext context) { + final palette = widget.palette; + + return Dialog( + insetPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 24), + backgroundColor: Colors.transparent, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 560), + child: Container( + decoration: BoxDecoration( + color: palette.isLight + ? Colors.white + : const Color(0xFF18191D).withValues(alpha: 0.98), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorderStrong), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 12, 14), + child: Row( + children: [ + Container( + width: 38, + height: 38, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(9), + ), + child: Icon( + Icons.storage_rounded, + color: palette.primaryLight, + size: 21, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '导入错题库', + style: TextStyle( + color: palette.textMain, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + ), + IconButton( + tooltip: '关闭', + onPressed: () => Navigator.of(context).pop(), + icon: Icon(Icons.close_rounded, color: palette.textSub), + ), + ], + ), + ), + Divider(height: 1, color: palette.panelBorderStrong), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 10), + child: Text( + '将 ${widget.selectedCount} 道已选题目导入到:', + style: TextStyle( + color: palette.textSub, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(20, 0, 20, 14), + itemCount: widget.projects.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final project = widget.projects[index]; + final selected = project.id == _selectedProject.id; + return _buildProjectOption(project, selected, palette); + }, + ), + ), + Divider(height: 1, color: palette.panelBorderStrong), + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + foregroundColor: palette.textMain, + side: BorderSide(color: palette.panelBorderStrong), + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '取消', + style: TextStyle(fontWeight: FontWeight.w800), + ), + ), + const SizedBox(width: 10), + FilledButton( + onPressed: () => + Navigator.of(context).pop(_selectedProject), + style: FilledButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text( + '确认导入', + style: TextStyle(fontWeight: FontWeight.w900), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildProjectOption( + WorkspaceProject project, + bool selected, + AppThemePalette palette, + ) { + final selectedBg = palette.primary.withValues( + alpha: palette.isLight ? 0.10 : 0.16, + ); + + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => setState(() => _selectedProject = project), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: selected ? selectedBg : palette.badgeBg, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected + ? palette.primary.withValues(alpha: 0.72) + : palette.panelBorderStrong, + ), + ), + child: Row( + children: [ + Icon( + Icons.folder_rounded, + color: selected ? palette.primaryLight : palette.textSub, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + project.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: selected ? palette.primaryLight : palette.textMain, + fontSize: 14, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + '${project.questionCount} 题', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + if (selected) + Icon(Icons.check_rounded, color: palette.primaryLight, size: 20), + ], + ), + ), + ); + } +} + +class _SplitQuestionCard extends StatelessWidget { + const _SplitQuestionCard({ + required this.palette, + required this.question, + required this.index, + required this.selected, + required this.imageUrls, + required this.resolveImageUrl, + required this.loadImageBytes, + required this.onToggle, + }); + + final AppThemePalette palette; + final SplitQuestion question; + final int index; + final bool selected; + final List imageUrls; + final String? Function(String? raw) resolveImageUrl; + final Future Function(String url) loadImageBytes; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final contentBlocks = question.contentBlocks + .where((block) => block.content.trim().isNotEmpty) + .toList(growable: false); + final contentStyle = TextStyle( + color: palette.textMain, + fontSize: 13, + height: 1.55, + fontWeight: FontWeight.w600, + ); + final contentWidgets = contentBlocks.isEmpty + ? [ + Text( + '暂无题干内容', + style: contentStyle.copyWith(color: palette.textSub), + ), + ] + : contentBlocks + .map( + (block) => Padding( + padding: const EdgeInsets.only(bottom: 7), + child: block.isImage + ? _QuestionMarkdownImage( + palette: palette, + url: block.content, + alt: '题目图片', + resolveImageUrl: resolveImageUrl, + loadImageBytes: loadImageBytes, + ) + : _QuestionContentBlock( + content: block.content, + palette: palette, + resolveImageUrl: resolveImageUrl, + loadImageBytes: loadImageBytes, + style: contentStyle, + ), + ), + ) + .toList(growable: false); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onToggle, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: selected + ? palette.primary.withValues( + alpha: palette.isLight ? 0.07 : 0.12, + ) + : palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: selected + ? palette.primary.withValues(alpha: 0.45) + : palette.panelBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _SelectionBox(selected: selected, palette: palette), + const SizedBox(width: 10), + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + question.questionId.isEmpty + ? '${index + 1}' + : question.questionId, + style: TextStyle( + color: palette.primary, + fontSize: 12, + fontWeight: FontWeight.w900, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Wrap( + spacing: 6, + runSpacing: 6, + children: [ + _QuestionMetaChip( + palette: palette, + icon: Icons.article_outlined, + label: question.questionType ?? '题目', + ), + if (question.hasFormula) + _QuestionIconChip( + palette: palette, + icon: Icons.functions_rounded, + tooltip: '含公式', + ), + if (question.hasImage || imageUrls.isNotEmpty) + _QuestionIconChip( + palette: palette, + icon: Icons.image_rounded, + tooltip: '含图片', + ), + ], + ), + ), + ], + ), + if (question.sectionTitle != null && + question.sectionTitle!.trim().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + question.sectionTitle!.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 11, + height: 1.35, + fontWeight: FontWeight.w700, + ), + ), + ], + const SizedBox(height: 10), + ...contentWidgets, + if (question.options.isNotEmpty) ...[ + const SizedBox(height: 2), + _QuestionOptions( + palette: palette, + options: question.options, + optionImages: question.optionImages, + resolveImageUrl: resolveImageUrl, + loadImageBytes: loadImageBytes, + ), + ], + if (imageUrls.isNotEmpty) ...[ + const SizedBox(height: 8), + _QuestionImages( + palette: palette, + imageUrls: imageUrls, + loadImageBytes: loadImageBytes, + ), + ], + if (question.knowledgeTags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 6, + runSpacing: 6, + children: question.knowledgeTags + .map((tag) => _QuestionTag(palette: palette, tag: tag)) + .toList(growable: false), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _QuestionOptions extends StatelessWidget { + const _QuestionOptions({ + required this.palette, + required this.options, + required this.optionImages, + required this.resolveImageUrl, + required this.loadImageBytes, + }); + + final AppThemePalette palette; + final List options; + final List optionImages; + final String? Function(String? raw) resolveImageUrl; + final Future Function(String url) loadImageBytes; + + @override + Widget build(BuildContext context) { + return Column( + children: List.generate(options.length, (index) { + final option = options[index]; + final imageUrl = + index < optionImages.length ? optionImages[index] : null; + final hasImage = imageUrl != null && imageUrl.trim().isNotEmpty; + + return Container( + width: double.infinity, + margin: const EdgeInsets.only(bottom: 7), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: palette.badgeBg, + borderRadius: BorderRadius.circular(7), + border: Border.all(color: palette.panelBorder), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasImage) ...[ + _QuestionMarkdownImage( + palette: palette, + url: imageUrl, + alt: '选项图片', + resolveImageUrl: resolveImageUrl, + loadImageBytes: loadImageBytes, + width: 92, + height: 70, + maxHeight: 70, + ), + const SizedBox(width: 9), + ], + Expanded( + child: MarkdownMathText( + text: option, + palette: palette, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + height: 1.45, + fontWeight: FontWeight.w700, + ), + imageBuilder: (context, alt, url) { + return _QuestionMarkdownImage( + palette: palette, + url: url, + alt: alt, + resolveImageUrl: resolveImageUrl, + loadImageBytes: loadImageBytes, + maxHeight: 130, + ); + }, + ), + ), + ], + ), + ); + }), + ); + } +} + +class _QuestionContentBlock extends StatelessWidget { + const _QuestionContentBlock({ + required this.content, + required this.palette, + required this.resolveImageUrl, + required this.loadImageBytes, + required this.style, + }); + + final String content; + final AppThemePalette palette; + final String? Function(String? raw) resolveImageUrl; + final Future Function(String url) loadImageBytes; + final TextStyle style; + + @override + Widget build(BuildContext context) { + return MarkdownMathText( + text: content, + palette: palette, + style: style, + imageBuilder: (context, alt, url) { + return _QuestionMarkdownImage( + palette: palette, + url: url, + alt: alt, + resolveImageUrl: resolveImageUrl, + loadImageBytes: loadImageBytes, + ); + }, + ); + } +} + +class _QuestionMarkdownImage extends StatelessWidget { + const _QuestionMarkdownImage({ + required this.palette, + required this.url, + required this.alt, + required this.resolveImageUrl, + required this.loadImageBytes, + this.width, + this.height, + this.maxHeight = 180, + }); + + final AppThemePalette palette; + final String url; + final String alt; + final String? Function(String? raw) resolveImageUrl; + final Future Function(String url) loadImageBytes; + final double? width; + final double? height; + final double maxHeight; + + @override + Widget build(BuildContext context) { + final resolvedUrl = resolveImageUrl(url); + if (resolvedUrl == null) { + return Text( + alt.trim().isEmpty ? '图片地址无效' : alt, + style: TextStyle( + color: palette.textSub, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ); + } + + return Container( + width: width ?? double.infinity, + height: height, + constraints: height == null + ? BoxConstraints(minHeight: 86, maxHeight: maxHeight) + : null, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: palette.imageBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.panelBorder), + ), + child: ProtectedImage( + url: resolvedUrl, + loadBytes: loadImageBytes, + fit: BoxFit.contain, + loading: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: palette.primaryLight, + strokeWidth: 2, + ), + ), + ), + error: Center( + child: Icon( + Icons.broken_image_outlined, + color: palette.textSub, + size: 22, + ), + ), + ), + ); + } +} + +class _QuestionImages extends StatelessWidget { + const _QuestionImages({ + required this.palette, + required this.imageUrls, + required this.loadImageBytes, + }); + + final AppThemePalette palette; + final List imageUrls; + final Future Function(String url) loadImageBytes; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: imageUrls + .map( + (url) => Container( + width: 118, + height: 88, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: palette.imageBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: palette.panelBorder), + ), + child: ProtectedImage( + url: url, + loadBytes: loadImageBytes, + fit: BoxFit.contain, + loading: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: palette.primaryLight, + strokeWidth: 2, + ), + ), + ), + error: Center( + child: Icon( + Icons.broken_image_outlined, + color: palette.textSub, + size: 22, + ), + ), + ), + ), + ) + .toList(growable: false), + ); + } +} + +class _SelectionBox extends StatelessWidget { + const _SelectionBox({required this.selected, required this.palette}); + + final bool selected; + final AppThemePalette palette; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? palette.primary : Colors.transparent, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: selected ? palette.primary : palette.panelBorderStrong, + ), + ), + child: selected + ? const Icon(Icons.check_rounded, color: Colors.white, size: 15) + : null, + ); + } +} + +class _QuestionMetaChip extends StatelessWidget { + const _QuestionMetaChip({ + required this.palette, + required this.icon, + required this.label, + }); + + final AppThemePalette palette; + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: palette.badgeBg, + borderRadius: BorderRadius.circular(5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 11, color: palette.textSub), + const SizedBox(width: 3), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: palette.textSub, + fontSize: 10, + height: 1, + fontWeight: FontWeight.w900, + ), + ), + ), + ], + ), + ); + } +} + +class _QuestionIconChip extends StatelessWidget { + const _QuestionIconChip({ + required this.palette, + required this.icon, + required this.tooltip, + }); + + final AppThemePalette palette; + final IconData icon; + final String tooltip; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: tooltip, + child: Container( + width: 20, + height: 20, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(5), + ), + child: Icon(icon, size: 12, color: palette.primaryLight), + ), + ); + } +} + +class _QuestionTag extends StatelessWidget { + const _QuestionTag({required this.palette, required this.tag}); + + final AppThemePalette palette; + final String tag; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(5), + ), + child: Text( + tag, + style: TextStyle( + color: palette.primary, + fontSize: 10, + fontWeight: FontWeight.w900, + ), + ), + ); + } +} + +class _SplitHistoryStateView extends StatelessWidget { + const _SplitHistoryStateView({ + required this.palette, + required this.icon, + required this.title, + required this.message, + this.actionText, + this.onAction, + }); + + final AppThemePalette palette; + final IconData icon; + final String title; + final String message; + final String? actionText; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 320), + margin: const EdgeInsets.symmetric(horizontal: 24), + padding: const EdgeInsets.fromLTRB(22, 24, 22, 22), + decoration: BoxDecoration( + color: palette.panelBg, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: palette.panelBorder), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: palette.primary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, color: palette.primaryLight, size: 24), + ), + const SizedBox(height: 14), + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textMain, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 6), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + color: palette.textSub, + fontSize: 13, + height: 1.35, + fontWeight: FontWeight.w700, + ), + ), + if (actionText != null && onAction != null) ...[ + const SizedBox(height: 14), + FilledButton.icon( + onPressed: onAction, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text(actionText!), + style: FilledButton.styleFrom( + backgroundColor: palette.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ], + ), + ), + ); + } +} + +String _formatRecordTime(DateTime? time) { + if (time == null) { + return '暂无时间'; + } + + final local = time.toLocal(); + final month = local.month.toString().padLeft(2, '0'); + final day = local.day.toString().padLeft(2, '0'); + final hour = local.hour.toString().padLeft(2, '0'); + final minute = local.minute.toString().padLeft(2, '0'); + return '$month-$day $hour:$minute'; +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/workspace_bottom_navigation_bar.dart b/apps/mobile/lib/features/workspace/presentation/pages/workspace_bottom_navigation_bar.dart new file mode 100644 index 00000000..fc78f7c3 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/workspace_bottom_navigation_bar.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class WorkspaceBottomNavigationBar extends StatelessWidget { + const WorkspaceBottomNavigationBar({ + required this.selectedIndex, + required this.onDestinationSelected, + super.key, + }); + + final int selectedIndex; + final ValueChanged onDestinationSelected; + + @override + Widget build(BuildContext context) { + return NavigationBar( + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + destinations: const [ + NavigationDestination( + icon: Icon(Icons.auto_awesome_rounded), + label: '智能录入', + ), + NavigationDestination( + icon: Icon(Icons.collections_bookmark_rounded), + label: '库', + ), + NavigationDestination( + icon: Icon(Icons.lightbulb_outline_rounded), + label: '台灯', + ), + NavigationDestination( + icon: Icon(Icons.chat_bubble_outline_rounded), + label: '对话', + ), + NavigationDestination( + icon: Icon(Icons.person_outline_rounded), + label: '我的', + ), + ], + ); + } +} diff --git a/apps/mobile/lib/features/workspace/presentation/pages/workspace_page.dart b/apps/mobile/lib/features/workspace/presentation/pages/workspace_page.dart new file mode 100644 index 00000000..0b96ee65 --- /dev/null +++ b/apps/mobile/lib/features/workspace/presentation/pages/workspace_page.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../app/theme/app_theme.dart'; +import '../../../../core/widgets/starry_background.dart'; +import '../../../auth/data/auth_api.dart'; +import 'chat_page.dart'; +import 'lamp_page.dart'; +import 'library_page.dart'; +import 'profile_page.dart'; +import 'smart_input_page.dart'; +import 'workspace_bottom_navigation_bar.dart'; + +class WorkspacePage extends StatefulWidget { + const WorkspacePage({ + super.key, + this.authApi, + this.themeModeListenable, + this.onToggleThemeMode, + }); + + final AuthApi? authApi; + final ValueListenable? themeModeListenable; + final VoidCallback? onToggleThemeMode; + + @override + State createState() => _WorkspacePageState(); +} + +class _WorkspacePageState extends State { + int _currentIndex = 0; + late final AuthApi _authApi; + ValueNotifier? _localThemeModeNotifier; + + ValueListenable get _themeModeListenable => + widget.themeModeListenable ?? _localThemeModeNotifier!; + + @override + void initState() { + super.initState(); + _authApi = widget.authApi ?? AuthApi(); + if (widget.themeModeListenable == null) { + _localThemeModeNotifier = ValueNotifier(ThemeMode.dark); + } + } + + @override + void dispose() { + _localThemeModeNotifier?.dispose(); + super.dispose(); + } + + void _toggleLocalThemeMode() { + final notifier = _localThemeModeNotifier; + if (notifier == null) { + return; + } + + notifier.value = + notifier.value == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark; + } + + @override + Widget build(BuildContext context) { + final palette = AppThemePalette.of(context); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: StarryBackground( + showHomeOrnaments: false, + child: SafeArea( + top: true, + child: Column( + children: [ + Expanded(child: _buildActiveTabView(palette: palette)), + WorkspaceBottomNavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: (index) { + setState(() { + _currentIndex = index; + }); + }, + ), + ], + ), + ), + ), + ); + } + + Widget _buildActiveTabView({required AppThemePalette palette}) { + final current = switch (_currentIndex) { + 0 => const SmartInputPage(), + 1 => const LibraryPage(), + 2 => const LampPage(), + 3 => const ChatPage(), + _ => ProfilePage( + authApi: _authApi, + themeModeListenable: _themeModeListenable, + onToggleThemeMode: widget.onToggleThemeMode ?? _toggleLocalThemeMode, + ), + }; + + if (_currentIndex == 0) { + return current; + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 0), + child: current, + ), + ); + } +} diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart new file mode 100644 index 00000000..8822a38b --- /dev/null +++ b/apps/mobile/lib/main.dart @@ -0,0 +1,7 @@ +import 'package:flutter/material.dart'; + +import 'app/app.dart'; + +export 'app/app.dart' show MyApp; + +void main() => runApp(const MyApp()); diff --git a/apps/mobile/linux/.gitignore b/apps/mobile/linux/.gitignore new file mode 100644 index 00000000..d3896c98 --- /dev/null +++ b/apps/mobile/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/mobile/linux/CMakeLists.txt b/apps/mobile/linux/CMakeLists.txt new file mode 100644 index 00000000..b390ec5c --- /dev/null +++ b/apps/mobile/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "error_log_app") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.error_log_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/mobile/linux/flutter/CMakeLists.txt b/apps/mobile/linux/flutter/CMakeLists.txt new file mode 100644 index 00000000..d5bd0164 --- /dev/null +++ b/apps/mobile/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/apps/mobile/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..e71a16d2 --- /dev/null +++ b/apps/mobile/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/apps/mobile/linux/flutter/generated_plugin_registrant.h b/apps/mobile/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..e0f0a47b --- /dev/null +++ b/apps/mobile/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/mobile/linux/flutter/generated_plugins.cmake b/apps/mobile/linux/flutter/generated_plugins.cmake new file mode 100644 index 00000000..2e1de87a --- /dev/null +++ b/apps/mobile/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/mobile/linux/runner/CMakeLists.txt b/apps/mobile/linux/runner/CMakeLists.txt new file mode 100644 index 00000000..e97dabc7 --- /dev/null +++ b/apps/mobile/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/mobile/linux/runner/main.cc b/apps/mobile/linux/runner/main.cc new file mode 100644 index 00000000..e7c5c543 --- /dev/null +++ b/apps/mobile/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/mobile/linux/runner/my_application.cc b/apps/mobile/linux/runner/my_application.cc new file mode 100644 index 00000000..ee956068 --- /dev/null +++ b/apps/mobile/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "error_log_app"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "error_log_app"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/apps/mobile/linux/runner/my_application.h b/apps/mobile/linux/runner/my_application.h new file mode 100644 index 00000000..72271d5e --- /dev/null +++ b/apps/mobile/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/mobile/macos/.gitignore b/apps/mobile/macos/.gitignore new file mode 100644 index 00000000..746adbb6 --- /dev/null +++ b/apps/mobile/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/apps/mobile/macos/Flutter/Flutter-Debug.xcconfig b/apps/mobile/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/apps/mobile/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/mobile/macos/Flutter/Flutter-Release.xcconfig b/apps/mobile/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 00000000..c2efd0b6 --- /dev/null +++ b/apps/mobile/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 00000000..376b1d91 --- /dev/null +++ b/apps/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,16 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import file_picker +import reactive_ble_mobile +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + ReactiveBlePlugin.register(with: registry.registrar(forPlugin: "ReactiveBlePlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/apps/mobile/macos/Runner.xcodeproj/project.pbxproj b/apps/mobile/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..992dd0b8 --- /dev/null +++ b/apps/mobile/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* error_log_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "error_log_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* error_log_app.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* error_log_app.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/error_log_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/error_log_app"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/error_log_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/error_log_app"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/error_log_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/error_log_app"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/apps/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..35af59aa --- /dev/null +++ b/apps/mobile/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/apps/mobile/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/apps/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/apps/mobile/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/mobile/macos/Runner/AppDelegate.swift b/apps/mobile/macos/Runner/AppDelegate.swift new file mode 100644 index 00000000..b3c17614 --- /dev/null +++ b/apps/mobile/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..a2ec33f1 --- /dev/null +++ b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 00000000..82b6f9d9 Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 00000000..13b35eba Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 00000000..0a3f5fa4 Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 00000000..bdb57226 Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 00000000..f083318e Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 00000000..326c0e72 Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 00000000..2f1632cf Binary files /dev/null and b/apps/mobile/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/apps/mobile/macos/Runner/Base.lproj/MainMenu.xib b/apps/mobile/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 00000000..80e867a4 --- /dev/null +++ b/apps/mobile/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/mobile/macos/Runner/Configs/AppInfo.xcconfig b/apps/mobile/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 00000000..b2df03f9 --- /dev/null +++ b/apps/mobile/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = error_log_app + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.errorLogApp + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/apps/mobile/macos/Runner/Configs/Debug.xcconfig b/apps/mobile/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 00000000..36b0fd94 --- /dev/null +++ b/apps/mobile/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/mobile/macos/Runner/Configs/Release.xcconfig b/apps/mobile/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 00000000..dff4f495 --- /dev/null +++ b/apps/mobile/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/mobile/macos/Runner/Configs/Warnings.xcconfig b/apps/mobile/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 00000000..42bcbf47 --- /dev/null +++ b/apps/mobile/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/apps/mobile/macos/Runner/DebugProfile.entitlements b/apps/mobile/macos/Runner/DebugProfile.entitlements new file mode 100644 index 00000000..dddb8a30 --- /dev/null +++ b/apps/mobile/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/apps/mobile/macos/Runner/Info.plist b/apps/mobile/macos/Runner/Info.plist new file mode 100644 index 00000000..4789daa6 --- /dev/null +++ b/apps/mobile/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/apps/mobile/macos/Runner/MainFlutterWindow.swift b/apps/mobile/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 00000000..3cc05eb2 --- /dev/null +++ b/apps/mobile/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/apps/mobile/macos/Runner/Release.entitlements b/apps/mobile/macos/Runner/Release.entitlements new file mode 100644 index 00000000..852fa1a4 --- /dev/null +++ b/apps/mobile/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/apps/mobile/macos/RunnerTests/RunnerTests.swift b/apps/mobile/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 00000000..61f3bd1f --- /dev/null +++ b/apps/mobile/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock new file mode 100644 index 00000000..470688a6 --- /dev/null +++ b/apps/mobile/pubspec.lock @@ -0,0 +1,626 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.3.4+2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.0.7" + cryptography: + dependency: transitive + description: + name: cryptography + sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.9.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.7.12" + esp_provisioning_ble: + dependency: "direct main" + description: + name: esp_provisioning_ble + sha256: "8ec1f414299cd095a3bd17aae3ce0259be8f700beb0ee290081c63d40cf8d2e8" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.3" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "11.0.2" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.0.0" + flutter_math_fork: + dependency: "direct main" + description: + name: flutter_math_fork + sha256: "6d5f2f1aa57ae539ffb0a04bb39d2da67af74601d685a161aff7ce5bda5fa407" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.7.4" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "1c2b787f99bdca1f3718543f81d38aa1b124817dfeb9fb196201bea85b6134bf" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.0.26" + flutter_reactive_ble: + dependency: "direct main" + description: + name: flutter_reactive_ble + sha256: "8a5ef9a1631f1fb3470afb89e5c0da7bc957dda14e738892281021a557eb686c" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.4.1" + flutter_svg: + dependency: "direct main" + description: + name: flutter_svg + sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + functional_data: + dependency: transitive + description: + name: functional_data + sha256: "76d17dc707c40e552014f5a49c0afcc3f1e3f05e800cd6b7872940bfe41a5039" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.2.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.0.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.0.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.16.0" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.8" + protobuf: + dependency: transitive + description: + name: protobuf + sha256: "68645b24e0716782e58948f8467fd42a880f255096a821f9e7d0ec625b00c84d" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "3.1.0" + provider: + dependency: transitive + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.1.5+1" + reactive_ble_mobile: + dependency: transitive + description: + name: reactive_ble_mobile + sha256: "69d901f1edceccd1adbb0d88e874503b4649860d090a8150c86c2071d86be7d8" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.4.1" + reactive_ble_platform_interface: + dependency: "direct overridden" + description: + name: reactive_ble_platform_interface + sha256: bdc1c5fa5dbead78f1d1f6e9af3d06a0398f108262bc40f81a56766e4e722d34 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.4.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "9f9f3d372d4304723e6136663bb291c0b93f5e4c8a4a6314347f481a33bda2b1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.7" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "6a52cfcdaeac77cad8c97b539ff688ccfc458c007b4db12be584fbe5c0e49e03" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.5.4" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "0.7.6" + tuple: + dependency: transitive + description: + name: tuple + sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.4.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.18" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.16" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "14.2.5" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: daf97c9d80197ed7b619040e86c8ab9a9dad285e7671ee7390f9180cc828a51e + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "5.10.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://mirrors.tuna.tsinghua.edu.cn/dart-pub/" + source: hosted + version: "6.5.0" +sdks: + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.24.0" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml new file mode 100644 index 00000000..e2f12b3e --- /dev/null +++ b/apps/mobile/pubspec.yaml @@ -0,0 +1,104 @@ +name: error_log_app +description: "A new Flutter project." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ">=3.5.0 <4.0.0" + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + permission_handler: ^12.0.1 + flutter_reactive_ble: ^5.4.1 + flutter_svg: ^2.1.0 + shared_preferences: ^2.5.3 + flutter_secure_storage: ^9.2.4 + http: ^1.6.0 + file_picker: ^11.0.2 + flutter_math_fork: ^0.7.4 + esp_provisioning_ble: 1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +dependency_overrides: + reactive_ble_platform_interface: 5.4.1 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/ + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/apps/mobile/test/auth_api_test.dart b/apps/mobile/test/auth_api_test.dart new file mode 100644 index 00000000..f359e655 --- /dev/null +++ b/apps/mobile/test/auth_api_test.dart @@ -0,0 +1,324 @@ +import 'dart:convert'; + +import 'package:error_log_app/core/network/api_client.dart'; +import 'package:error_log_app/features/auth/data/auth_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_session_store.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('登录成功后保存后端下发的 session cookie', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/auth/login'); + expect(jsonDecode(request.body), { + 'identifier': 'student@example.com', + 'password': '123456', + }); + + return http.Response( + jsonEncode({ + 'user': { + 'id': 1, + 'email': 'student@example.com', + 'username': 'student01', + 'is_admin': false, + 'quota': {}, + }, + }), + 200, + headers: {'set-cookie': 'session=abc123; HttpOnly; Path=/'}, + ); + }), + ); + + final api = AuthApi(client: client); + final user = await api.login( + identifier: 'student@example.com', + password: '123456', + ); + expect(user.email, 'student@example.com'); + expect(await client.hasSession(), isTrue); + }); + + test('注册使用后端要求的字段并返回用户', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/auth/register'); + expect(jsonDecode(request.body), { + 'email': 'student@example.com', + 'username': 'student01', + 'password': '123456', + 'code': '654321', + }); + + return http.Response( + jsonEncode({ + 'success': true, + 'user': { + 'id': 2, + 'email': 'student@example.com', + 'username': 'student01', + 'is_admin': false, + 'quota': {}, + }, + }), + 201, + headers: {'set-cookie': 'session=registered; Path=/'}, + ); + }), + ); + + final api = AuthApi(client: client); + final user = await api.register( + email: 'student@example.com', + username: 'student01', + password: '123456', + code: '654321', + ); + expect(user.id, 2); + expect(await client.hasSession(), isTrue); + }); + + test('发送验证码失败时抛出后端错误信息', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + return http.Response.bytes( + utf8.encode(jsonEncode({'success': false, 'error': '邮箱格式错误'})), + 400, + ); + }), + ); + + final api = AuthApi(client: client); + + expect( + () => api.sendCode(email: 'bad-email', type: 'register'), + throwsA( + isA().having( + (error) => error.message, + 'message', + '邮箱格式错误', + ), + ), + ); + }); + + test('可以判断本地是否已有 session cookie', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + ); + + expect(await client.hasSession(), isFalse); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(ApiClient.sessionCookieKey, 'session=abc123'); + + expect(await client.hasSession(), isTrue); + }); + + test('更新当前用户资料时发送 PATCH 请求并携带 session cookie', () async { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'PATCH'); + expect(request.url.path, '/api/auth/profile'); + expect(request.headers['cookie'], 'session=abc123'); + expect(jsonDecode(request.body), { + 'display_name': 'Admin', + 'nickname': '数学冲刺版', + }); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '更新成功'})), + 200, + ); + }), + ); + + final api = AuthApi(client: client); + final response = await api.updateProfile( + displayName: 'Admin', + nickname: '数学冲刺版', + ); + + expect(response.success, isTrue); + expect(response.message, '更新成功'); + }); + + test('上传头像时使用 multipart file 字段并携带 session cookie', () async { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/auth/profile/avatar'); + expect(request.headers['cookie'], 'session=abc123'); + expect( + request.headers['content-type'], + contains('multipart/form-data'), + ); + + final bodyText = utf8.decode(request.bodyBytes, allowMalformed: true); + expect(bodyText, contains('name="file"')); + expect(bodyText, contains('avatar.png')); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '上传成功'})), + 200, + ); + }), + ); + + final api = AuthApi(client: client); + final response = await api.uploadAvatar( + filename: 'avatar.png', + bytes: const [1, 2, 3], + ); + + expect(response.success, isTrue); + expect(response.message, '上传成功'); + }); + + test('删除头像时发送 DELETE 请求', () async { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'DELETE'); + expect(request.url.path, '/api/auth/profile/avatar'); + expect(request.headers['cookie'], 'session=abc123'); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '删除成功'})), + 200, + ); + }), + ); + + final api = AuthApi(client: client); + final response = await api.deleteAvatar(); + + expect(response.success, isTrue); + expect(response.message, '删除成功'); + }); + + test('跨域图片下载不会携带 session 或覆盖本地 session', () async { + final store = TestSessionStore('session=abc123'); + final client = ApiClient( + baseUrl: 'https://server.test', + sessionStore: store, + httpClient: MockClient((request) async { + expect(request.url.host, 'cdn.example'); + expect(request.headers.containsKey('cookie'), isFalse); + return http.Response.bytes( + const [1, 2, 3], + 200, + headers: {'set-cookie': 'session=attacker; Path=/'}, + ); + }), + ); + + expect( + await client.getBytes('https://cdn.example/avatar.png'), + orderedEquals(const [1, 2, 3]), + ); + expect(await store.read(), 'session=abc123'); + }); + + test('同源绝对 URL 会携带 session,不同端口不会携带', () async { + final store = TestSessionStore('session=abc123'); + final requests = []; + final client = ApiClient( + baseUrl: 'https://server.test', + sessionStore: store, + httpClient: MockClient((request) async { + requests.add(request); + return http.Response.bytes(const [1], 200); + }), + ); + + await client.getBytes('https://server.test/avatar.png'); + await client.getBytes('https://server.test:8443/avatar.png'); + + expect(requests[0].headers['cookie'], 'session=abc123'); + expect(requests[1].headers.containsKey('cookie'), isFalse); + }); + + test('不支持的资源协议会被拒绝', () async { + final client = ApiClient( + baseUrl: 'https://server.test', + sessionStore: TestSessionStore('session=abc123'), + ); + + expect( + () => client.getBytes('file:///tmp/avatar.png'), + throwsA(isA()), + ); + }); + + test('401 响应会清理本地 session', () async { + final store = TestSessionStore('session=abc123'); + final client = ApiClient( + baseUrl: 'https://server.test', + sessionStore: store, + httpClient: MockClient((request) async { + return http.Response.bytes( + utf8.encode(jsonEncode({'error': '登录已过期'})), + 401, + ); + }), + ); + + await expectLater( + client.getJson('/api/auth/me'), + throwsA(isA()), + ); + expect(await store.read(), isNull); + }); + + test('退出接口失败时仍然清理本地 session', () async { + final store = TestSessionStore('session=abc123'); + final api = AuthApi( + client: ApiClient( + baseUrl: 'https://server.test', + sessionStore: store, + httpClient: MockClient((request) async { + return http.Response.bytes( + utf8.encode(jsonEncode({'error': '服务暂不可用'})), + 500, + ); + }), + ), + ); + + await expectLater(api.logout(), throwsA(isA())); + expect(await store.read(), isNull); + }); +} diff --git a/apps/mobile/test/chat_api_test.dart b/apps/mobile/test/chat_api_test.dart new file mode 100644 index 00000000..710edade --- /dev/null +++ b/apps/mobile/test/chat_api_test.dart @@ -0,0 +1,301 @@ +import 'dart:convert'; + +import 'package:error_log_app/core/network/api_client.dart'; +import 'package:error_log_app/features/chat/data/chat_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_session_store.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=test-session', + }); + }); + + test('获取当前用户独立对话列表时携带分页参数和 session cookie', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/chat/my-sessions'); + expect(request.url.queryParameters['page'], '2'); + expect(request.url.queryParameters['limit'], '30'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'total': 1, + 'sessions': [ + { + 'id': 'session-001', + 'question_id': null, + 'title': '你好', + 'created_at': '2026-05-20T01:16:39.020457', + 'updated_at': '2026-05-31T07:28:05.564490', + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = ChatApi(client: client); + final response = await api.getMySessions(page: 2, limit: 30); + + expect(response.success, isTrue); + expect(response.total, 1); + expect(response.sessions.single.id, 'session-001'); + expect(response.sessions.single.title, '你好'); + }); + + test('修改对话标题时发送 PATCH 请求', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'PATCH'); + expect(request.url.path, '/api/chat/session-001'); + expect(request.headers['cookie'], 'session=test-session'); + expect(jsonDecode(request.body), {'title': '新的标题'}); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '修改成功'})), + 200, + ); + }), + ); + + final api = ChatApi(client: client); + final response = await api.renameSession( + sessionId: 'session-001', + title: '新的标题', + ); + + expect(response.success, isTrue); + expect(response.message, '修改成功'); + }); + + test('删除对话时发送 DELETE 请求', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'DELETE'); + expect(request.url.path, '/api/chat/session-001'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '删除成功'})), + 200, + ); + }), + ); + + final api = ChatApi(client: client); + final response = await api.deleteSession(sessionId: 'session-001'); + + expect(response.success, isTrue); + expect(response.message, '删除成功'); + }); + + test('创建新对话时发送 POST 请求并解析 session id', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/chat'); + expect(request.headers['cookie'], 'session=test-session'); + expect(jsonDecode(request.body), {'title': '新对话', 'question_id': 101}); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'message': '创建成功', + 'session': { + 'id': 'session-new', + 'question_id': 101, + 'title': '新对话', + }, + }), + ), + 200, + ); + }), + ); + + final api = ChatApi(client: client); + final response = await api.createSession(title: '新对话', questionId: 101); + + expect(response.success, isTrue); + expect(response.sessionId, 'session-new'); + expect(response.session?.questionId, 101); + }); + + test('游标分页获取对话消息时携带 before_id', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/chat/session-001/messages'); + expect(request.url.queryParameters['limit'], '30'); + expect(request.url.queryParameters['before_id'], '88'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'messages': [ + { + 'id': 90, + 'role': 'assistant', + 'content': r'答案是 $x^2$', + 'reasoning': '推理过程', + 'created_at': '2026-05-31T07:28:05.564490', + }, + ], + 'has_more': true, + }), + ), + 200, + ); + }), + ); + + final api = ChatApi(client: client); + final response = await api.getMessages( + sessionId: 'session-001', + limit: 30, + beforeId: 88, + ); + + expect(response.success, isTrue); + expect(response.hasMore, isTrue); + expect(response.messages.single.id, 90); + expect(response.messages.single.content, r'答案是 $x^2$'); + }); + + test('错题库综合查询时携带项目和分页参数并解析题目', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/error-bank'); + expect(request.url.queryParameters['page'], '2'); + expect(request.url.queryParameters['page_size'], '20'); + expect(request.url.queryParameters['project_id'], '12'); + expect(request.url.queryParameters['keyword'], '圆锥'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'total': 1, + 'questions': [ + { + 'id': 34, + 'question_type': '选择题', + 'subject': '高中+数学', + 'content_blocks': [ + {'block_type': 'text', 'content': r'已知圆锥底面半径为 $\sqrt{3}$'}, + ], + 'knowledge_tags': ['圆锥', '体积'], + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = ChatApi(client: client); + final response = await api.queryErrorBank( + page: 2, + pageSize: 20, + projectId: 12, + keyword: '圆锥', + ); + + expect(response.success, isTrue); + expect(response.total, 1); + expect(response.questions.single.id, 34); + expect(response.questions.single.previewText, contains('圆锥')); + }); + + test('SSE 流式对话发送模型和引用题目参数并解析事件', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/chat/session-001/stream'); + expect(request.headers['accept'], 'text/event-stream'); + expect(request.headers['cookie'], 'session=test-session'); + expect(jsonDecode(request.body), { + 'message': '讲一下这道题', + 'model_provider': 'openai', + 'model_name': 'deepseek-v4-flash', + 'provider_source': 'system', + 'provider_id': 'provider-001', + 'deep_think': true, + 'context_refs': [ + { + 'type': 'question', + 'project_id': 12, + 'question_ids': [34, 35], + }, + ], + }); + + return http.Response.bytes( + utf8.encode( + 'data: {"token":"你好"}\n\n' + 'data: {"reasoning":"先分析"}\n\n' + 'data: {"done":true}\n\n', + ), + 200, + headers: {'content-type': 'text/event-stream'}, + ); + }), + ); + + final api = ChatApi(client: client); + final events = await api + .streamMessage( + sessionId: 'session-001', + request: const ChatStreamRequest( + message: '讲一下这道题', + modelProvider: 'openai', + modelName: 'deepseek-v4-flash', + providerSource: 'system', + providerId: 'provider-001', + deepThink: true, + contextRefs: [ + ChatContextRef( + type: 'question', + projectId: 12, + questionIds: [34, 35], + ), + ], + ), + ) + .toList(); + + expect(events.map((event) => event.token).whereType(), ['你好']); + expect(events.map((event) => event.reasoning).whereType(), ['先分析']); + expect(events.last.done, isTrue); + }); +} diff --git a/apps/mobile/test/device_api_test.dart b/apps/mobile/test/device_api_test.dart new file mode 100644 index 00000000..824b5037 --- /dev/null +++ b/apps/mobile/test/device_api_test.dart @@ -0,0 +1,184 @@ +import 'dart:convert'; + +import 'package:error_log_app/core/network/api_client.dart'; +import 'package:error_log_app/features/device/data/device_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_session_store.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + }); + + test('创建设备绑定时发送 force_new 并携带 session cookie', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/device/bind'); + expect(request.headers['cookie'], 'session=abc123'); + expect(jsonDecode(request.body), {'force_new': false}); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'device_uuid': '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + 'qr_payload': + 'aiwb://bind?device_uuid=8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + }), + ), + 200, + ); + }), + ); + + final api = DeviceApi(client: client); + final response = await api.bindDevice(); + + expect(response.success, isTrue); + expect(response.deviceUuid, '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21'); + }); + + test('可以请求强制生成新的设备 UUID', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(jsonDecode(request.body), {'force_new': true}); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'device_uuid': 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb', + 'qr_payload': + 'aiwb://bind?device_uuid=aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb', + }), + ), + 200, + ); + }), + ); + + final api = DeviceApi(client: client); + final response = await api.bindDevice(forceNew: true); + + expect(response.deviceUuid, 'aaaaaaaa-1111-2222-3333-bbbbbbbbbbbb'); + }); + + test('查询当前用户设备绑定状态', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/device/binding'); + expect(request.headers['cookie'], 'session=abc123'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'bound': true, + 'device_uuid': '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + 'qr_payload': + 'aiwb://bind?device_uuid=8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + }), + ), + 200, + ); + }), + ); + + final api = DeviceApi(client: client); + final response = await api.getBinding(); + + expect(response.success, isTrue); + expect(response.bound, isTrue); + expect(response.deviceUuid, '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21'); + }); + + test('解绑当前用户设备时发送 device_uuid', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/device/unbind'); + expect(request.headers['cookie'], 'session=abc123'); + expect(jsonDecode(request.body), { + 'device_uuid': '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + }); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': 'ok'})), + 200, + ); + }), + ); + + final api = DeviceApi(client: client); + final response = await api.unbindDevice( + '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + ); + + expect(response.success, isTrue); + expect(response.message, 'ok'); + }); + + test('查询硬件上传图片列表', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/device/images'); + expect(request.url.queryParameters, { + 'device_uuid': '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + 'limit': '50', + }); + expect(request.headers['cookie'], 'session=abc123'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'images': [ + { + 'id': 7, + 'device_uuid': '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + 'file_key': 'captures/capture.jpg', + 'filename': 'capture.jpg', + 'image_url': '/api/image/capture.jpg', + 'content_type': 'image/jpeg', + 'file_size': 2048, + 'created_at': '2026-06-01T10:30:00Z', + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = DeviceApi(client: client); + final response = await api.getImages( + deviceUuid: '8f4b8f6e-2c7a-4e3a-9c6a-4c1f2e7b9a21', + ); + + expect(response.success, isTrue); + expect(response.images, hasLength(1)); + expect(response.images.single.displayName, 'capture.jpg'); + expect(response.images.single.imageUrl, '/api/image/capture.jpg'); + expect(response.images.single.fileSize, 2048); + }); +} diff --git a/apps/mobile/test/esp_ble_device_provisioner_test.dart b/apps/mobile/test/esp_ble_device_provisioner_test.dart new file mode 100644 index 00000000..d2f86344 --- /dev/null +++ b/apps/mobile/test/esp_ble_device_provisioner_test.dart @@ -0,0 +1,111 @@ +import 'dart:typed_data'; + +import 'package:error_log_app/features/device/data/esp_ble_device_provisioner.dart'; +import 'package:flutter_reactive_ble/flutter_reactive_ble.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('device-config 只包含上传参数,不包含 Wi-Fi 凭据', () { + const request = DeviceProvisioningRequest( + deviceId: '550e8400-e29b-41d4-a716-446655440000', + wifiSsid: 'classroom', + wifiPassword: 'secret', + uploadUrl: 'http://10.1.86.71:5001', + imageProfile: 'medium', + ); + + expect(request.toDeviceConfigJson(), { + 'op': 'set', + 'device_id': '550e8400-e29b-41d4-a716-446655440000', + 'upload_url': 'http://10.1.86.71:5001', + 'image_profile': 'medium', + }); + }); + + test('device-config 校验接受协议允许的字段', () { + const request = DeviceProvisioningRequest( + deviceId: '550E8400-E29B-41D4-A716-446655440000', + wifiSsid: 'classroom', + wifiPassword: 'secret', + uploadUrl: 'https://lamp.dianchuang.club/api/device/capture', + imageProfile: 'high', + ); + + expect(request.validateDeviceConfig, returnsNormally); + }); + + test('device-config 校验拒绝无效 device_id', () { + const request = DeviceProvisioningRequest( + deviceId: 'not-a-uuid', + wifiSsid: 'classroom', + wifiPassword: 'secret', + uploadUrl: 'https://lamp.dianchuang.club/api/device/capture', + imageProfile: 'medium', + ); + + expect( + request.validateDeviceConfig, + throwsA(isA()), + ); + }); + + test('device-config 校验拒绝包含空格或过长的 upload_url', () { + final tooLongUrl = 'https://example.com/${'a' * 237}'; + + expect(isValidDeviceUploadUrl('https://example.com/upload'), isTrue); + expect(isValidDeviceUploadUrl('https://example.com/u pload'), isFalse); + expect(isValidDeviceUploadUrl(tooLongUrl), isFalse); + }); + + test('device-config 校验拒绝协议外 image_profile', () { + const request = DeviceProvisioningRequest( + deviceId: '550e8400-e29b-41d4-a716-446655440000', + wifiSsid: 'classroom', + wifiPassword: 'secret', + uploadUrl: 'https://lamp.dianchuang.club/api/device/capture', + imageProfile: 'ultra', + ); + + expect( + request.validateDeviceConfig, + throwsA(isA()), + ); + }); + + test('device-config 返回可兼容固件 null terminator', () { + final decoded = decodeDeviceConfigResponse( + Uint8List.fromList('{"ok":true}\u0000'.codeUnits), + ); + + expect(decoded, {'ok': true}); + }); + + test('device-config 返回可截取 JSON 对象', () { + final decoded = decodeDeviceConfigResponse( + Uint8List.fromList('noise{"ok":true}\u0000tail'.codeUnits), + ); + + expect(decoded, {'ok': true}); + }); + + test('device-config 返回可兼容左括号误写', () { + final decoded = decodeDeviceConfigResponse( + Uint8List.fromList('("ok":true}'.codeUnits), + ); + + expect(decoded, {'ok': true}); + }); + + test('按 Espressif protocomm 规则从 service UUID 推导 endpoint UUID', () { + final serviceUuid = Uuid.parse('2f1f6e62-8ef5-43a4-9fa6-d7dbf8605e58'); + + expect( + ReactiveBleProvTransport.endpointUuid(serviceUuid, 0xff51).toString(), + '2f1fff51-8ef5-43a4-9fa6-d7dbf8605e58', + ); + expect( + ReactiveBleProvTransport.endpointUuid(serviceUuid, 0xff54).toString(), + '2f1fff54-8ef5-43a4-9fa6-d7dbf8605e58', + ); + }); +} diff --git a/apps/mobile/test/test_session_store.dart b/apps/mobile/test/test_session_store.dart new file mode 100644 index 00000000..cf02b500 --- /dev/null +++ b/apps/mobile/test/test_session_store.dart @@ -0,0 +1,26 @@ +import 'package:error_log_app/core/network/api_client.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class TestSessionStore implements SessionStore { + TestSessionStore([this.value]); + + String? value; + + @override + Future read() async { + return value ?? + (await SharedPreferences.getInstance()).getString( + ApiClient.sessionCookieKey, + ); + } + + @override + Future write(String nextValue) async { + value = nextValue; + } + + @override + Future delete() async { + value = null; + } +} diff --git a/apps/mobile/test/time_format_test.dart b/apps/mobile/test/time_format_test.dart new file mode 100644 index 00000000..0cf675d5 --- /dev/null +++ b/apps/mobile/test/time_format_test.dart @@ -0,0 +1,22 @@ +import 'package:error_log_app/core/utils/time_format.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('后端无时区时间按 UTC 解析,避免本地显示早 8 小时', () { + final parsed = parseBackendDateTime('2026-05-31T07:28:05.564490'); + + expect(parsed?.toUtc(), DateTime.utc(2026, 5, 31, 7, 28, 5, 564, 490)); + }); + + test('后端显式时区时间保留原时区语义', () { + final parsed = parseBackendDateTime('2026-05-31T07:28:05+08:00'); + + expect(parsed?.toUtc(), DateTime.utc(2026, 5, 30, 23, 28, 5)); + }); + + test('相对时间使用统一文案', () { + final text = formatRelativeTime(DateTime.now()); + + expect(text, '刚刚更新'); + }); +} diff --git a/apps/mobile/test/widget_test.dart b/apps/mobile/test/widget_test.dart new file mode 100644 index 00000000..986b4646 --- /dev/null +++ b/apps/mobile/test/widget_test.dart @@ -0,0 +1,402 @@ +import 'package:error_log_app/main.dart'; +import 'package:error_log_app/core/network/api_client.dart'; +import 'package:error_log_app/features/auth/data/auth_api.dart'; +import 'package:error_log_app/features/login/presentation/pages/login_page.dart'; +import 'package:error_log_app/features/login/presentation/widgets/login_form_panel.dart'; +import 'package:error_log_app/features/login/presentation/widgets/login_hero_panel.dart'; +import 'package:error_log_app/features/home/presentation/widgets/home_hero.dart'; +import 'package:error_log_app/features/workspace/presentation/pages/workspace_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_session_store.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('首页显示品牌、主视觉文案和操作按钮', (tester) async { + await tester.pumpWidget(const MyApp()); + + expect(find.text('智卷错题本'), findsOneWidget); + expect(find.text('AI 驱动 · 专为学生设计'), findsOneWidget); + expect(find.text('重塑错题整理'), findsOneWidget); + expect(find.text('一键生成知识图谱'), findsOneWidget); + expect(find.text('开始使用'), findsOneWidget); + expect(find.text('查看演示'), findsNothing); + expect(find.text('进入工作台'), findsNothing); + }); + + testWidgets('点击开始使用后显示登录页', (tester) async { + await tester.pumpWidget(MyApp(authApi: _FakeAuthApi())); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('欢迎回来'), findsOneWidget); + expect(find.text('登录以继续使用你的错题本'), findsOneWidget); + expect(find.text('请输入邮箱或用户名'), findsOneWidget); + expect(find.text('请输入密码'), findsOneWidget); + }); + + testWidgets('登录页点击登录后进入工作台', (tester) async { + await tester.pumpWidget( + MaterialApp( + routes: { + '/': (_) => LoginPage(authApi: _FakeAuthApi()), + '/workspace': (_) => const Scaffold(body: Text('工作台页面搭建中')), + }, + ), + ); + + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), 'student@example.com'); + await tester.enterText(fields.at(1), '123456'); + await tester.ensureVisible(find.text('登录').last); + await tester.pump(); + await tester.tap(find.text('登录').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('工作台页面搭建中'), findsOneWidget); + }); + + testWidgets('注册页可以发送验证码并创建账户', (tester) async { + final authApi = _FakeAuthApi(); + var enteredWorkspace = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: LoginFormPanel( + authApi: authApi, + onLogin: () { + enteredWorkspace = true; + }, + ), + ), + ), + ), + ); + + await tester.tap(find.text('注册')); + await tester.pump(); + final fields = find.byType(TextField); + await tester.enterText(fields.at(0), 'student01'); + await tester.enterText(fields.at(1), 'student@example.com'); + await tester.enterText(fields.at(2), '654321'); + await tester.enterText(fields.at(3), '123456'); + await tester.enterText(fields.at(4), '123456'); + await tester.ensureVisible(find.text('发送验证码')); + await tester.pump(); + await tester.tap(find.text('发送验证码')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(authApi.sentCodeEmail, 'student@example.com'); + expect(find.text('验证码已发送'), findsOneWidget); + + ScaffoldMessenger.of( + tester.element(find.byType(LoginFormPanel)), + ).clearSnackBars(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + await tester.ensureVisible(find.text('创建账户').last); + await tester.pump(); + await tester.tap(find.text('创建账户').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(authApi.registeredEmail, 'student@example.com'); + expect(enteredWorkspace, isTrue); + }); + + testWidgets('首页渲染 logo 和全局闪烁星空层', (tester) async { + await tester.pumpWidget(const MyApp()); + + expect(find.byKey(const Key('app-logo')), findsOneWidget); + expect(find.byKey(const Key('global-star-field')), findsOneWidget); + }); + + testWidgets('首页 logo 使用白色 SVG', (tester) async { + await tester.pumpWidget(const MyApp()); + + final logo = tester.widget(find.byKey(const Key('app-logo'))); + final colorFilter = logo.colorFilter.toString(); + expect(colorFilter, contains('red: 1.0000')); + expect(colorFilter, contains('green: 1.0000')); + expect(colorFilter, contains('blue: 1.0000')); + expect(colorFilter, contains('BlendMode.srcIn')); + }); + + testWidgets('知识图谱标题使用流动渐变文字', (tester) async { + await tester.pumpWidget(const MyApp()); + + final flowingTitle = find.byType(FlowingGradientText); + expect(flowingTitle, findsOneWidget); + + final widget = tester.widget(flowingTitle); + expect(widget.text, '一键生成知识图谱'); + expect(widget.colors.length, greaterThanOrEqualTo(3)); + }); + + testWidgets('登录页有专属流动波浪', (tester) async { + await tester.pumpWidget(MyApp(authApi: _FakeAuthApi())); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.byType(FlowingLoginWave), findsOneWidget); + }); + + test('登录页波浪固定路径并只移动颜色渐变', () { + const size = Size(820, 420); + const startPainter = LoginWavePainter(progress: 0, isLight: false); + const middlePainter = LoginWavePainter(progress: 0.5, isLight: false); + + expect( + startPainter.buildWavePath(size).getBounds(), + middlePainter.buildWavePath(size).getBounds(), + ); + expect( + startPainter.buildShaderRect(size), + isNot(middlePainter.buildShaderRect(size)), + ); + }); + + testWidgets('登录页可以切换到注册表单', (tester) async { + await tester.pumpWidget(MyApp(authApi: _FakeAuthApi())); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('注册')); + await tester.pump(); + + expect(find.text('创建账户'), findsNWidgets(2)); + expect(find.text('免费注册,开始智能错题整理'), findsOneWidget); + expect(find.text('用户名'), findsOneWidget); + expect(find.text('邮箱'), findsOneWidget); + expect(find.text('发送验证码'), findsOneWidget); + expect(find.text('验证码'), findsOneWidget); + expect(find.text('确认密码'), findsOneWidget); + expect(find.text('您的昵称'), findsOneWidget); + expect(find.text('your@email.com'), findsOneWidget); + expect(find.text('6 位验证码'), findsOneWidget); + expect(find.text('至少 6 位'), findsOneWidget); + expect(find.text('再次输入密码'), findsOneWidget); + }); + + testWidgets('注册表单可以切回登录表单', (tester) async { + await tester.pumpWidget(MyApp(authApi: _FakeAuthApi())); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('注册')); + await tester.pump(); + await tester.tap(find.text('登录').first); + await tester.pump(); + + expect(find.text('欢迎回来'), findsOneWidget); + expect(find.text('登录以继续使用你的错题本'), findsOneWidget); + expect(find.text('创建账户'), findsNothing); + }); + + testWidgets('首页提供全局太阳月亮主题切换按钮', (tester) async { + await tester.pumpWidget(const MyApp()); + + expect(find.byKey(const Key('theme-toggle-button')), findsOneWidget); + expect(find.byIcon(Icons.light_mode_rounded), findsOneWidget); + + await tester.tap(find.byKey(const Key('theme-toggle-button'))); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Icons.dark_mode_rounded), findsOneWidget); + }); + + testWidgets('主题切换会记录到本地缓存', (tester) async { + await tester.pumpWidget(const MyApp()); + + await tester.tap(find.byKey(const Key('theme-toggle-button'))); + await tester.pump(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('theme_mode'), 'light'); + }); + + testWidgets('启动时读取本地缓存里的主题', (tester) async { + SharedPreferences.setMockInitialValues({'theme_mode': 'light'}); + + await tester.pumpWidget(const MyApp()); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.byIcon(Icons.dark_mode_rounded), findsOneWidget); + }); + + testWidgets('点击开始使用时有有效 session 会进入工作台', (tester) async { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + + await tester.pumpWidget(MyApp(authApi: _FakeAuthApi(hasSession: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('开始使用'), findsOneWidget); + expect(find.text('智能录入'), findsNothing); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('智能录入'), findsOneWidget); + expect(find.text('库'), findsOneWidget); + expect(find.text('对话'), findsOneWidget); + expect(find.text('我的'), findsOneWidget); + }); + + testWidgets('工作台键盘弹出时不压缩底部栏布局', (tester) async { + await tester.pumpWidget(const MaterialApp(home: WorkspacePage())); + + final scaffold = tester.widget(find.byType(Scaffold)); + expect(scaffold.resizeToAvoidBottomInset, isFalse); + }); + + testWidgets('我的页可以切换主题并退出登录', (tester) async { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + final authApi = _FakeAuthApi(hasSession: true); + + await tester.pumpWidget(MyApp(authApi: authApi)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('我的')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('student01'), findsOneWidget); + expect(find.text('@student01'), findsOneWidget); + expect(find.text('切换为日间模式'), findsOneWidget); + expect(find.text('退出登录'), findsOneWidget); + + await tester.tap(find.text('切换为日间模式')); + await tester.pump(); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('theme_mode'), 'light'); + + await tester.tap(find.text('退出登录')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(authApi.loggedOut, isTrue); + expect(find.text('欢迎回来'), findsOneWidget); + }); + + testWidgets('用户资料设置页显示账户信息表单', (tester) async { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=abc123', + }); + final authApi = _FakeAuthApi(hasSession: true); + + await tester.pumpWidget(MyApp(authApi: authApi)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + await tester.tap(find.text('开始使用')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('我的')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('用户资料设置')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('账户信息'), findsOneWidget); + expect(find.text('用户名'), findsOneWidget); + expect(find.text('注册邮箱'), findsNothing); + expect(find.text('显示名称'), findsOneWidget); + expect(find.text('当前昵称'), findsOneWidget); + expect(find.text('保存更改'), findsOneWidget); + }); +} + +class _FakeAuthApi extends AuthApi { + _FakeAuthApi({this.hasSession = false}) + : super( + client: ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://test', + ), + ); + + String? sentCodeEmail; + String? registeredEmail; + bool loggedOut = false; + final bool hasSession; + + @override + Future hasStoredSession() async { + return hasSession; + } + + @override + Future me() async { + return const AuthUser( + id: 1, + email: 'student@example.com', + username: 'student01', + isAdmin: false, + ); + } + + @override + Future sendCode({ + required String email, + String type = 'register', + }) async { + sentCodeEmail = email; + } + + @override + Future login({ + required String identifier, + required String password, + }) async { + return const AuthUser( + id: 1, + email: 'student@example.com', + username: 'student01', + isAdmin: false, + ); + } + + @override + Future register({ + required String email, + required String username, + required String password, + required String code, + }) async { + registeredEmail = email; + return AuthUser(id: 2, email: email, username: username, isAdmin: false); + } + + @override + Future logout() async { + loggedOut = true; + await clearStoredSession(); + } +} diff --git a/apps/mobile/test/workspace_api_test.dart b/apps/mobile/test/workspace_api_test.dart new file mode 100644 index 00000000..b7c05c7d --- /dev/null +++ b/apps/mobile/test/workspace_api_test.dart @@ -0,0 +1,493 @@ +import 'dart:convert'; + +import 'package:error_log_app/core/network/api_client.dart'; +import 'package:error_log_app/features/workspace/data/workspace_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'test_session_store.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({ + ApiClient.sessionCookieKey: 'session=test-session', + }); + }); + + test('获取错题库项目列表时携带 project_type=question 并解析数量', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/projects'); + expect(request.url.queryParameters['project_type'], 'question'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'projects': [ + { + 'id': 12, + 'public_id': 'pub-12', + 'name': 'math', + 'title': '数学', + 'project_type': 'question', + 'summary': '单元错题', + 'description': '数学错题', + 'color': '#8B72FF', + 'icon': 'database', + 'is_default': false, + 'question_count': 4, + 'note_count': 0, + 'updated_at': '2026-05-31T08:00:00', + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.getProjects(projectType: 'question'); + + expect(response.success, isTrue); + expect(response.projects, hasLength(1)); + expect(response.projects.single.id, 12); + expect(response.projects.single.displayName, '数学'); + expect(response.projects.single.questionCount, 4); + expect(response.projects.single.isQuestionProject, isTrue); + }); + + test('导入选中题目到错题库时发送后端要求的负载', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/save-to-db'); + expect(request.headers['cookie'], 'session=test-session'); + expect(jsonDecode(request.body), { + 'run_id': 'run-001', + 'project_id': 12, + 'selected_ids': ['0', '1'], + 'answers': [], + }); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '导入成功'})), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.saveSplitQuestionsToDb( + runId: 'run-001', + projectId: 12, + selectedIds: const ['0', '1'], + ); + + expect(response.success, isTrue); + expect(response.message, '导入成功'); + }); + + test('重置上传会话时发送后端要求的空负载', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/upload/reset'); + expect(request.headers['cookie'], 'session=test-session'); + expect(jsonDecode(request.body), {}); + + return http.Response.bytes( + utf8.encode(jsonEncode({'success': true, 'message': '已重置'})), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.resetUploadSession(); + + expect(response.success, isTrue); + expect(response.message, '已重置'); + }); + + test('整理笔记预览时上传原图文件且不传 project_id', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/notes/'); + expect(request.headers['cookie'], 'session=test-session'); + expect( + request.headers['content-type'], + contains('multipart/form-data'), + ); + + final bodyText = utf8.decode(request.bodyBytes, allowMalformed: true); + expect(bodyText, contains('name="files"')); + expect(bodyText, contains('note.jpg')); + expect(bodyText, contains('name="model_provider"')); + expect(bodyText, contains('openai')); + expect(bodyText, contains('name="model_name"')); + expect(bodyText, contains('gpt-4o-mini')); + expect(bodyText, isNot(contains('name="project_id"'))); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'note_preview': { + 'title': '四边形笔记', + 'subject': '初中数学', + 'content_markdown': r'''## 平行四边形 +$AB=CD$''', + 'knowledge_tags': ['平行四边形'], + 'source_images': ['/images/note.jpg'], + 'ocr_text': 'OCR 原文', + }, + }), + ), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.organizeNotePreview( + files: const [ + UploadFileItem(filename: 'note.jpg', bytes: [1, 2, 3]), + ], + modelRequest: const SplitRequest( + modelProvider: 'openai', + modelName: 'gpt-4o-mini', + providerSource: null, + providerId: null, + ), + ); + + expect(response.success, isTrue); + expect(response.notePreview?.displayTitle, '四边形笔记'); + expect(response.notePreview?.displaySubject, '初中数学'); + expect(response.notePreview?.contentMarkdown, contains(r'$AB=CD$')); + expect(response.notePreview?.knowledgeTags, contains('平行四边形')); + }); + + test('保存整理后的笔记预览时发送指定笔记本和预览内容', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'POST'); + expect(request.url.path, '/api/notes/save-organized'); + expect(request.headers['cookie'], 'session=test-session'); + expect(jsonDecode(request.body), { + 'project_id': 21, + 'title': '四边形笔记', + 'subject': '初中数学', + 'content_markdown': '## 平行四边形', + 'source_images': ['/images/note.jpg'], + 'ocr_text': 'OCR 原文', + 'knowledge_tags': ['平行四边形'], + }); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'note': { + 'id': 4, + 'title': '四边形笔记', + 'subject': '初中数学', + 'content_markdown': '## 平行四边形', + 'source_images': ['/images/note.jpg'], + 'knowledge_tags': ['平行四边形'], + }, + }), + ), + 201, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.saveOrganizedNote( + projectId: 21, + preview: const NotePreview( + title: '四边形笔记', + subject: '初中数学', + contentMarkdown: '## 平行四边形', + sourceImages: ['/images/note.jpg'], + ocrText: 'OCR 原文', + knowledgeTags: ['平行四边形'], + ), + ); + + expect(response.success, isTrue); + expect(response.note?.id, 4); + expect(response.note?.displayTitle, '四边形笔记'); + }); + + test('错题库综合查询时携带项目和分页参数并解析题目', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/error-bank'); + expect(request.url.queryParameters['project_id'], '12'); + expect(request.url.queryParameters['page'], '1'); + expect(request.url.queryParameters['page_size'], '10'); + expect(request.url.queryParameters['keyword'], '圆锥'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'total': 1, + 'grand_total': 1, + 'page': 1, + 'page_size': 10, + 'total_pages': 1, + 'items': [ + { + 'id': 34, + 'question_type': '选择题', + 'subject': '高中+数学', + 'content_json': jsonEncode([ + {'block_type': 'text', 'content': r'已知圆锥底面半径为 $\sqrt{3}$'}, + ]), + 'options_json': jsonEncode(['A. \$\\pi\$', 'B. \$3\\pi\$']), + 'image_refs_json': jsonEncode(['/images/q34.png']), + 'knowledge_tags': jsonEncode(['立体几何', '圆锥体积']), + 'review_status': '待复习', + 'review_is_due': true, + 'review_count': 2, + 'review_interval_days': 7, + 'needs_correction': true, + 'original_filename': 'test2.jpg', + 'ease_factor': 2.5, + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.queryErrorBank(projectId: 12, keyword: '圆锥'); + + expect(response.success, isTrue); + expect(response.items, hasLength(1)); + expect(response.items.single.id, 34); + expect(response.items.single.previewText, contains(r'\sqrt{3}')); + expect(response.items.single.options, hasLength(2)); + expect(response.items.single.imageRefs.single, '/images/q34.png'); + expect(response.items.single.knowledgeTags, contains('圆锥体积')); + expect(response.items.single.reviewCount, 2); + expect(response.items.single.needsCorrection, isTrue); + expect(response.items.single.originalFilename, 'test2.jpg'); + expect(response.items.single.easeFactor, 2.5); + expect(response.hasMore, isFalse); + }); + + test('分页查询笔记时携带项目和关键词并解析笔记', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/notes/'); + expect(request.url.queryParameters['project_id'], '21'); + expect(request.url.queryParameters['page'], '2'); + expect(request.url.queryParameters['limit'], '10'); + expect(request.url.queryParameters['keyword'], '四边形'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'total': 11, + 'page': 2, + 'limit': 10, + 'total_pages': 2, + 'items': [ + { + 'id': 116, + 'title': '第四章 四边形', + 'subject': '数学', + 'summary': '平行四边形定义与性质', + 'content_markdown': r'''## 第四章 四边形 +![图](/images/quad.jpg) +$AB \parallel CD$''', + 'knowledge_tags': ['平行四边形'], + 'source_images': [ + r'C:\Users\15184\Desktop\error_correction\backend\runtime_data\uploads\quad.jpg', + ], + 'review_status': '待复习', + 'review_is_due': true, + 'review_count': 1, + 'review_interval_days': 3, + 'ease_factor': 2.5, + 'content_json': [ + {'block_type': 'text', 'content': '两组对边分别平行的四边形叫做平行四边形。'}, + ], + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.queryNotes( + projectId: 21, + page: 2, + keyword: '四边形', + ); + + expect(response.success, isTrue); + expect(response.items, hasLength(1)); + expect(response.items.single.displayTitle, '第四章 四边形'); + expect( + response.items.single.previewText, + contains('![图](/images/quad.jpg)'), + ); + expect(response.items.single.imageRefs.single, contains('quad.jpg')); + expect(response.items.single.reviewStatus, '待复习'); + expect(response.items.single.reviewCount, 1); + expect(response.items.single.easeFactor, 2.5); + expect(response.hasMore, isFalse); + }); + + test('查询最近分割记录时携带 limit 并解析记录', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/split-records'); + expect(request.url.queryParameters['limit'], '20'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'records': [ + { + 'id': 9, + 'run_id': 'run-history-9', + 'subject': null, + 'model_provider': 'openai', + 'file_names': ['test.jpg'], + 'question_count': 1, + 'created_at': '2026-06-01T12:52:00', + 'questions': [ + { + 'id': 101, + 'uid': 'tmp-101', + 'question_type': '选择题', + 'content_blocks': [ + {'block_type': 'text', 'content': '若 a+b=1,求 a 的值。'}, + ], + 'has_formula': true, + 'has_image': false, + 'needs_correction': false, + 'knowledge_tags': ['代数'], + }, + ], + }, + ], + }), + ), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.getSplitRecords(limit: 20); + + expect(response.success, isTrue); + expect(response.records, hasLength(1)); + expect(response.records.single.displaySubject, '未识别'); + expect(response.records.single.fileNames.single, 'test.jpg'); + expect(response.records.single.questionCount, 1); + expect(response.records.single.questions.single.uid, 'tmp-101'); + expect(response.records.single.questions.single.plainText, contains('a+b')); + }); + + test('获取分割记录详情时请求 record_id 并解析题目', () async { + final client = ApiClient( + sessionStore: TestSessionStore(), + baseUrl: 'http://server.test', + httpClient: MockClient((request) async { + expect(request.method, 'GET'); + expect(request.url.path, '/api/split-records/9'); + expect(request.headers['cookie'], 'session=test-session'); + + return http.Response.bytes( + utf8.encode( + jsonEncode({ + 'success': true, + 'message': 'ok', + 'record': { + 'id': 9, + 'run_id': 'run-history-9', + 'subject': '小学数学', + 'model_provider': 'openai', + 'file_names': ['paper.jpg'], + 'question_count': 2, + 'created_at': '2026-06-01T12:52:00', + 'questions': [ + { + 'id': 101, + 'uid': 'tmp-101', + 'question_type': '填空题', + 'content_blocks': [ + {'block_type': 'text', 'content': '11 × 4 ='}, + ], + 'has_formula': true, + 'has_image': false, + 'needs_correction': false, + 'knowledge_tags': ['乘法估算'], + }, + ], + }, + }), + ), + 200, + ); + }), + ); + + final api = WorkspaceApi(client: client); + final response = await api.getSplitRecordDetail(recordId: 9); + + expect(response.success, isTrue); + expect(response.message, 'ok'); + expect(response.record, isNotNull); + expect(response.record!.displaySubject, '小学数学'); + expect(response.record!.questionCount, 2); + expect(response.record!.questions.single.questionType, '填空题'); + expect(response.record!.questions.single.knowledgeTags.single, '乘法估算'); + }); +} diff --git a/apps/mobile/web/favicon.png b/apps/mobile/web/favicon.png new file mode 100644 index 00000000..8aaa46ac Binary files /dev/null and b/apps/mobile/web/favicon.png differ diff --git a/apps/mobile/web/icons/Icon-192.png b/apps/mobile/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/apps/mobile/web/icons/Icon-192.png differ diff --git a/apps/mobile/web/icons/Icon-512.png b/apps/mobile/web/icons/Icon-512.png new file mode 100644 index 00000000..88cfd48d Binary files /dev/null and b/apps/mobile/web/icons/Icon-512.png differ diff --git a/apps/mobile/web/icons/Icon-maskable-192.png b/apps/mobile/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/apps/mobile/web/icons/Icon-maskable-192.png differ diff --git a/apps/mobile/web/icons/Icon-maskable-512.png b/apps/mobile/web/icons/Icon-maskable-512.png new file mode 100644 index 00000000..d69c5669 Binary files /dev/null and b/apps/mobile/web/icons/Icon-maskable-512.png differ diff --git a/apps/mobile/web/index.html b/apps/mobile/web/index.html new file mode 100644 index 00000000..415ec8a8 --- /dev/null +++ b/apps/mobile/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + error_log_app + + + + + + diff --git a/apps/mobile/web/manifest.json b/apps/mobile/web/manifest.json new file mode 100644 index 00000000..661b904b --- /dev/null +++ b/apps/mobile/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "error_log_app", + "short_name": "error_log_app", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/mobile/windows/.gitignore b/apps/mobile/windows/.gitignore new file mode 100644 index 00000000..d492d0d9 --- /dev/null +++ b/apps/mobile/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/apps/mobile/windows/CMakeLists.txt b/apps/mobile/windows/CMakeLists.txt new file mode 100644 index 00000000..eccd90dc --- /dev/null +++ b/apps/mobile/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(error_log_app LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "error_log_app") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/apps/mobile/windows/flutter/CMakeLists.txt b/apps/mobile/windows/flutter/CMakeLists.txt new file mode 100644 index 00000000..903f4899 --- /dev/null +++ b/apps/mobile/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/apps/mobile/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 00000000..48de52bf --- /dev/null +++ b/apps/mobile/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); +} diff --git a/apps/mobile/windows/flutter/generated_plugin_registrant.h b/apps/mobile/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 00000000..dc139d85 --- /dev/null +++ b/apps/mobile/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/mobile/windows/flutter/generated_plugins.cmake b/apps/mobile/windows/flutter/generated_plugins.cmake new file mode 100644 index 00000000..0e69e40f --- /dev/null +++ b/apps/mobile/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/mobile/windows/runner/CMakeLists.txt b/apps/mobile/windows/runner/CMakeLists.txt new file mode 100644 index 00000000..394917c0 --- /dev/null +++ b/apps/mobile/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/mobile/windows/runner/Runner.rc b/apps/mobile/windows/runner/Runner.rc new file mode 100644 index 00000000..cf3be7fd --- /dev/null +++ b/apps/mobile/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "error_log_app" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "error_log_app" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "error_log_app.exe" "\0" + VALUE "ProductName", "error_log_app" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/apps/mobile/windows/runner/flutter_window.cpp b/apps/mobile/windows/runner/flutter_window.cpp new file mode 100644 index 00000000..955ee303 --- /dev/null +++ b/apps/mobile/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/apps/mobile/windows/runner/flutter_window.h b/apps/mobile/windows/runner/flutter_window.h new file mode 100644 index 00000000..6da0652f --- /dev/null +++ b/apps/mobile/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/mobile/windows/runner/main.cpp b/apps/mobile/windows/runner/main.cpp new file mode 100644 index 00000000..2062e346 --- /dev/null +++ b/apps/mobile/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"error_log_app", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/apps/mobile/windows/runner/resource.h b/apps/mobile/windows/runner/resource.h new file mode 100644 index 00000000..66a65d1e --- /dev/null +++ b/apps/mobile/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/apps/mobile/windows/runner/resources/app_icon.ico b/apps/mobile/windows/runner/resources/app_icon.ico new file mode 100644 index 00000000..c04e20ca Binary files /dev/null and b/apps/mobile/windows/runner/resources/app_icon.ico differ diff --git a/apps/mobile/windows/runner/runner.exe.manifest b/apps/mobile/windows/runner/runner.exe.manifest new file mode 100644 index 00000000..153653e8 --- /dev/null +++ b/apps/mobile/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/apps/mobile/windows/runner/utils.cpp b/apps/mobile/windows/runner/utils.cpp new file mode 100644 index 00000000..3a0b4651 --- /dev/null +++ b/apps/mobile/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/apps/mobile/windows/runner/utils.h b/apps/mobile/windows/runner/utils.h new file mode 100644 index 00000000..3879d547 --- /dev/null +++ b/apps/mobile/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/apps/mobile/windows/runner/win32_window.cpp b/apps/mobile/windows/runner/win32_window.cpp new file mode 100644 index 00000000..60608d0f --- /dev/null +++ b/apps/mobile/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/apps/mobile/windows/runner/win32_window.h b/apps/mobile/windows/runner/win32_window.h new file mode 100644 index 00000000..e901dde6 --- /dev/null +++ b/apps/mobile/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/ui-showcase/.gitignore b/apps/ui-showcase/.gitignore similarity index 100% rename from ui-showcase/.gitignore rename to apps/ui-showcase/.gitignore diff --git a/ui-showcase/README.md b/apps/ui-showcase/README.md similarity index 84% rename from ui-showcase/README.md rename to apps/ui-showcase/README.md index c6e1a0ce..b5cc4fcd 100644 --- a/ui-showcase/README.md +++ b/apps/ui-showcase/README.md @@ -1,6 +1,6 @@ # UI Showcase -独立的组件库展示项目,用于集中预览主应用 `frontend/src/components/base` 下的全部 Base 组件(Headless UI + Tailwind 实现)。 +独立的组件库展示项目,用于集中预览主应用 `apps/frontend/src/components/base` 下的全部 Base 组件(Headless UI + Tailwind 实现)。 ## 特性 @@ -27,5 +27,5 @@ npm run dev ## 注意 -- 本项目依赖 `../frontend` 的源码存在;Tailwind 的 `content` 配置也会扫描该目录。 +- 本项目依赖同级的 `../frontend` 源码目录;Tailwind 的 `content` 配置也会扫描该目录。 - Font Awesome 图标通过 CDN 引入(与主应用一致),离线时图标不显示但不影响布局。 diff --git a/ui-showcase/index.html b/apps/ui-showcase/index.html similarity index 100% rename from ui-showcase/index.html rename to apps/ui-showcase/index.html diff --git a/ui-showcase/package.json b/apps/ui-showcase/package.json similarity index 100% rename from ui-showcase/package.json rename to apps/ui-showcase/package.json diff --git a/ui-showcase/public/favicon.svg b/apps/ui-showcase/public/favicon.svg similarity index 100% rename from ui-showcase/public/favicon.svg rename to apps/ui-showcase/public/favicon.svg diff --git a/apps/ui-showcase/public/logo.svg b/apps/ui-showcase/public/logo.svg new file mode 100644 index 00000000..3e1d29d3 --- /dev/null +++ b/apps/ui-showcase/public/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/ui-showcase/src/App.vue b/apps/ui-showcase/src/App.vue similarity index 99% rename from ui-showcase/src/App.vue rename to apps/ui-showcase/src/App.vue index 51ce3d3d..9aa15bf8 100644 --- a/ui-showcase/src/App.vue +++ b/apps/ui-showcase/src/App.vue @@ -15,8 +15,8 @@ import OverviewPage from '~/pages/OverviewPage.vue' const { isDark, initTheme, setTheme, themeColors, accentColorId, setAccentColor } = useTheme() -// 版本号与 frontend/package.json 的 version 保持一致。 -// dev server 的 fs.allow 只包含 ui-showcase 与 frontend/src,无法直接 import 该 JSON。 +// 版本号与 apps/frontend/package.json 的 version 保持一致。 +// dev server 的 fs.allow 只包含 apps/ui-showcase 与 apps/frontend/src,无法直接 import 该 JSON。 const FRONTEND_VERSION = '0.0.0' const REPO_URL = 'https://github.com/IDhammaI/error_correction' diff --git a/ui-showcase/src/api-data/data.ts b/apps/ui-showcase/src/api-data/data.ts similarity index 99% rename from ui-showcase/src/api-data/data.ts rename to apps/ui-showcase/src/api-data/data.ts index d317bf17..e7dfc7c3 100644 --- a/ui-showcase/src/api-data/data.ts +++ b/apps/ui-showcase/src/api-data/data.ts @@ -1,6 +1,6 @@ /** * api-data/data.ts - * 数据展示类组件的 API 数据分片,从 frontend/src/components/base/ 各 SFC 源码提取。 + * 数据展示类组件的 API 数据分片,从 apps/frontend/src/components/base/ 各 SFC 源码提取。 */ import type { ComponentApi } from '~/api-types' diff --git a/ui-showcase/src/api-data/forms.ts b/apps/ui-showcase/src/api-data/forms.ts similarity index 99% rename from ui-showcase/src/api-data/forms.ts rename to apps/ui-showcase/src/api-data/forms.ts index 4c678fb2..8e9f3a98 100644 --- a/ui-showcase/src/api-data/forms.ts +++ b/apps/ui-showcase/src/api-data/forms.ts @@ -2,7 +2,7 @@ import type { ComponentApi } from '~/api-types' /** * forms.ts - * 表单类基础组件的 API 数据,提取自 frontend/src/components/base/ 各 SFC 源码。 + * 表单类基础组件的 API 数据,提取自 apps/frontend/src/components/base/ 各 SFC 源码。 */ export const FORMS_API: ComponentApi[] = [ { diff --git a/ui-showcase/src/api-data/nav.ts b/apps/ui-showcase/src/api-data/nav.ts similarity index 99% rename from ui-showcase/src/api-data/nav.ts rename to apps/ui-showcase/src/api-data/nav.ts index cf7a19b5..94878c5a 100644 --- a/ui-showcase/src/api-data/nav.ts +++ b/apps/ui-showcase/src/api-data/nav.ts @@ -1,6 +1,6 @@ /** * nav.ts - * 导航与布局类组件的 API 数据分片,均提取自 frontend/src/components/base 下各 SFC 源码。 + * 导航与布局类组件的 API 数据分片,均提取自 apps/frontend/src/components/base 下各 SFC 源码。 */ import type { ComponentApi } from '~/api-types' diff --git a/ui-showcase/src/api-data/overlay.ts b/apps/ui-showcase/src/api-data/overlay.ts similarity index 99% rename from ui-showcase/src/api-data/overlay.ts rename to apps/ui-showcase/src/api-data/overlay.ts index acc7b7db..231f23ed 100644 --- a/ui-showcase/src/api-data/overlay.ts +++ b/apps/ui-showcase/src/api-data/overlay.ts @@ -1,6 +1,6 @@ /** * overlay.ts - * 浮层 / 反馈 / 按钮类组件的 API 数据(提取自 frontend/src/components/base 各 SFC 源码)。 + * 浮层 / 反馈 / 按钮类组件的 API 数据(提取自 apps/frontend/src/components/base 各 SFC 源码)。 */ import type { ComponentApi } from '~/api-types' diff --git a/ui-showcase/src/api-docs.ts b/apps/ui-showcase/src/api-docs.ts similarity index 100% rename from ui-showcase/src/api-docs.ts rename to apps/ui-showcase/src/api-docs.ts diff --git a/ui-showcase/src/api-types.ts b/apps/ui-showcase/src/api-types.ts similarity index 100% rename from ui-showcase/src/api-types.ts rename to apps/ui-showcase/src/api-types.ts diff --git a/ui-showcase/src/catalog.ts b/apps/ui-showcase/src/catalog.ts similarity index 100% rename from ui-showcase/src/catalog.ts rename to apps/ui-showcase/src/catalog.ts diff --git a/ui-showcase/src/components/ApiTable.vue b/apps/ui-showcase/src/components/ApiTable.vue similarity index 100% rename from ui-showcase/src/components/ApiTable.vue rename to apps/ui-showcase/src/components/ApiTable.vue diff --git a/ui-showcase/src/components/CatalogNav.vue b/apps/ui-showcase/src/components/CatalogNav.vue similarity index 100% rename from ui-showcase/src/components/CatalogNav.vue rename to apps/ui-showcase/src/components/CatalogNav.vue diff --git a/ui-showcase/src/components/DemoBlock.vue b/apps/ui-showcase/src/components/DemoBlock.vue similarity index 100% rename from ui-showcase/src/components/DemoBlock.vue rename to apps/ui-showcase/src/components/DemoBlock.vue diff --git a/ui-showcase/src/components/DocTip.vue b/apps/ui-showcase/src/components/DocTip.vue similarity index 100% rename from ui-showcase/src/components/DocTip.vue rename to apps/ui-showcase/src/components/DocTip.vue diff --git a/ui-showcase/src/main.ts b/apps/ui-showcase/src/main.ts similarity index 100% rename from ui-showcase/src/main.ts rename to apps/ui-showcase/src/main.ts diff --git a/ui-showcase/src/pages/AccordionPage.vue b/apps/ui-showcase/src/pages/AccordionPage.vue similarity index 100% rename from ui-showcase/src/pages/AccordionPage.vue rename to apps/ui-showcase/src/pages/AccordionPage.vue diff --git a/ui-showcase/src/pages/AffixPage.vue b/apps/ui-showcase/src/pages/AffixPage.vue similarity index 100% rename from ui-showcase/src/pages/AffixPage.vue rename to apps/ui-showcase/src/pages/AffixPage.vue diff --git a/ui-showcase/src/pages/AlertPage.vue b/apps/ui-showcase/src/pages/AlertPage.vue similarity index 100% rename from ui-showcase/src/pages/AlertPage.vue rename to apps/ui-showcase/src/pages/AlertPage.vue diff --git a/ui-showcase/src/pages/AnchorPage.vue b/apps/ui-showcase/src/pages/AnchorPage.vue similarity index 100% rename from ui-showcase/src/pages/AnchorPage.vue rename to apps/ui-showcase/src/pages/AnchorPage.vue diff --git a/ui-showcase/src/pages/AvatarGroupPage.vue b/apps/ui-showcase/src/pages/AvatarGroupPage.vue similarity index 100% rename from ui-showcase/src/pages/AvatarGroupPage.vue rename to apps/ui-showcase/src/pages/AvatarGroupPage.vue diff --git a/ui-showcase/src/pages/AvatarPage.vue b/apps/ui-showcase/src/pages/AvatarPage.vue similarity index 100% rename from ui-showcase/src/pages/AvatarPage.vue rename to apps/ui-showcase/src/pages/AvatarPage.vue diff --git a/ui-showcase/src/pages/BackTopPage.vue b/apps/ui-showcase/src/pages/BackTopPage.vue similarity index 100% rename from ui-showcase/src/pages/BackTopPage.vue rename to apps/ui-showcase/src/pages/BackTopPage.vue diff --git a/ui-showcase/src/pages/BreadcrumbPage.vue b/apps/ui-showcase/src/pages/BreadcrumbPage.vue similarity index 100% rename from ui-showcase/src/pages/BreadcrumbPage.vue rename to apps/ui-showcase/src/pages/BreadcrumbPage.vue diff --git a/ui-showcase/src/pages/ButtonGroupPage.vue b/apps/ui-showcase/src/pages/ButtonGroupPage.vue similarity index 100% rename from ui-showcase/src/pages/ButtonGroupPage.vue rename to apps/ui-showcase/src/pages/ButtonGroupPage.vue diff --git a/ui-showcase/src/pages/ButtonPage.vue b/apps/ui-showcase/src/pages/ButtonPage.vue similarity index 100% rename from ui-showcase/src/pages/ButtonPage.vue rename to apps/ui-showcase/src/pages/ButtonPage.vue diff --git a/ui-showcase/src/pages/CalendarPage.vue b/apps/ui-showcase/src/pages/CalendarPage.vue similarity index 100% rename from ui-showcase/src/pages/CalendarPage.vue rename to apps/ui-showcase/src/pages/CalendarPage.vue diff --git a/ui-showcase/src/pages/CardPage.vue b/apps/ui-showcase/src/pages/CardPage.vue similarity index 100% rename from ui-showcase/src/pages/CardPage.vue rename to apps/ui-showcase/src/pages/CardPage.vue diff --git a/ui-showcase/src/pages/CarouselPage.vue b/apps/ui-showcase/src/pages/CarouselPage.vue similarity index 100% rename from ui-showcase/src/pages/CarouselPage.vue rename to apps/ui-showcase/src/pages/CarouselPage.vue diff --git a/ui-showcase/src/pages/CascaderPage.vue b/apps/ui-showcase/src/pages/CascaderPage.vue similarity index 100% rename from ui-showcase/src/pages/CascaderPage.vue rename to apps/ui-showcase/src/pages/CascaderPage.vue diff --git a/ui-showcase/src/pages/CheckboxGroupPage.vue b/apps/ui-showcase/src/pages/CheckboxGroupPage.vue similarity index 100% rename from ui-showcase/src/pages/CheckboxGroupPage.vue rename to apps/ui-showcase/src/pages/CheckboxGroupPage.vue diff --git a/ui-showcase/src/pages/CheckboxPage.vue b/apps/ui-showcase/src/pages/CheckboxPage.vue similarity index 100% rename from ui-showcase/src/pages/CheckboxPage.vue rename to apps/ui-showcase/src/pages/CheckboxPage.vue diff --git a/ui-showcase/src/pages/CircleProgressPage.vue b/apps/ui-showcase/src/pages/CircleProgressPage.vue similarity index 100% rename from ui-showcase/src/pages/CircleProgressPage.vue rename to apps/ui-showcase/src/pages/CircleProgressPage.vue diff --git a/ui-showcase/src/pages/CodePage.vue b/apps/ui-showcase/src/pages/CodePage.vue similarity index 100% rename from ui-showcase/src/pages/CodePage.vue rename to apps/ui-showcase/src/pages/CodePage.vue diff --git a/ui-showcase/src/pages/ColorPickerPage.vue b/apps/ui-showcase/src/pages/ColorPickerPage.vue similarity index 100% rename from ui-showcase/src/pages/ColorPickerPage.vue rename to apps/ui-showcase/src/pages/ColorPickerPage.vue diff --git a/ui-showcase/src/pages/CommandPalettePage.vue b/apps/ui-showcase/src/pages/CommandPalettePage.vue similarity index 100% rename from ui-showcase/src/pages/CommandPalettePage.vue rename to apps/ui-showcase/src/pages/CommandPalettePage.vue diff --git a/ui-showcase/src/pages/CountdownPage.vue b/apps/ui-showcase/src/pages/CountdownPage.vue similarity index 100% rename from ui-showcase/src/pages/CountdownPage.vue rename to apps/ui-showcase/src/pages/CountdownPage.vue diff --git a/ui-showcase/src/pages/DateRangePickerPage.vue b/apps/ui-showcase/src/pages/DateRangePickerPage.vue similarity index 100% rename from ui-showcase/src/pages/DateRangePickerPage.vue rename to apps/ui-showcase/src/pages/DateRangePickerPage.vue diff --git a/ui-showcase/src/pages/DateTimePage.vue b/apps/ui-showcase/src/pages/DateTimePage.vue similarity index 100% rename from ui-showcase/src/pages/DateTimePage.vue rename to apps/ui-showcase/src/pages/DateTimePage.vue diff --git a/ui-showcase/src/pages/DescriptionsPage.vue b/apps/ui-showcase/src/pages/DescriptionsPage.vue similarity index 100% rename from ui-showcase/src/pages/DescriptionsPage.vue rename to apps/ui-showcase/src/pages/DescriptionsPage.vue diff --git a/ui-showcase/src/pages/DividerPage.vue b/apps/ui-showcase/src/pages/DividerPage.vue similarity index 100% rename from ui-showcase/src/pages/DividerPage.vue rename to apps/ui-showcase/src/pages/DividerPage.vue diff --git a/ui-showcase/src/pages/DrawerPage.vue b/apps/ui-showcase/src/pages/DrawerPage.vue similarity index 100% rename from ui-showcase/src/pages/DrawerPage.vue rename to apps/ui-showcase/src/pages/DrawerPage.vue diff --git a/ui-showcase/src/pages/EllipsisPage.vue b/apps/ui-showcase/src/pages/EllipsisPage.vue similarity index 100% rename from ui-showcase/src/pages/EllipsisPage.vue rename to apps/ui-showcase/src/pages/EllipsisPage.vue diff --git a/ui-showcase/src/pages/EmptyStatePage.vue b/apps/ui-showcase/src/pages/EmptyStatePage.vue similarity index 100% rename from ui-showcase/src/pages/EmptyStatePage.vue rename to apps/ui-showcase/src/pages/EmptyStatePage.vue diff --git a/ui-showcase/src/pages/FloatButtonPage.vue b/apps/ui-showcase/src/pages/FloatButtonPage.vue similarity index 100% rename from ui-showcase/src/pages/FloatButtonPage.vue rename to apps/ui-showcase/src/pages/FloatButtonPage.vue diff --git a/ui-showcase/src/pages/FormPage.vue b/apps/ui-showcase/src/pages/FormPage.vue similarity index 100% rename from ui-showcase/src/pages/FormPage.vue rename to apps/ui-showcase/src/pages/FormPage.vue diff --git a/ui-showcase/src/pages/GuideStartPage.vue b/apps/ui-showcase/src/pages/GuideStartPage.vue similarity index 100% rename from ui-showcase/src/pages/GuideStartPage.vue rename to apps/ui-showcase/src/pages/GuideStartPage.vue diff --git a/ui-showcase/src/pages/GuideThemePage.vue b/apps/ui-showcase/src/pages/GuideThemePage.vue similarity index 97% rename from ui-showcase/src/pages/GuideThemePage.vue rename to apps/ui-showcase/src/pages/GuideThemePage.vue index e0aebbd3..005cc55f 100644 --- a/ui-showcase/src/pages/GuideThemePage.vue +++ b/apps/ui-showcase/src/pages/GuideThemePage.vue @@ -19,7 +19,7 @@ const cssVarCode = `:root { .accent-bg-soft { background: rgb(var(--accent-rgb) / 0.12); } .accent-text { color: rgb(var(--accent-rgb)); }` -// 变量名与默认值来自 frontend/src/style.css 的 :root 与 useTheme.ts(violet 预设)。 +// 变量名与默认值来自 apps/frontend/src/style.css 的 :root 与 useTheme.ts(violet 预设)。 const cssVarRows = [ { name: '--accent-rgb', value: '129 115 223', desc: '主强调色,空格分隔的 RGB 三元组,配合 rgb(var(...) / alpha) 派生透明度' }, { name: '--accent-hover-rgb', value: '145 132 235', desc: '悬停态强调色,深色模式下也用作更亮的文字色' }, @@ -91,7 +91,7 @@ setAccentColor('emerald') // 切换主题色,写入 CSS 变量和 localStorage

CSS 变量清单

- 主题相关变量定义在 frontend/src/style.css 的 + 主题相关变量定义在 apps/frontend/src/style.css:root 中, setAccentColor 切换主题色时会在运行时改写。默认值为紫罗兰(violet)预设。

diff --git a/ui-showcase/src/pages/HighlightPage.vue b/apps/ui-showcase/src/pages/HighlightPage.vue similarity index 100% rename from ui-showcase/src/pages/HighlightPage.vue rename to apps/ui-showcase/src/pages/HighlightPage.vue diff --git a/ui-showcase/src/pages/ImagePage.vue b/apps/ui-showcase/src/pages/ImagePage.vue similarity index 100% rename from ui-showcase/src/pages/ImagePage.vue rename to apps/ui-showcase/src/pages/ImagePage.vue diff --git a/ui-showcase/src/pages/InfiniteScrollPage.vue b/apps/ui-showcase/src/pages/InfiniteScrollPage.vue similarity index 100% rename from ui-showcase/src/pages/InfiniteScrollPage.vue rename to apps/ui-showcase/src/pages/InfiniteScrollPage.vue diff --git a/ui-showcase/src/pages/InputPage.vue b/apps/ui-showcase/src/pages/InputPage.vue similarity index 100% rename from ui-showcase/src/pages/InputPage.vue rename to apps/ui-showcase/src/pages/InputPage.vue diff --git a/ui-showcase/src/pages/LinkPage.vue b/apps/ui-showcase/src/pages/LinkPage.vue similarity index 100% rename from ui-showcase/src/pages/LinkPage.vue rename to apps/ui-showcase/src/pages/LinkPage.vue diff --git a/ui-showcase/src/pages/ListPage.vue b/apps/ui-showcase/src/pages/ListPage.vue similarity index 100% rename from ui-showcase/src/pages/ListPage.vue rename to apps/ui-showcase/src/pages/ListPage.vue diff --git a/ui-showcase/src/pages/LoadingBarPage.vue b/apps/ui-showcase/src/pages/LoadingBarPage.vue similarity index 100% rename from ui-showcase/src/pages/LoadingBarPage.vue rename to apps/ui-showcase/src/pages/LoadingBarPage.vue diff --git a/ui-showcase/src/pages/LoadingPage.vue b/apps/ui-showcase/src/pages/LoadingPage.vue similarity index 100% rename from ui-showcase/src/pages/LoadingPage.vue rename to apps/ui-showcase/src/pages/LoadingPage.vue diff --git a/ui-showcase/src/pages/MarqueePage.vue b/apps/ui-showcase/src/pages/MarqueePage.vue similarity index 100% rename from ui-showcase/src/pages/MarqueePage.vue rename to apps/ui-showcase/src/pages/MarqueePage.vue diff --git a/ui-showcase/src/pages/MediaPage.vue b/apps/ui-showcase/src/pages/MediaPage.vue similarity index 100% rename from ui-showcase/src/pages/MediaPage.vue rename to apps/ui-showcase/src/pages/MediaPage.vue diff --git a/ui-showcase/src/pages/MentionPage.vue b/apps/ui-showcase/src/pages/MentionPage.vue similarity index 100% rename from ui-showcase/src/pages/MentionPage.vue rename to apps/ui-showcase/src/pages/MentionPage.vue diff --git a/ui-showcase/src/pages/MenuPage.vue b/apps/ui-showcase/src/pages/MenuPage.vue similarity index 100% rename from ui-showcase/src/pages/MenuPage.vue rename to apps/ui-showcase/src/pages/MenuPage.vue diff --git a/ui-showcase/src/pages/ModalPage.vue b/apps/ui-showcase/src/pages/ModalPage.vue similarity index 100% rename from ui-showcase/src/pages/ModalPage.vue rename to apps/ui-showcase/src/pages/ModalPage.vue diff --git a/ui-showcase/src/pages/NumberAnimationPage.vue b/apps/ui-showcase/src/pages/NumberAnimationPage.vue similarity index 100% rename from ui-showcase/src/pages/NumberAnimationPage.vue rename to apps/ui-showcase/src/pages/NumberAnimationPage.vue diff --git a/ui-showcase/src/pages/NumberInputPage.vue b/apps/ui-showcase/src/pages/NumberInputPage.vue similarity index 100% rename from ui-showcase/src/pages/NumberInputPage.vue rename to apps/ui-showcase/src/pages/NumberInputPage.vue diff --git a/ui-showcase/src/pages/OverviewPage.vue b/apps/ui-showcase/src/pages/OverviewPage.vue similarity index 99% rename from ui-showcase/src/pages/OverviewPage.vue rename to apps/ui-showcase/src/pages/OverviewPage.vue index b9c4f1d3..7cb79c8f 100644 --- a/ui-showcase/src/pages/OverviewPage.vue +++ b/apps/ui-showcase/src/pages/OverviewPage.vue @@ -59,7 +59,7 @@ function onGroupAnchorClick(groupId: string) {