feat: app 骨架 + 网络层 + 认证(登录/注册)

- flutter create 初始化 + 依赖(riverpod 3/dio/go_router/three_dart 0.0.16)
- ApiClient:JWT 拦截器 + 统一响应解包 + 401 登出回调 + multipart 上传
- TokenStorage(shared_preferences)+ AuthNotifier 登录/登出
- 登录页(含注册对话框)+ 4 Tab 主框架
- 6 个单元测试通过(网络层 4 + 认证 2)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:39:01 +08:00
co-authored by Claude Opus 4.7
commit acbe60c13c
144 changed files with 6887 additions and 0 deletions
@@ -0,0 +1,372 @@
# slogan-app MVP 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 实现 slogan-app Flutter MVP:登录 → 4 Tab 框架 → 拍照上传 → 衣橱管理 → 化身查看 → 穿搭生成(日期/地点 → 轮询 → 方案流 3D+2D 切换查看)。
**Architecture:** Flutter 单仓,Riverpod 状态管理,Dio 网络层(JWT 拦截器 + 统一响应),AvatarViewer 渲染抽象层(three_dart v1 实现,flutter_scene 后续替换),核心交互为方案 PageView 横滑 + 3D 化身拖拽旋转。
**Tech Stack:** Flutter (stable) / Riverpod / Dio / go_router / three_dart + three_dart_jsm / cached_network_image / camera + image_picker / shared_preferences
**后端对接:** slogan-agentGoAPI,见 slogan-agent 仓库 `docs/superpowers/specs/2026-07-31-slogan-agent-design.md` 第 11 节路由表。
---
### Task 1: 项目骨架
**Files:**
- Run: `flutter create` 初始化(org 自定,项目名 slogan_app
- Modify: `pubspec.yaml`(依赖)
- Create: `lib/main.dart`(入口 + 主题 + go_router 路由表)
- Create: `lib/core/config/app_config.dart`
- [ ] **Step 1: 初始化**
```bash
cd slogan-app
flutter create --org com.slogan --project-name slogan_app .
```
- [ ] **Step 2: pubspec.yaml 依赖**
```yaml
dependencies:
flutter_riverpod: ^2.6.0
dio: ^5.7.0
go_router: ^14.0.0
cached_network_image: ^3.4.0
image_picker: ^1.1.0
camera: ^0.11.0
shared_preferences: ^2.3.0
three_dart: ^0.2.0
three_dart_jsm: ^0.2.0
fluttertoast: ^8.2.0
```
(版本以 pub.dev 最新稳定为准,`flutter pub add` 逐个添加)
- [ ] **Step 3: main.dart**MaterialApp + ThemeMaterial 3seed 主色)+ go_router 路由(login / home(4 tab) / photo-guide / plan-flow / plan-detail);启动时读取 token → 无 token 重定向登录页
- [ ] **Step 4: 验证** `flutter analyze` 无错误 + `flutter test` 默认通过
- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: flutter skeleton with router and theme"`
---
### Task 2: 网络层(ApiClient + JWT 拦截器)
**Files:**
- Create: `lib/core/network/api_client.dart`
- Create: `lib/core/network/api_exception.dart`
- Create: `lib/core/storage/token_storage.dart`
- Test: `test/core/network/api_client_test.dart`
- [ ] **Step 1: 写失败测试**mock Dio adapter401 触发登出回调;code!=0 抛 ApiException 带 message;成功解析 data
- [ ] **Step 2: 确认失败** `flutter test`
- [ ] **Step 3: 实现 api_client.dart**
```dart
class ApiClient {
ApiClient({Dio? dio, required TokenStorage tokenStorage})
: _tokenStorage = tokenStorage {
_dio = dio ?? Dio(BaseOptions(
baseUrl: AppConfig.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
final token = _tokenStorage.token;
if (token != null) options.headers['Authorization'] = 'Bearer $token';
handler.next(options);
},
onError: (e, handler) {
if (e.response?.statusCode == 401) onUnauthorized?.call();
handler.next(e);
},
));
}
Future<T> post<T>(String path, Map<String, dynamic> body,
{T Function(dynamic data)? parse}) async {
final res = await _dio.post(path, data: body);
return _unwrap<T>(res, parse);
}
Future<T> get<T>(String path, {Map<String, dynamic>? query, T Function(dynamic data)? parse}) async { ... }
Future<T> upload<T>(String path, Map<String, dynamic> fields, String fileField, String filePath, {String Function(dynamic)? parse}) async { ... }
T _unwrap<T>(Response res, ...) {
final code = res.data['code'] as int;
final message = res.data['message'] as String? ?? '';
if (code != 0) throw ApiException(code, message);
return parse?.call(res.data['data']) ?? res.data['data'] as T;
}
VoidCallback? onUnauthorized;
}
```
- [ ] **Step 4: TokenStorage**shared_preferences 封装:token 读写 + 清空)
- [ ] **Step 5: 测试通过** + `flutter analyze` + **Commit**
---
### Task 3: 认证(登录页 + 状态)
**Files:**
- Create: `lib/core/auth/auth_provider.dart`
- Create: `lib/features/auth/login_page.dart`
- Test: `test/core/auth/auth_provider_test.dart`
- [ ] **Step 1: 写失败测试**ProviderContainer:登录成功 → token 持久化 + 状态 authenticated;失败 → 状态 error 携带 message
- [ ] **Step 2: 实现 auth_provider.dart**AsyncNotifierlogin(account, password) → ApiClient.post('/user/login') → 存 token
- [ ] **Step 3: login_page.dart**:账号/密码输入 + 登录按钮 + 加载态 + 错误提示(fluttertoast);登录成功 go_router push 替换到 home
- [ ] **Step 4: 测试通过** + **Commit**
---
### Task 4: 4 Tab 主框架
**Files:**
- Create: `lib/features/home/home_page.dart`BottomNavigationBar + IndexedStack 4 Tab
- Create: `lib/features/profile/profile_page.dart`(占位)
- Create: `lib/features/wardrobe/wardrobe_page.dart`(占位)
- Create: `lib/features/outfit/outfit_page.dart`(占位)
- Create: `lib/features/commercial/commercial_page.dart`(占位)
- [ ] **Step 1: 实现 4 Tab 框架**:底部导航(我的形象/我的衣橱/穿搭方案/门店电商)+ 图标 + IndexedStack 保状态
- [ ] **Step 2: Widget 测试**:切 Tab 显示对应页面
- [ ] **Step 3: 测试通过** + **Commit**
---
### Task 5: 拍照引导 + 照片上传(Tab1)
**Files:**
- Create: `lib/features/profile/photo_guide_page.dart`(引导 + 拍摄)
- Create: `lib/features/profile/photo_guide_item.dart`(单张拍摄卡片)
- Create: `lib/features/profile/photo_upload_provider.dart`
- Create: `lib/features/profile/profile_page.dart`(集成:照片齐备度展示 + 上传入口)
- Test: `test/features/profile/photo_upload_provider_test.dart`
- [ ] **Step 1: 写失败测试**providermock ApiClient.upload 成功 → 状态更新;失败 → error)
- [ ] **Step 2: 实现 provider**:4 个类型照片上传(type 1-4),逐张上传成功后标记完成;本地压缩(`image_picker` 自带 maxWidth: 2048
- [ ] **Step 3: photo_guide_page.dart**:4 张卡片(大头照/全身正面/侧面/背面,各含拍摄示例说明文案 + 相机按钮 image_picker 拍摄)+ 上传进度 + 完成态跳转
- [ ] **Step 4: profile_page.dart**:显示 4 张照片状态(已传/未传)+ "进入拍摄引导" 按钮 + 身形参数入口(Task 6)
- [ ] **Step 5: 测试通过** + **Commit**
---
### Task 6: 身形参数 + 化身状态(Tab1)
**Files:**
- Create: `lib/features/profile/body_tune_page.dart`(滑杆微调)
- Create: `lib/features/profile/body_provider.dart`
- Create: `lib/features/profile/avatar_provider.dart`
- Test: `test/features/profile/avatar_provider_test.dart`
- [ ] **Step 1: 写失败测试**avatar providerget → build → 状态 done + glbUrl 非空)
- [ ] **Step 2: body_tune_page.dart**:身高(145-200cm)/体重/肤色(1-5) 滑杆,保存 → POST /body-measurement/save
- [ ] **Step 3: avatar_provider.dart**:页面进入时 GET /avatar/get → 无记录则提示先传照片 → POST /avatar/build → 轮询 build_status(每 2s × 最多 30 次)→ done 后展示 glb_url
- [ ] **Step 4: profile_page.dart** 集成:身形参数卡片 + 化身构建按钮 + 构建状态展示
- [ ] **Step 5: 测试通过** + **Commit**
---
### Task 7: 衣橱管理(Tab2
**Files:**
- Create: `lib/features/wardrobe/wardrobe_provider.dart`
- Create: `lib/features/wardrobe/wardrobe_page.dart`(网格)
- Create: `lib/features/wardrobe/wardrobe_upload_page.dart`(上传表单:照片 + 分类 + 季节 + 风格标签)
- Test: `test/features/wardrobe/wardrobe_provider_test.dart`
- [ ] **Step 1: 写失败测试**providerupload 成功追加列表;delete 移除;list 加载)
- [ ] **Step 2: 实现 provider** + 上传表单页(DropdownButton 分类[上衣/下装/鞋/配饰] + 季节 + 标签输入)
- [ ] **Step 3: 网格页**GridView 服装照片 + 长按删除确认 + 空态引导("衣橱空空如也,去上传第一件衣服吧")
- [ ] **Step 4: 测试通过** + **Commit**
---
### Task 8: 生成入口(Tab3 上半)
**Files:**
- Create: `lib/features/outfit/generate_page.dart`
- Create: `lib/features/outfit/outfit_generate_provider.dart`
- Test: `test/features/outfit/outfit_generate_provider_test.dart`
- [ ] **Step 1: 写失败测试**providergenerate 成功返回 taskId;开始轮询状态)
- [ ] **Step 2: 生成入口页**:日期范围(showDateRangePicker+ 地点(TextField + 定位按钮[geolocator 可选,v1 手动输入]+ "生成穿搭" 按钮(校验:日期非空/地点非空/衣橱非空提示)
- [ ] **Step 3: 生成确认后** → 跳转任务状态页(Task 9
- [ ] **Step 4: 测试通过** + **Commit**
---
### Task 9: 任务轮询 + 方案流(Tab3 核心)
**Files:**
- Create: `lib/features/outfit/task_status_page.dart`
- Create: `lib/features/outfit/plan_flow_page.dart`PageView 横滑)
- Create: `lib/features/outfit/plan_provider.dart`
- Test: `test/features/outfit/plan_provider_test.dart`
- [ ] **Step 1: 写失败测试**providertask 轮询 done → 加载 plan listfailed → error
- [ ] **Step 2: task_status_page.dart**:轮询 GET /outfit/task/statusTimer.periodic 3s),状态文案映射(pending=准备中/planning=方案规划中/scoring=方案评分中/rendering=效果图生成中/done=完成/failed=失败+error 展示);done → 跳转方案流;失败显示重试
- [ ] **Step 3: plan_provider.dart**GET /outfit/plan/list + detail(含 items/images/hairstyle
- [ ] **Step 4: plan_flow_page.dart**PageView.builder 每页一张方案卡片:
- 3D 化身区(AvatarViewerTask 10
- 方案摘要(标题/评分 Chip/来源标签:衣橱组合=蓝 / AI 推荐=橙)
- 发型切换(横排发型 chip)+ 发色取色(HSV 面板)
- 底部条目列表(slot 图标 + 名称 + 描述;推荐条目带"查看商品"入口,v1 占位)
- "选为主方案" 按钮 → POST select-main → 效果图页(Task 11
- [ ] **Step 5: 测试通过** + **Commit**
---
### Task 10: AvatarViewer 抽象 + three_dart 实现
**Files:**
- Create: `lib/features/outfit/viewer/avatar_viewer.dart`(接口 + controller
- Create: `lib/features/outfit/viewer/avatar_viewer_three_dart.dart`three_dart 实现)
- Create: `lib/features/outfit/viewer/avatar_viewer_placeholder.dart`(降级占位:头像图 + 手势提示)
- Create: `lib/features/outfit/viewer/viewer_factory.dart`
- Test: `test/features/outfit/viewer/avatar_viewer_placeholder_test.dart`
- [ ] **Step 1: 定义抽象接口**
```dart
/// 渲染层抽象:业务代码只依赖此接口,flutter_scene 进 stable 后提供第二实现
abstract class AvatarViewerController {
Future<void> loadAvatar(String glbUrl); // 头像+体型主体
Future<void> loadHairstyle(String glbUrl); // 发型层
void setHairColor(Color color); // 发色 PBR baseColor
void rotateBy(double dx, double dy);
void zoomBy(double scale);
void resetView();
}
class AvatarViewer extends StatefulWidget {
final AvatarViewerController Function() controllerFactory;
...
}
```
- [ ] **Step 2: 实现 three_dart 版**`three_dart` + `three_dart_jsm` GLTFLoader 加载 GLB → Scene 显示(DirectionalLight + AmbientLight + OrbitControls 式手动手势:onPanUpdate → rotateBy 旋转 Object3DonScaleUpdate → zoomBy 缩放相机/模型)—— 参考 `three_js_advanced_loaders` 示例;GLB 本地缓存(Task 13
- [ ] **Step 3: 降级实现**:加载失败(网络/格式)→ placeholder(用户大头照 Image + "3D 模型加载失败,显示照片效果" 文案)
- [ ] **Step 4: viewer_factory.dart**`AvatarViewer createViewer()` → three_dart 实现(有 GLB url 时)/ placeholder(无 url 时)
- [ ] **Step 5: Widget 测试**placeholder 渲染)+ `flutter analyze` + **Commit**
---
### Task 11: 效果图查看 + 方案详情(Tab3 下半)
**Files:**
- Create: `lib/features/outfit/effect_image_page.dart`
- Create: `lib/features/outfit/plan_detail_provider.dart`
- Test: `test/features/outfit/plan_detail_provider_test.dart`
- [ ] **Step 1: 写失败测试**providerselect-main → 轮询 detail.images 直到 3 张完成)
- [ ] **Step 2: select-main 后跳转效果图页**:3 视角(正面/侧面/背面)Tab/滑块切换,cached_network_image 加载,生成中展示进度(轮询 plan/detail images status
- [ ] **Step 3: 方案详情页**(从列表进入):完整 detail 渲染(items 图片/名称/描述 + 效果图 + 收藏按钮 POST review
- [ ] **Step 4: 测试通过** + **Commit**
---
### Task 12: 门店/电商(Tab4MVP 列表展示)
**Files:**
- Create: `lib/features/commercial/store_page.dart`(附近门店列表)
- Create: `lib/features/commercial/store_provider.dart`
- Create: `lib/features/commercial/subscription_page.dart`(订阅占位)
- Test: `test/features/commercial/store_provider_test.dart`
- [ ] **Step 1: 写失败测试**providerGET /partner-store/list 解析列表)
- [ ] **Step 2: 门店列表页**:类型筛选 chip(形象设计/服装门店)+ ListView 卡片(名称/类型/地址/距离占位)
- [ ] **Step 3: 订阅页**:标准版/Pro 权益卡片 + "开通"按钮(v1 占位 toast"支付功能开发中"
- [ ] **Step 4: 测试通过** + **Commit**
---
### Task 13: 缓存与性能
**Files:**
- Modify: `lib/core/storage/`(新增 glb_cache.dart
- Create: `lib/core/storage/glb_cache.dart`GLB 本地 LRU
- Test: `test/core/storage/glb_cache_test.dart`
- [ ] **Step 1: 写失败测试**(缓存:put → hit;LRU 超限淘汰;过期清理)
- [ ] **Step 2: 实现**:文件缓存目录 `getApplicationSupportDirectory()/glb_cache/`key=url hash256MB 上限(超限按最后访问时间淘汰);方案流预取下一页 GLB
- [ ] **Step 3: 测试通过** + **Commit**
---
### Task 14: 错误处理与加载状态组件
**Files:**
- Create: `lib/shared/widgets/loading_view.dart`(骨架屏)
- Create: `lib/shared/widgets/error_view.dart`(错误 + 重试)
- Create: `lib/shared/widgets/empty_view.dart`(空态 + 引导动作)
- [ ] **Step 1: 三个组件**loading 骨架、error 文案+重试回调、empty 图标+文案+按钮)
- [ ] **Step 2: 接入**profile/wardrobe/outfit/commercial 各页加载态/空态/错误态替换
- [ ] **Step 3: Widget 测试** + **Commit**
---
### Task 15: 集成冒烟(联调 slogan-agent
- [ ] **Step 1: 本地起 slogan-agent**`go run main.go`,3007 端口,mock 效果图供应商)
- [ ] **Step 2: App 联调**iOS 模拟器 + Android 模拟器各一遍):
1. 登录 → 2. 拍照引导上传 4 张(相册选图代替拍摄)→ 3. 身形参数 → 4. 构建化身 → 5. 衣橱上传 3+ 件 → 6. 生成穿搭(日期+地点)→ 7. 任务轮询 → 8. 方案流(3D 查看/发型切换/横滑)→ 9. 选主方案 → 10. 效果图 3 视角查看
- [ ] **Step 3: 修复联调问题**(网络/字段/状态映射),`flutter analyze` 0 错误
- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete, verified against slogan-agent"`
---
## Self-Review 备注
- 后端字段命名 snake_caseJSON),Dart 侧解析用 map 取值避免命名映射负担
- 效果图生成在后端为异步任务,App 端轮询 plan/detail 的 images 状态(rendering→done
- three_dart 若 iOS 渲染异常(着色器兼容),降级路径 placeholder 保证 MVP 可用;GLB 渲染验证放 Task 10 明确检查