This commit is contained in:
2026-08-17 13:19:15 +08:00
parent 2b7c1c790b
commit 0e93c4f0a9
314 changed files with 2151 additions and 17468 deletions
+6
View File
@@ -1,3 +1,9 @@
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
.gstack/
# 运行时数据与构建产物(不提交)
server/data/
server/workspace/
server/main
+109
View File
@@ -0,0 +1,109 @@
# CLAUDE.md
## 目录结构与职责(硬性约束)
> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目即 `styleagent/`,位于 `server/styleagent/`),禁止新项目照抄 `biz/`;`ui-src/`、`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。以下表格路径均以 `server/` 为根(前端另见 `app-uni/`)。
| 目录 | 职责 | 强约束 |
|---|---|---|
| common/ | 通用层:HTTP 服务与鉴权中间件、RouteRegister 反射路由、DAO 基类、查询缓存、锁与协程池封装、DB 组访问器、文件存储 | 不得依赖业务模块包;新增跨模块通用能力放这里 |
| styleagent/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小 | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 |
| styleagent/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) |
| styleagent/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 |
| styleagent/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 |
| styleagent/controller/ | 接口层:接收参数、调用 service、原样返回 service 结果 | 见「分层职责规范」;禁止调用 dao |
| 前端目录 | 前台 `app-uni/` 为 uni-app (Vue 3) 多端工程(Android/iOS/微信小程序/H5);后台管理端未建 | H5 构建产物由 server 托管(main.go WebStaticDir);多端工程差异见技术设计.md |
| 运行时数据目录 | SQLite 库、上传/生成文件(本项目 `server/data/` `server/workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 |
## 分层职责规范(硬性要求)
严格分层 `controller → service → dao`,禁止跨层调用(controller 禁止直接调 dao)。
| 层 | 目录 | 职责 | 禁止 |
|---|---|---|---|
| controller | server/styleagent/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 |
| service | server/styleagent/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 |
| dao | server/styleagent/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 |
**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。
**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。
**教训(此前偏离原因,开发时引以为戒)**:
1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传"
2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段
3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式
## 分层文件对齐与代码模式(硬性要求)
- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;**无 HTTP 出入口的表(纯内部配置、聚合数据、状态聚合等)豁免 controller/dto 分层**:entity/dao/service 保留,数据经所属主表端点透出(如详情页聚合子表数据),禁止造无路由的空壳 controller/dto 门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致
- **非表文件一律不进业务分层目录**:路由注册与中间件装配写在 `main.go`(公共中间件与 RouteRegister 在 `common/http.go`);建表在 dao `init()`(CREATE TABLE IF NOT EXISTS + 索引 + 迁移),死表 DROP 由启动时与代码对齐;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件
- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件
- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射
- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑
- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record)
- dao:单例 `var Xxx = &xxxDao{}`,`init()``CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk)
- controller:路由**反射注册**(`common.RouteRegister`,按 controller 结构体名转 kebab-case 组前缀),path/method 唯一来源为 dto 的 `g.Meta`(携带 path/method/summary);新增接口只需写 dto + controller 方法,禁止在 main.go 手动逐条注册;跨组方法以 `H5` 前缀命名(h5 组只注册 H5 开头方法,admin 组跳过);直接写响应体的例外场景(如支付回调裸文本 "success")注册在 controller 内并注释说明
- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE
- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值"
## 错误处理规范(硬性要求)
- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过
- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错
## 并发规范
- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上)
- **新增并行点的固定三处**:common 加池封装(grpool)→ `biz/consts` 加默认大小 → `config.yml``key: 并发度`(缺失或非法时回退默认值)
- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池
- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine
- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外)
- **锁的使用**:互斥场景唯一入口是 `common.WithLock[T any](ctx, key, expire, retries, retryInterval, fn func() (T, error)) (T, error)`——泛型回调,业务返回值经 T 原样透出给下游;内部按 config.yml 自动选择锁实现(配置了 `redis` 节点 → redis 锁,跨实例互斥,SET NX EX + token 对比删除防误删他人锁;未配置 → gcache 内存锁,单实例互斥),禁止直接用 gcache/gredis 自己实现加锁。拿不到锁(被占用,`ErrLockHeld`)最多重试 `retries` 次、每次间隔 `retryInterval`(`retries=0` 立即失败;ctx 取消/超时同样终止等待);中间件故障不重试直接返回。锁自动释放:无论 fn 成功、失败还是 panic,defer 释放。`expire` 必须 > 0(进程崩溃兜底不死锁),fn 耗时必须在 expire 前完成,fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小
- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程
## 文档职责(三文档体系)
| 文档 | 职责 | 何时补充/更新 |
|---|---|---|
| CLAUDE.md(本文件) | 公司通用开发规范:分层职责、代码模式、并发/事务/缓存约束、流程 | 规范变化时 |
| README.md | 项目功能介绍:架构、数据流、表清单、功能模块、API 清单、使用说明 | 功能增减时 |
| 技术设计.md | 实现细节与技术决策:DDL、检索参数、风险与备选方案 | 关键技术决策/参数变化时 |
## 开发流程(文档驱动,硬性要求)
永远以文档驱动开发:用户提出开发需求 → 先给出实现方案(技术选型、影响面、改动清单) → **用户确认后先补充文档再动手写代码**。补充哪个文档取决于内容性质:规范 → 本文件,功能 → README,实现细节/技术决策 → 技术设计.md。禁止未经确认直接开发,禁止先写代码后补文档。
## 数据访问规范(硬性要求)
- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback
- **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999)
- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组
- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存
- **批处理 SQL**:批量写入用 `InsertAll` 类方法,批量删除用 `IN` 子句,禁止循环单条 INSERT/DELETE
- **配置即使用**:config.yml 中出现 redis / mq 等中间件配置时,代码必须实际接入使用,禁止"配置了但代码不用"或"代码写死但配置缺失"
- **消息/回调幂等**:接入 MQ / Webhook 时,消费与回调处理必须幂等——MQ 至少一次语义、webhook 失败重试都可能重复投递同一事件,禁止依赖"只投一次"假设。幂等手段:以业务唯一键(如 `任务ID + 事件类型`)先查重或建唯一约束再落库,重复事件直接忽略;重试与补偿逻辑同样要防重复执行
## 运维部署规范(硬性要求)
- **部署形态**:Docker 单镜像部署,前后端一体单端口(8080);运行时数据目录挂载持久化,容器重建不丢数据。部署文件为 `server/Dockerfile`,启动与使用见 README
- **数据即文件**:文件型存储,备份 = 打包「数据库目录(小而关键)+ 上传文件目录(大而可重建)」两个目录;迁移 = 拷贝到新机器即可
- **运行时数据与代码分离**:数据目录不提交 git;**删除即丢数据,改动前先确认**
- **配置即文件**:项目配置文件为唯一配置入口(监听端口、并发度等),环境变量覆盖无效
## 金额单位规范(硬性要求)
- **金额一律以「分」为单位存储与传输**:所有金额字段(零售价、进价、单价、小计、总金额)在数据库与接口中使用整数分(entity/dto 字段类型 `int64`),禁止浮点元
- **展示转换在前端**:前端展示 ÷100 转元保留两位小数,提交 ×100 转分;禁止后端做元↔分转换,后端只处理整数分
- **金额计算**:`数量(浮点克) × 单价(分)` 后必须 `common.RoundInt` 四舍五入到整数分,禁止裸浮点累加
- **量纲区分**:数量/库存/用量为浮点克(REAL),金额为整数分;混算时必须显式转换
- 存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行
## 约定
- controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}`
- **参数校验优先用 GoFrame DTO 校验**:请求结构体用 `v` tag(required / regex / in 等)声明,框架自动校验并返回错误,controller 不手写校验;仅 DTO 表达不了的业务规则(跨字段依赖、查库校验如重名、取值范围依赖配置)放 service。JSON 格式解析可留在 controller 或下沉 service,但须保持与调用点一致
- 响应组装(实体 → DTO 字段映射)在 service 进行,controller 只透传
- service 方法签名 ctx 开头,错误统一用 `gerror`
- 编译验证:`go build ./...`
- **H5 客户侧文案合规(硬性)**:H5 页面与微信模板消息面向终端消费者,软件方无医疗资质,禁止出现 诊所/开方/处方/药方/医嘱/药品/服药/诊疗 等医疗行为用语——暗示诊疗即违规;一律用中性话术:「健康打卡」「调理」「饮食禁忌提醒」「联系服务机构」等;登录页与浏览器标题等可被搜索引擎收录的对外表面保持中性品牌名
+200
View File
@@ -0,0 +1,200 @@
# 我的形象穿搭(slogan
AI 形象穿搭应用:用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型与场合建议),支持 3D 化身查看、效果图生成、CPS 联盟商品推荐与会员订阅。
## 技术栈与架构
- **前端** `app-uni/`uni-app (Vue 3 + Vite + Pinia) 多端工程(Android/iOS/微信小程序/H5),H5 构建产物由后端托管
- **后端** `server/`Go 1.22+ / GoFrame v2 / SQLite / JWT
- 依赖服务:OpenAI 兼容大模型(穿搭方案生成)、通义万相(效果图出图)、Tripo(图像转 3D 化身)、和风天气 + 高德地理编码、虎皮棋聚合支付、美团/京东/淘宝 CPS 联盟
- **部署**:Docker 单镜像,前后端一体单端口 **8080**`server/Dockerfile`
```
app-uni/ ──H5 构建产物──▶ server 静态托管(:8080
server/ ──▶ 4 个 SQLite 库(data/+ 文件存储(workspace/
```
## 快速开始
```bash
# 后端(server/ 目录)
go mod tidy
go build -o slogan-agent .
./slogan-agent # 监听 :8080,首次启动自动建库建表(server/data/ 下 4 个 SQLite
# 前端 H5 开发(app-uni/ 目录,BASE_URL 指向 http://127.0.0.1:8080
npm install
npm run dev:h5
```
- 测试账号:`wenwu901` / `123456`(登录页自带「测试账号一键登录」按钮)
- OpenAPI 文档:`http://127.0.0.1:8080/api.json`
- 联调与测试规范:**必须使用真实用户数据**(测试账号),禁止用临时注册新账号验证业务链路
## 配置说明(server/config.yml
| 配置节 | 说明 |
|---|---|
| `database.*` | 4 个 SQLite 分组(default/plan/pay/cps),落盘 `data/``cache.ttl` 查询缓存秒数 |
| `server.address` | 监听地址(:8080);`clientMaxBodySize` 上传大小上限 |
| `llm` | 大模型(OpenAI 兼容),未配置时生成任务失败并返回明确错误 |
| `imagegen` | 效果图供应商:`wanx`(通义万相,需 `wanx_api_key` |
| `weather.geo` | 和风天气 v7 Key + 高德地理编码 Key,未配置时穿搭生成跳过天气/地理推荐 |
| `payment` | 虎皮棋聚合支付(`xunhu_appid/appsecret` 为空则支付功能降级关闭) |
| `avatar` | Tripo 3D 生成(`tripo_api_key` 为空则 /avatar/build 返回失败提示) |
| `render` | 化身帧序列预渲染(Node + headless-gl |
| `cps` | 美团/京东/淘宝联盟(key 全空则联盟入口优雅降级隐藏) |
| `ad` | 广告激励限频(自然日):`effect_extra` 2 次 / `vip_trial` 1 次 |
> 注:`wanx_api_key` 与 `llm.api_key` 为真实 Key 占位,提交前请勿携带真实密钥。
## 数据库(4 库 22 表)
### slogan.dbdefault,用户域)
| 表 | 用途 |
|---|---|
| slogan_user | 用户账号(username/phone/password/role |
| slogan_user_photo | 用户照片(type: 1 大头照 / 2 全身正面 / 3 全身侧面 / 4 全身背面) |
| slogan_wardrobe_item | 衣橱单品(分类/季节/风格标签/颜色) |
| slogan_body_measurement | 身形参数(身高/体重/三围/肩宽/肤色) |
| slogan_avatar_model | 3D 化身(Tripo 构建状态 + GLB/帧序列 URL |
| slogan_scoring_rule | 方案评分规则(5 维权重,可配置) |
| slogan_partner_store | 合作门店(形象设计/服装门店) |
### slogan_plan.dbplan,穿搭域)
| 表 | 用途 |
|---|---|
| slogan_hairstyle_asset | 发型资产库(GLB + 预览图,seed 8 款) |
| slogan_outfit_generation_task | 穿搭生成任务(状态机 pending→planning→scoring→rendering→done/failed |
| slogan_outfit_plan | 穿搭方案(日期地点/标题/来源/评分/主方案标记/发型引用) |
| slogan_plan_outfit_item | 方案穿衣清单(slot 槽位,来源:衣橱 or AI 新品) |
| slogan_plan_effect_image | 方案效果图(角度: front/side/back,万相出图) |
| slogan_plan_review | 方案反馈(fav/unfav |
### slogan_pay.dbpay,支付域)
| 表 | 用途 |
|---|---|
| slogan_user_member | 用户会员(套餐/到期时间) |
| slogan_member_plan | 会员套餐(金额分/时长/权益,可配置) |
| slogan_payment_order | 支付订单(渠道/状态/回调原始报文) |
| slogan_pay_notify_log | 支付回调日志(幂等落库) |
| slogan_ad_reward_log | 广告激励领取记录(限频) |
### slogan_cps.dbcps,联盟域)
| 表 | 用途 |
|---|---|
| slogan_cps_category | 联盟统一分类树(三源归一) |
| slogan_cps_product | 联盟商品池(美团/京东/淘宝,金额单位:分) |
| slogan_cps_click_log | 商品点击/转链记录 |
| slogan_scene_category_map | 业务场景 → 联盟分类映射(发型/买同款/升级款/延伸优惠/会员权益) |
## 功能模块与接口(35 个)
统一响应格式 `{"code":0,"message":"OK","data":...}``code != 0` 为业务错误;除公开接口外需 `Authorization: Bearer <token>`JWT7 天有效)。
| 模块 | 接口 | 说明 | 公开 |
|---|---|---|---|
| 用户 | `POST /user/register` | 注册 | 是 |
| 用户 | `POST /user/login` | 登录返回 token | 是 |
| 用户 | `POST /user/change-password` | 修改密码 | |
| 用户 | `GET /user/profile` | 我的资料 | |
| 照片 | `POST /user-photo/upload` | 上传照片 | |
| 照片 | `GET /user-photo/list` | 照片列表(type 筛选) | |
| 照片 | `POST /user-photo/delete` | 删除照片 | |
| 衣橱 | `POST /wardrobe/upload` | 上传服装(multipart: file + category/season/style_tags/color_info | |
| 衣橱 | `GET /wardrobe/list` | 衣橱列表 | |
| 衣橱 | `POST /wardrobe/update` | 更新服装信息 | |
| 衣橱 | `POST /wardrobe/delete` | 删除服装 | |
| 身形 | `POST /body-measurement/save` | 保存身形参数(身高/体重/三围/肩宽/肤色) | |
| 身形 | `GET /body-measurement/get` | 查询身形参数 | |
| 化身 | `POST /avatar/build` | 构建 3D 化身(Tripo 图像转 3D,异步) | |
| 化身 | `GET /avatar/get` | 我的化身(GLB/帧序列/构建状态) | |
| 发型 | `GET /hairstyle/list` | 发型资产库 | 是 |
| 穿搭 | `POST /outfit/generate` | 生成穿搭方案(异步任务) | |
| 穿搭 | `GET /outfit/task/status` | 任务状态 | |
| 穿搭 | `GET /outfit/plan/list` | 方案列表 | |
| 穿搭 | `GET /outfit/plan/detail` | 方案详情(穿衣清单 + 发型 + 效果图) | |
| 穿搭 | `POST /outfit/plan/select-main` | 选定主方案(触发 3 视角效果图生成) | |
| 穿搭 | `POST /outfit/plan/review` | 方案反馈 | |
| 门店 | `GET /partner-store/list` | 合作门店(type: 0 全部 / 1 形象设计 / 2 服装门店) | |
| 会员 | `GET /member/plan/list` | 会员套餐列表 | |
| 会员 | `GET /member/status` | 我的会员状态(含剩余广告权益) | |
| 会员 | `POST /member/order/create` | 创建支付订单 | |
| 会员 | `GET /member/order/status` | 订单状态(轮询) | |
| 会员 | `POST /member/order/notify` | 支付回调(裸文本 "success",幂等) | 是 |
| 广告 | `POST /ad/reward/claim` | 领取广告激励(看视频领效果图次数/体验会员,自然日限频) | |
| CPS | `GET /cps/category/list` | 统一分类列表 | |
| CPS | `GET /cps/product/list` | 商品池分页列表(分类/城市/场景筛选) | |
| CPS | `GET /cps/my/recent` | 我的优惠记录(点击去重) | |
| CPS | `POST /cps/product/link` | 商品转链(返回可打开链接) | |
| CPS | `GET /cps/plan/recommend` | 方案驱动推荐(延伸优惠) | |
| CPS | `GET /cps/wardrobe/upgrade` | 衣橱升级款 | |
| 静态 | `GET /workspace/*` | 上传文件与模板资产(鉴权放行) | |
## 核心业务流程
### 穿搭生成(outfit/generate
```
pending → planning(天气获取 → 规则预筛 3 套候选 → LLM 规划 1 次调用)
→ scoring(规则引擎 5 维评分:天气 25/场合 25/色彩 20/完整度 20/风格 10,阈值 75
→ 全低分 → LLM 兜底创作(1 次调用,recommend 方案)
→ 落库 outfit_plan + plan_outfit_item → done
```
- 衣橱不足 3 件、日期倒挂、Key 未配置等均在任务结果中返回明确错误
- 服务重启时未完成任务标记 failed(避免重复消耗模型费用)
### 效果图
- 选定主方案后异步生成 正面/侧面/背面 3 张(通义万相 wan2.7-image-pro),内容 hash 缓存 24h,每日限 3 次(`scoring_rule``effect_limit` 维度)
- 效果图次数可通过广告激励补充(`/ad/reward/claim``effect_extra` 自然日 2 次)
### 3D 化身
- 基于三视角全身照(正面/侧面/背面)→ Tripo 图像转 3D 生成 GLB → 本地 Nodeheadless-gl)渲染 36 帧旋转预览
- 构建为异步任务,`GET /avatar/get` 轮询状态;服务重启未完成任务标记失败
### 会员支付
- 套餐下单 → 虎皮棋聚合支付(alipay/wechat)→ `pay_url` 拉起支付 → 前端轮询订单状态 → 回调 `/member/order/notify` 幂等更新会员
- `xunhu_appid/appsecret` 未配置时支付功能降级关闭
### CPS 联盟
- 美团/京东/淘宝三联盟商品定时同步(`cps.sync_cron`,默认每日 4 点),分类归一到 `slogan_cps_category`
- 业务场景映射(发型卡/买同款/到店试穿/延伸优惠/找升级款/会员权益)→ 场景推荐接口
- key 全空时联盟入口优雅降级隐藏
## 目录结构
```
server/
main.go 入口:路由注册 + workspace 静态服务 + 任务恢复 + CPS 定时同步
common/ 通用层:HTTP/RouteRegister/JWT 鉴权/DAO 基类/缓存/锁/协程池/DB 组访问器
styleagent/
controller/ Controller 层(反射路由,struct 名 → kebab-case URL
service/ 业务层(生成编排/化身/衣橱/效果图/支付/会员/CPS/广告)
dao/ 每表一 DAOinit 自动建表 + 索引 + seed
model/entity/ 实体(与表一一对应)
model/dto/ 请求/响应结构(g.Meta 定义路由)
agent/ LLM 调用(OpenAI 兼容)+ 方案规划/兜底 + 万相出图
scoring/ 规则评分引擎(零 LLM 成本)
weather/ 和风天气 + 高德地理编码
avatar/ Tripo 3D 客户端 + GLB 帧渲染
cps/ 三联盟客户端(美团/京东/淘宝)
consts/ 常量(表名/状态/数据库组)
scripts/ avatar-renderGLB 帧渲染,Dockerfile 依赖)
app-uni/ uni-app (Vue3) 多端工程
```
## 部署
```bash
docker build -t slogan-agent server/
docker run -d -p 8080:8080 \
-v /data/slogan/data:/app/data \ # 数据库目录(小而关键)
-v /data/slogan/workspace:/app/workspace \ # 上传/生成文件(大而可重建)
slogan-agent
```
- **备份 = 打包 `data/`(数据库)+ `workspace/`(文件)两个目录**;迁移 = 拷贝到新机器
- 生产部署前在 config.yml 填写 llm/weather/geo/imagegen/payment/avatar/cps 的真实 Key
## 开发规范
分层职责(controller → service → dao)、并发/事务/缓存/金额单位等硬性约束见根目录 **CLAUDE.md**;实现细节与技术决策见 **技术设计.md**
-9
View File
@@ -1,9 +0,0 @@
# Flutter/Dart
.dart_tool/
build/
.flutter-plugins
.flutter-plugins-dependencies
*.iml
.idea/
# 系统
.DS_Store
-45
View File
@@ -1,45 +0,0 @@
# 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: "058e0af2c2b57e369d905a03ac9748b0ebf543c6"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: android
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: ios
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: linux
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: macos
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: web
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
- platform: windows
create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6
# 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'
-17
View File
@@ -1,17 +0,0 @@
# slogan_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
-28
View File
@@ -1,28 +0,0 @@
# 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
-14
View File
@@ -1,14 +0,0 @@
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
-45
View File
@@ -1,45 +0,0 @@
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.slogan.slogan_app"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.slogan.slogan_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -1,45 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="slogan_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -1,5 +0,0 @@
package com.slogan.slogan_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
-24
View File
@@ -1,24 +0,0 @@
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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
-6
View File
@@ -1,6 +0,0 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
-5
View File
@@ -1,5 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
-26
View File
@@ -1,26 +0,0 @@
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 {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.0.1" apply false
id("org.jetbrains.kotlin.android") version "2.3.20" apply false
}
include(":app")
@@ -1,969 +0,0 @@
# 商业化 P0 实现计划(客户端)· slogan-app
> **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:** 会员中心(套餐展示 → 下单 → 系统浏览器支付 → 轮询确认)+ 广告激励入口(Mock 激励视频 → 领取加次/体验会员),后端未配置时接口报错自动隐藏充值入口。
**Architecture:** /commercial 页重构为会员中心(home 第 4 Tab 与路由都指向它)。新增 `lib/core/ads/`AdsService 抽象 + Mock 实现,P1 换穿山甲)、`lib/features/member/`provider + 会员中心页 + 支付页)。支付用 `url_launcher` 打开系统浏览器,`/pay` 页 2s 轮询订单状态(60s 上限),成功后刷新会员状态。iOS 端隐藏充值入口(App Store 政策),保留广告激励。
**Tech Stack:** Flutter 3.44 / Riverpod 3 / go_router 17 / dio 5 / url_launcher ^6.3.0
**关联 spec:** `docs/superpowers/specs/2026-07-31-commerce-monetization-design.md` 支柱 A/B 客户端部分。
**验证方式(沿用 MVP 惯例):** `dart analyze`(中文路径下 flutter analyze 崩溃)、`flutter test``flutter build web --release` 全量编译、mock 后端冒烟。
---
## 任务总览与文件映射
| 任务 | 文件 |
|---|---|
| T1 | `pubspec.yaml``lib/core/ads/ads_service.dart``test/features/member/benefits_test.dart` |
| T2 | `lib/features/member/member_provider.dart` |
| T3 | `lib/features/member/member_center_page.dart`(新建)、`lib/features/commercial/commercial_page.dart`(删除)、`lib/features/home/home_page.dart``lib/main.dart` |
| T4 | `lib/features/member/pay_page.dart``lib/main.dart` 路由 |
| T5 | analyze + test + build + 冒烟 |
---
### Task 1: url_launcher 依赖 + 广告抽象 + 权益文案纯函数(TDD)
**Files:**
- Modify: `pubspec.yaml`
- Create: `lib/core/ads/ads_service.dart`
- Test: `test/features/member/benefits_test.dart`
- [ ] **Step 1: 写失败测试**(权益 key → 中文文案;benefitTexts 尚未存在)
`test/features/member/benefits_test.dart`:
```dart
import 'package:flutter_test/flutter_test.dart';
import 'package:slogan_app/features/member/member_provider.dart';
void main() {
test('benefitTexts 映射已知权益文案,未知 key 原样保留', () {
final info = MemberInfo(
isVip: true,
expireAt: '2026-08-30 12:00:00',
planName: '月卡',
benefits: const ['effect_unlimited', 'unknown_key'],
);
expect(benefitTexts(info), ['无限效果图', 'unknown_key']);
});
test('benefitTexts 空权益返回空列表', () {
final info = MemberInfo(
isVip: false,
expireAt: '',
planName: '',
benefits: const [],
);
expect(benefitTexts(info), isEmpty);
});
}
```
- [ ] **Step 2: 运行确认失败**
Run: `dart analyze lib test 2>&1 | head -5` Expected: 报 `member_provider.dart` 不存在 / import 失败
- [ ] **Step 3: pubspec 加 url_launcher**
```yaml
url_launcher: ^6.3.0
```
- [ ] **Step 4: 广告抽象 `lib/core/ads/ads_service.dart`**
```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK
abstract class AdsService {
bool get enabled;
Future<bool> showRewarded();
}
/// 本地模拟激励视频(约 1 秒"播放"后返回完整观看)
class MockAdsService implements AdsService {
@override
bool get enabled => true;
@override
Future<bool> showRewarded() async {
await Future.delayed(const Duration(milliseconds: 900));
return true;
}
}
final adsServiceProvider = Provider<AdsService>((ref) {
// P1AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现)
return MockAdsService();
});
```
- [ ] **Step 5: 提交**
```bash
git add pubspec.yaml lib/core/ads test/features/member
git commit -m "feat: 广告服务抽象(Mock 激励视频)+ url_launcher 依赖"
```
---
### Task 2: member provider(会员状态/套餐/下单/轮询/领奖)
**Files:**
- Create: `lib/features/member/member_provider.dart`
- [ ] **Step 1: 实现 member_provider.dart**(含 Task 1 测试依赖的 `MemberInfo``benefitTexts`
```dart
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/network/api_client.dart';
class MemberInfo {
final bool isVip;
final String expireAt;
final String planName;
final List<String> benefits;
const MemberInfo({
required this.isVip,
required this.expireAt,
required this.planName,
required this.benefits,
});
factory MemberInfo.fromJson(Map<String, dynamic> e) => MemberInfo(
isVip: e['is_vip'] as bool? ?? false,
expireAt: e['expire_at'] as String? ?? '',
planName: e['plan_name'] as String? ?? '',
benefits: (e['benefits'] as List<dynamic>? ?? []).cast<String>(),
);
}
/// 权益 key → 文案
const benefitLabels = {
'effect_unlimited': '无限效果图',
'ai_priority': '优先 AI 方案',
'cps_commission_x15': '返现加成 1.5x',
'store_discount': '门店折扣',
};
List<String> benefitTexts(MemberInfo m) =>
m.benefits.map((b) => benefitLabels[b] ?? b).toList();
class MemberNotifier extends AsyncNotifier<MemberInfo> {
@override
Future<MemberInfo> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/member/status');
return MemberInfo.fromJson(data ?? {});
}
Future<void> refresh() async {
state = await AsyncValue.guard(build);
}
/// 领取广告激励(服务端限频);adType: effect_extra | vip_trial
/// 返回当日剩余次数;超出限频抛 ApiException
Future<int> claimReward(String adType) async {
final api = ref.read(apiClientProvider);
final data =
await api.post<Map<String, dynamic>>('/ad/reward/claim', {'ad_type': adType});
await refresh(); // vip_trial 可能开通体验会员
return (data?['reward']?['remaining_today'] as num?)?.toInt() ?? 0;
}
}
final memberProvider =
AsyncNotifierProvider<MemberNotifier, MemberInfo>(MemberNotifier.new);
class MemberPlan {
final int id;
final String name;
final int priceFen;
final int durationDays;
final List<String> features;
const MemberPlan({
required this.id,
required this.name,
required this.priceFen,
required this.durationDays,
required this.features,
});
factory MemberPlan.fromJson(Map<String, dynamic> e) => MemberPlan(
id: (e['id'] as num).toInt(),
name: e['name'] as String? ?? '',
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
durationDays: (e['duration_days'] as num?)?.toInt() ?? 30,
features: _parseFeatures(e['features'] as String? ?? ''),
);
static List<String> _parseFeatures(String s) {
try {
return (jsonDecode(s) as List<dynamic>).cast<String>();
} catch (_) {
return const [];
}
}
String get priceText =>
'¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}';
}
final memberPlanProvider = FutureProvider<List<MemberPlan>>((ref) async {
final api = ref.read(apiClientProvider);
final list = await api.get<List<dynamic>>('/member/plan/list');
return (list ?? [])
.map((e) => MemberPlan.fromJson(e as Map<String, dynamic>))
.toList();
});
class OrderResult {
final String orderNo;
final String payUrl;
const OrderResult({required this.orderNo, required this.payUrl});
}
/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL)
Future<OrderResult> createMemberOrder(WidgetRef ref, int planId) async {
final api = ref.read(apiClientProvider);
final data =
await api.post<Map<String, dynamic>>('/member/order/create', {'plan_id': planId});
return OrderResult(
orderNo: data?['order_no'] as String? ?? '',
payUrl: data?['pay_url'] as String? ?? '',
);
}
/// 订单状态(支付页 2s 轮询):pending | paid | closed
Future<String> fetchOrderStatus(WidgetRef ref, String orderNo) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/member/order/status',
query: {'order_no': orderNo});
return data?['status'] as String? ?? '';
}
```
- [ ] **Step 2: 运行测试 + 静态检查**
Run: `dart analyze lib/features/member lib/core/ads 2>&1 | tail -3` Expected: 无 issue
Run: `flutter test test/features/member/benefits_test.dart` Expected: 2 个用例 PASS
- [ ] **Step 3: 提交**
```bash
git add lib/features/member
git commit -m "feat: 会员 provider(状态/套餐/下单/轮询/领奖)"
```
---
### Task 3: 会员中心页(commercial 重构)+ 入口切换
**Files:**
- Create: `lib/features/member/member_center_page.dart`
- Delete: `lib/features/commercial/commercial_page.dart`
- Modify: `lib/features/home/home_page.dart`
- Modify: `lib/main.dart`
- [ ] **Step 1: 创建 member_center_page.dart**(完整代码,含会员卡/套餐弹层/广告激励卡/合作门店)
```dart
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:go_router/go_router.dart';
import '../../core/ads/ads_service.dart';
import '../../core/config/app_config.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'member_provider.dart';
import 'pay_page.dart';
class PartnerStoreInfo {
final int id;
final String name;
final int type; // 1 造型/发型店,2 服装店
final String address;
final String commissionPolicy;
const PartnerStoreInfo({
required this.id,
required this.name,
required this.type,
required this.address,
required this.commissionPolicy,
});
}
class StoreNotifier extends AsyncNotifier<List<PartnerStoreInfo>> {
@override
Future<List<PartnerStoreInfo>> build() async {
final api = ref.read(apiClientProvider);
final list = await api.get<List<dynamic>>('/partner-store/list');
return list
.map((e) => PartnerStoreInfo(
id: (e['id'] as num).toInt(),
name: e['name'] as String? ?? '',
type: (e['type'] as num?)?.toInt() ?? 1,
address: e['address'] as String? ?? '',
commissionPolicy: e['commission_policy'] as String? ?? '',
))
.toList();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
}
final storeProvider =
AsyncNotifierProvider<StoreNotifier, List<PartnerStoreInfo>>(StoreNotifier.new);
const _storeTypeLabels = {1: '造型', 2: '服装'};
/// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1)
class MemberCenterPage extends ConsumerStatefulWidget {
const MemberCenterPage({super.key});
@override
ConsumerState<MemberCenterPage> createState() => _MemberCenterPageState();
}
class _MemberCenterPageState extends ConsumerState<MemberCenterPage> {
int? _typeFilter;
bool _rewarding = false;
bool get _isIOS => Platform.isIOS;
Future<void> _openPlans() async {
final plans = await showModalBottomSheet<List<MemberPlan>>(
context: context,
builder: (ctx) => const _PlanSheet(),
);
if (plans == null || !mounted) return;
try {
final result = await createMemberOrder(ref, plans.id);
if (!mounted || result.payUrl.isEmpty) return;
await context.push('/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl));
ref.read(memberProvider.notifier).refresh();
} catch (e) {
if (!mounted) return;
Fluttertoast.showToast(
msg: e.toString().replaceFirst('Exception: ', ''));
}
}
Future<void> _claimReward(String adType, String successMsg) async {
if (_rewarding) return;
final ads = ref.read(adsServiceProvider);
if (!ads.enabled) {
Fluttertoast.showToast(msg: '广告功能暂未开通');
return;
}
setState(() => _rewarding = true);
try {
final watched = await ads.showRewarded();
if (!watched) {
Fluttertoast.showToast(msg: '未完整观看,无法领取');
return;
}
final remaining =
await ref.read(memberProvider.notifier).claimReward(adType);
if (!mounted) return;
Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)');
} catch (e) {
if (!mounted) return;
Fluttertoast.showToast(
msg: e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _rewarding = false);
}
}
@override
Widget build(BuildContext context) {
final member = ref.watch(memberProvider);
final stores = ref.watch(storeProvider);
final scheme = Theme.of(context).colorScheme;
return ListView(
padding: const EdgeInsets.all(16),
children: [
_MemberCard(
member: member,
isIOS: _isIOS,
onOpenPlans: _openPlans,
),
if (member.valueOrNull?.isVip == false) ...[
const SizedBox(height: 12),
_AdsRewardCard(
rewarding: _rewarding,
onClaim: (adType, msg) => _claimReward(adType, msg),
),
],
const SizedBox(height: 16),
Row(
children: [
Text('合作门店',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
const Spacer(),
ChoiceChip(
label: const Text('全部'),
selected: _typeFilter == null,
onSelected: (_) => setState(() => _typeFilter = null),
),
const SizedBox(width: 8),
for (final entry in _storeTypeLabels.entries) ...[
ChoiceChip(
label: Text(entry.value),
selected: _typeFilter == entry.key,
onSelected: (_) => setState(() => _typeFilter = entry.key),
),
const SizedBox(width: 8),
],
],
),
const SizedBox(height: 8),
stores.when(
loading: () => const Padding(
padding: EdgeInsets.only(top: 32), child: LoadingView(text: '加载门店...')),
error: (e, _) => Padding(
padding: const EdgeInsets.only(top: 32),
child: ErrorView(
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: () => ref.read(storeProvider.notifier).refresh(),
),
),
data: (list) {
final shown = _typeFilter == null
? list
: list.where((s) => s.type == _typeFilter).toList();
if (shown.isEmpty) {
return const Padding(
padding: EdgeInsets.only(top: 32),
child: Center(
child: Text('附近暂无可合作门店',
style: TextStyle(color: Colors.grey))),
);
}
return Column(
children: [
for (final s in shown) ...[
Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: scheme.primaryContainer,
child: Icon(
s.type == 1 ? Icons.content_cut : Icons.checkroom),
),
title: Text(s.name),
subtitle: Text('${s.address}\n${s.commissionPolicy}'),
isThreeLine: true,
trailing:
const Icon(Icons.chevron_right, color: Colors.grey),
),
),
const SizedBox(height: 8),
],
],
);
},
),
],
);
}
}
class _MemberCard extends ConsumerWidget {
final AsyncValue<MemberInfo> member;
final bool isIOS;
final VoidCallback onOpenPlans;
const _MemberCard(
{required this.member, required this.isIOS, required this.onOpenPlans});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: scheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: member.when(
loading: () => const Text('加载会员状态...',
style: TextStyle(fontSize: 13)),
error: (e, _) => Text('会员状态加载失败:$e',
style: const TextStyle(fontSize: 12)),
data: (m) {
if (m.isVip) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.workspace_premium, size: 30, color: scheme.primary),
const SizedBox(width: 10),
const Text('形象会员',
style:
TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
const Spacer(),
Chip(
label: Text(m.planName),
labelStyle:
TextStyle(color: scheme.primary, fontSize: 12),
visualDensity: VisualDensity.compact,
),
]),
const SizedBox(height: 6),
Text('有效期至 ${m.expireAt}',
style: TextStyle(fontSize: 12, color: scheme.primary)),
if (benefitTexts(m).isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final b in benefitTexts(m))
Chip(
label: Text(b),
labelStyle: const TextStyle(fontSize: 11),
visualDensity: VisualDensity.compact,
),
],
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(children: [
Icon(Icons.workspace_premium, size: 30),
SizedBox(width: 10),
Text('形象会员',
style:
TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
]),
const SizedBox(height: 6),
const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣',
style: TextStyle(fontSize: 12)),
const SizedBox(height: 10),
if (isIOS)
const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员',
style: TextStyle(fontSize: 11, color: Colors.grey))
else
FilledButton.icon(
onPressed: onOpenPlans,
icon: const Icon(Icons.payment, size: 18),
label: const Text('开通会员'),
),
],
);
},
),
),
);
}
}
class _AdsRewardCard extends ConsumerWidget {
final bool rewarding;
final void Function(String adType, String msg) onClaim;
const _AdsRewardCard({required this.rewarding, required this.onClaim});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('免费获取权益',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple),
title: const Text('看视频 · 效果图 +1'),
subtitle: const Text('每日最多 2 次,次日重置'),
trailing: OutlinedButton(
onPressed: rewarding
? null
: () => onClaim('effect_extra', '已获得 1 次效果图'),
child: const Text('看视频'),
),
),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.ondemand_video, color: Colors.teal),
title: const Text('看视频 · 体验会员 1 天'),
subtitle: const Text('每日最多 1 次,含无限效果图'),
trailing: OutlinedButton(
onPressed: rewarding
? null
: () => onClaim('vip_trial', '已获得 1 天体验会员'),
child: const Text('看视频'),
),
),
],
),
),
);
}
}
class _PlanSheet extends ConsumerWidget {
const _PlanSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final plans = ref.watch(memberPlanProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('选择会员套餐',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
plans.when(
loading: () => const Padding(
padding: EdgeInsets.all(24), child: LoadingView()),
error: (e, _) => Text('套餐加载失败:$e',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey)),
data: (list) => list.isEmpty
? const Padding(
padding: EdgeInsets.all(24),
child: Text('暂未开放套餐', textAlign: TextAlign.center),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final p in list)
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
tileColor: Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.5),
title: Text(p.name,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
subtitle: Text(
'${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}',
style: const TextStyle(fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Text(p.priceText,
style: TextStyle(
color:
Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold)),
onTap: () => Navigator.pop(context, p),
),
],
),
),
],
),
),
);
}
}
```
> 注意:`_openPlans` 里 `plans` 变量名与 `memberPlanProvider` 无关;`showModalBottomSheet` 返回选中的套餐。依赖 `AppConfig` 在 import 中(本页未用到可删,`apiClientProvider` 来自 `api_client.dart`StoreNotifier 里用到——需 import `../../core/network/api_client.dart`)。
- [ ] **Step 2: 删除旧页并切换入口**
```bash
rm lib/features/commercial/commercial_page.dart
```
`home_page.dart` 修改:import 换 `../member/member_center_page.dart`,第 4 Tab 用 `MemberCenterPage()`,标题与 label 改为「会员中心」:
```dart
import '../member/member_center_page.dart';
// ...
static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '会员中心'];
// ...
MemberCenterPage(),
// ...
NavigationDestination(
icon: Icon(Icons.store_outlined),
selectedIcon: Icon(Icons.store),
label: '会员'),
```
- [ ] **Step 3: main.dart /commercial 路由指向新页**
`lib/main.dart``import '../features/member/member_center_page.dart';`,第 78-82 行 `/commercial` 的 builder 改为 `MemberCenterPage()`
- [ ] **Step 4: 静态检查**
Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error(可能提示 unused import `app_config.dart`,删掉即可)
- [ ] **Step 5: 提交**
```bash
git add lib/features/member/member_center_page.dart lib/features/home/home_page.dart lib/main.dart
git add -u lib/features/commercial
git commit -m "feat: 会员中心页(会员卡/套餐弹层/广告激励/合作门店)"
```
---
### Task 4: 支付页(轮询确认结果)
**Files:**
- Create: `lib/features/member/pay_page.dart`
- Modify: `lib/main.dart`
- [ ] **Step 1: 创建 pay_page.dart**
```dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import 'member_provider.dart';
class PayArgs {
final String orderNo;
final String payUrl;
const PayArgs({required this.orderNo, required this.payUrl});
}
enum PayPhase { launching, paying, paid, timeout, failed }
/// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s)
class PayPage extends ConsumerStatefulWidget {
final PayArgs args;
const PayPage({super.key, required this.args});
@override
ConsumerState<PayPage> createState() => _PayPageState();
}
class _PayPageState extends ConsumerState<PayPage> {
PayPhase _phase = PayPhase.launching;
Timer? _timer;
int _elapsed = 0;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _start() async {
try {
final ok = await launchUrl(Uri.parse(widget.args.payUrl),
mode: LaunchMode.externalApplication);
if (!ok) {
setState(() => _phase = PayPhase.failed);
return;
}
setState(() => _phase = PayPhase.paying);
} catch (e) {
setState(() => _phase = PayPhase.failed);
return;
}
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
}
Future<void> _check() async {
_elapsed += 2;
try {
final status = await fetchOrderStatus(ref, widget.args.orderNo);
if (status == 'paid') {
_timer?.cancel();
if (!mounted) return;
setState(() => _phase = PayPhase.paid);
Fluttertoast.showToast(msg: '会员开通成功');
return;
}
if (_elapsed >= 60) {
_timer?.cancel();
if (!mounted) return;
setState(() => _phase = PayPhase.timeout);
}
} catch (_) {
// 轮询失败不中断,下次再试
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('会员支付')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
switch (_phase) {
PayPhase.launching ||
PayPhase.paying => Column(children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('请在浏览器中完成支付,正在确认结果…'),
const SizedBox(height: 8),
Text('订单号 ${widget.args.orderNo}',
style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => _check(),
child: const Text('我已完成支付'),
),
]),
PayPhase.paid => Column(children: [
Icon(Icons.check_circle, size: 64, color: scheme.primary),
const SizedBox(height: 12),
const Text('支付成功,会员已开通!',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.pop(),
child: const Text('返回会员中心'),
),
]),
PayPhase.timeout => Column(children: [
Icon(Icons.hourglass_empty, size: 64, color: Colors.orange),
const SizedBox(height: 12),
const Text('支付结果确认中'),
const SizedBox(height: 8),
const Text('可稍后到会员中心查看开通状态,以支付结果为准',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context.pop(),
child: const Text('返回'),
),
]),
PayPhase.failed => Column(children: [
Icon(Icons.error_outline, size: 64, color: scheme.error),
const SizedBox(height: 12),
const Text('无法打开支付页面'),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context.pop(),
child: const Text('返回'),
),
]),
},
],
),
),
),
);
}
}
```
- [ ] **Step 2: main.dart 注册路由**
```dart
GoRoute(
path: '/pay',
builder: (context, state) =>
PayPage(args: state.extra! as PayArgs),
),
```
import `../features/member/pay_page.dart`
- [ ] **Step 3: 静态检查**
Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error
- [ ] **Step 4: 提交**
```bash
git add lib/features/member/pay_page.dart lib/main.dart
git commit -m "feat: 支付页(系统浏览器收银台 + 2s 轮询确认)"
```
---
### Task 5: 全量验证与冒烟
- [ ] **Step 1: 单元测试**
Run: `flutter test` Expected: 全部 PASS(含新增 benefits 2 个用例)
- [ ] **Step 2: 全量编译(web 兜底)**
Run: `cd /Users/zhangbin/Desktop/d盘/work/slogan/slogan-app && flutter build web --release` Expected: 构建成功(若提示 stale cache,先 `flutter clean && flutter pub get`
- [ ] **Step 3: 冒烟(起后端,mock 支付/广告降级路径)**
后端按后端计划 T9 起服务(不配 mock 时):
```bash
# 会员状态:未登录返回 401;已登录非会员返回 is_vip=false
# 套餐列表:返回 2 个套餐
# 下单:报"支付未开通" → App 开通按钮 toast 提示,不崩溃
# 广告:claim 前 2 次成功(remaining 1/0),第 3 次报"今日次数已用完" → toast 展示
```
- [ ] **Step 4: 提交**
```bash
git add -A
git status # 确认无残留
git log --oneline -5
```
---
## 自检清单
- [ ] /commercialhome 第 4 Tab 与路由)指向会员中心,旧 commercial_page 已删除
- [ ] iOS 隐藏充值入口(Platform.isIOS),广告激励保留
- [ ] 支付页轮询 2s/60s 上限,paid/timeout/failed 三态完整;返回后刷新会员状态
- [ ] 广告入口走 adsServiceProvider 抽象,Mock 可用,穿山甲 P1 替换点已注明
- [ ] 所有后端接口错误 toast 展示 message,不崩溃;未开通时入口不渲染/隐藏
- [ ] `dart analyze` 无 error、`flutter test` 全过、web 编译成功
## 后续计划(P1,不在本计划内)
方案页三处 CPS 入口(做同款发型/买同款/到店试穿)、/cps-product-list 商品列表、衣橱「找升级款」、最近优惠、穿山甲 SDK 替换 Mock、webview 内嵌收银台。
@@ -1,372 +0,0 @@
# 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 明确检查
@@ -1,204 +0,0 @@
# 商业化四支柱设计(客户端)· slogan-app
> **目标:** 以「个人形象设计」为主流程,把四支柱收入入口从「方案/单品」里长出来:VIP 会员充值、穿山甲广告激励、线下门店引流(美团联盟)、线上商品(京东/淘宝 CPS)。不做泛化场景广场。
> **核心原则:** 客户端零硬编码业务配置;所有商业化入口以「接口可用」为开关 —— 后端未配置 key 时接口报错/返回空 → App 自动隐藏对应入口,主流程(生成方案 → 查看)不受影响。
## 1. 信息架构改造
```
改造前:/commercial 孤立 tab(会员占位卡 + 门店列表)
改造后:
├─ /commercial 会员中心(P0) 会员状态卡 · 套餐 · 广告激励 · 合作门店 · 最近优惠
├─ /plan-viewer 方案页(P0) 发型卡「做同款发型」· 穿衣清单「买同款/到店试穿」· 场合「延伸优惠」
├─ /wardrobe 衣橱(P1 长按「找升级款」
└─ 全局(P2) 开屏广告 / 信息流广告位(效果图页底部)
```
**开关原则**:每个商业化入口包一层 `commercialGate`(统一查询后端配置/捕获接口错误),未开通 → 按钮不渲染。启动时不做额外网络调用,**入口可见性由首次打开该页面的接口结果决定**(零新增请求)。
## 2. 支柱 A:会员中心(/commercial 重构)
### 2.1 页面结构
```
/ commercialConsumerStatefulWidget,保留现有门店列表与类型筛选)
├─ 会员状态卡:头像/会员名 · is_vip · expire_at(倒计时)· 权益 chips(无限效果图/优先AI/返现1.5x/门店折扣)
│ ├─ 未开通 →「立即开通」按钮 → 打开套餐 bottom sheet
│ └─ 已开通 →「会员码」按钮(store_discount 到店出示,P1
├─ 广告激励卡(非会员时显示):
│ ├─ 看视频 +1 效果图(每日 2 次,剩余次数显示)
│ └─ 看视频 1 天体验会员(每日 1 次)
├─ 合作门店列表(现有 /partner-store/list 品牌合作门店保留,含「会员价」角标 P1;
│ 美团联盟到店券走 /cps/product/list,两条链路互不替换)
└─ 最近优惠(/cps/my/recentP1,未开通则隐藏整卡)
```
### 2.2 支付时序(App 侧)
```
点击套餐 → POST /member/order/create {plan_id} → 返回 {order_no, pay_url}
→ 打开 PayWebViewPage(内嵌 webview_flutteriOS 用 WKWebView
→ WebView 加载 pay_url,监听 url 变化(payment 完成跳转)
→ 同时 Timer 每 2s GET /member/order/status?order_no=... 轮询(上限 60s
→ status=paid → 关闭 WebView → 刷新 memberProvider → 成功 toast
→ 超时 → 提示「支付结果确认中,请稍后在会员中心查看」(状态以服务端为准)
```
- 支付页依赖 `webview_flutter`(P0 引入,国内 App 常规做法);若平台编译受限,降级方案:`url_launcher` 唤起系统浏览器支付,返回 App 后仍走轮询(**P0 默认 url_launcher 方案,webview_flutter 留 P1**,减小依赖风险)
### 2.3 状态与 Provider
| Provider | 类型 | 数据 | 接口 |
|---|---|---|---|
| `memberProvider` | AsyncNotifier | isVip, expireAt, planName, benefits[] | GET /member/status |
| `memberPlanProvider` | FutureProvider | 套餐列表 | GET /member/plan/list |
| `orderCreateProvider` | Notifier.family(planId) | orderNo, payUrl | POST /member/order/create |
| `orderStatusProvider` | FutureProvider.family(orderNo) | status | GET /member/order/status |
- `memberProvider` 缓存登录态期间;`refresh()` 在支付成功、广告领奖后调用
- 权益 chips 文案从套餐 `features` JSON 解析 → 本地文案 map(`effect_unlimited→无限效果图` 等)
## 3. 支柱 B:广告激励(lib/core/ads/ 新建)
### 3.1 抽象(供应商隔离)
```dart
// lib/core/ads/ads_provider.dart
abstract class AdsService {
bool get enabled; // appid 未配置 → falseApp 隐藏广告入口
Future<bool> showRewarded(); // 激励视频,返回是否完整观看
}
// lib/core/ads/pangle_ads_service.dart —— 穿山甲实现(P1 接入 SDK,P0 仅接口 + mock
// P0MockAdsService —— 本地模拟 3 秒「播放」返回 true,保证主链路可开发可测
```
- **初始化**`AdsConfig`AppConfig 常量:pangleAppId 默认空)→ `adsServiceProvider` 单例
- **降级**appid 空 / SDK 初始化失败 → `enabled=false` → 会员中心激励卡、广告位全部不渲染
### 3.2 激励流程(服务端防刷,客户端只展示)
```
点击「看视频」→ adsService.showRewarded()
→ 完整观看 → POST /ad/reward/claim {ad_type: effect_extra | vip_trial}
→ 成功 → 展示奖励弹窗(+1 效果图 / 1 天体验会员)→ refresh 会员卡
→ 失败(限频/未配置)→ 隐藏式错误(后端返回「今日次数已用完」→ 入口变灰)
```
- 剩余次数展示:`/ad/reward/claim` 响应带回 `{reward: {ad_type, remaining_today}}`,App 本地缓存当日显示;或 P0 简单化 —— 仅在领取失败时提示次数用完
- **效果图配额联动**:后端限额 = 基础 3 + 当日额外次数;App 端文案统一显示「今日剩余 X 次」(P1 后端在生成接口响应中带 `remaining` 字段,P0 保持现状提示)
### 3.3 广告位(P2,本期只留占位)
- 开屏广告:`/home` 进入时加载(P2
- 信息流广告:`/plan-effect` 效果图 GridView 底部插一条(P2
## 4. 支柱 C/D:方案驱动 CPS 入口
### 4.1 方案页(/plan-viewer)三处入口
| 位置 | 按钮 | scene | 请求 | 跳转 |
|---|---|---|---|---|
| 发型卡尾部 | 「做同款发型」 | haircut | GET /cps/plan/recommend {plan_id, scene: haircut} | /cps-product-list(美团丽人/理发券) |
| 穿衣清单每项 trailing | 「买同款」 | item_buy | 京东搜索单品名 | /cps-product-list(电商商品) |
| 穿衣清单每项 trailing | 「到店试穿」 | item_upgrade | 美团服装类目 | /cps-product-list(门店券) |
| 场合卡(P1detail 有 occasion 字段后) | 「延伸优惠」 | occasion | 映射表推荐 | /cps-product-list |
- **可见性**:推荐接口返回空列表 / 接口报错(CPS 未开通)→ 该按钮隐藏;发型卡无发型名(默认发型)→ 隐藏「做同款」
- **交互**:推荐返回列表 → push `/cps-product-list?source=&category_code=&city=`(带标题「做同款发型 · 丽人」);列表点击 → POST /cps/product/link {product_id, scene, plan_id} → 打开 deeplink
- **打开方式**`url_launcher`(系统浏览器,携 pid 转链 URL;电商商品优先深链 App,P1 增强)
### 4.2 衣橱升级款(/wardrobe 长按菜单加一项)
```
长按衣物卡 → 菜单:删除 / 找升级款
「找升级款」→ GET /cps/wardrobe/upgrade {item_id} → /cps-product-list(标题「升级款 · 上衣」)
```
### 4.3 商品列表页 /cps-product-list
```
AppBar 标题(由入口传入)+ 分类 chips/cps/category/listP1
商品卡:封面图(AppConfig.resolveUrl)· 名称 · 价格(分→元)· 店铺 · 佣金标
上滑加载更多(page 分页,has_more 判定)
点击 → POST /cps/product/link → deeplink → url_launcher 打开
```
### 4.4 Provider
| Provider | 类型 | 接口 |
|---|---|---|
| `cpsRecommendProvider` | FutureProvider.family((planId, scene)) | GET /cps/plan/recommend |
| `cpsProductProvider` | AsyncNotifierProvider.family((source, categoryCode, city)) | GET /cps/product/list 分页 |
| `cpsUpgradeProvider` | FutureProvider.family(itemId) | GET /cps/wardrobe/upgrade |
| `cpsLinkAction` | Notifier | POST /cps/product/link |
## 5. 会员权益在客户端的呈现
| 权益 | 客户端表现 |
|---|---|
| effect_unlimited | 效果图页配额提示隐藏(「每日限 3 次」文案在 isVip 时不显示) |
| ai_priority | 生成页状态文案「VIP 优先排队」(MVP 仅文案) |
| cps_commission_x15 | 商品卡佣金标签「返现加成 1.5x」(VIP 用户) |
| store_discount | 门店列表「会员价」角标 + 会员码页(P1) |
## 6. 配置(AppConfig 常量,均为本地编译期配置)
```dart
// lib/core/config/app_config.dart 追加
static const String pangleAppId = ''; // 穿山甲 AppId,空 = 广告功能关闭
static const bool cpsEnabled = true; // 兜底开关;最终以接口结果为准
```
- 商业化开关**最终以接口为准**(后端 config 未配置 → 接口错误 → App 隐藏入口),本地常量只控制「广告 SDK 是否初始化」
## 7. API 映射与错误处理
| 后端接口 | 客户端方法 | 错误处理 |
|---|---|---|
| GET /member/status | memberProvider.build | 401 → 重登;其他 → 默认非会员 |
| GET /member/plan/list | memberPlanProvider | 错误 → 套餐区显示「暂未开通」 |
| POST /member/order/create | 下单动作 | 错误 → toast「支付未开通」 |
| GET /member/order/status | 轮询 | 404/错误 → 结束轮询提示稍后查看 |
| POST /member/order/notify | -(后端回调,客户端不参与) | - |
| POST /ad/reward/claim | 领奖动作 | 错误 → toast 后端 message(限频等) |
| GET /cps/plan/recommend | cpsRecommendProvider | 空/错误 → 入口隐藏 |
| GET /cps/product/list | cpsProductProvider | 空/错误 → 列表空态「暂未开放」 |
| POST /cps/product/link | cpsLinkAction | 错误 → toast「跳转失败」 |
| GET /cps/my/recent | recentProvider | 错误 → 整卡隐藏 |
- 所有新接口走现有 `apiClientProvider`(Dio 封装,自动带 token),无需改动网络层
## 8. 路由与依赖变更
```
/lib/main.dart 新增 route
/cps-product-listextra: CpsListArgs{title, source, categoryCode, city}
/pay-webviewextra: PayWebviewArgs{orderNo, payUrl}P1 webview_flutter
依赖(P0):url_launcher(打开 deeplink / 系统浏览器支付)
依赖(P1):webview_flutter(内嵌收银台)、穿山甲 SDK(pangle 插件)
```
- 穿山甲 SDK Flutter 插件社区维护不稳定 → **P1 先验证 iOS/Android 编译,若插件不可用则改为原生 module 接入(P2)**P0 用 MockAdsService 保证业务链路先闭环
## 9. 分期与对齐
| 分期 | 客户端内容 | 依赖 |
|---|---|---|
| **P0** | /commercial 重构会员中心(状态/套餐/下单/轮询)+ MockAds + 广告激励入口 + 方案页「做同款发型/买同款/到店试穿」+ /cps-product-list + url_launcher + cps 入口隐藏逻辑 | 后端 P0(会员+广告接口) |
| **P1** | 场合卡「延伸优惠」+ 衣橱「找升级款」+ 最近优惠 + 会员码/门店折扣角标 + webview_flutter 内嵌收银台 + 穿山甲 SDK 接入(替换 Mock | 后端 P1(CPS 引擎) |
| **P2** | 开屏/信息流广告位 + 穿山甲插件不可用时的原生 module 兜底 + 收益/返现展示 | 后端 P2 |
## 10. 合规(客户端侧)
- **iOS 充值**App Store 虚拟商品政策风险 → iOS 端隐藏会员套餐充值入口(`Platform.isIOS` 判断),保留广告激励 + 门店引流;「会员价」到店核销不受影响
- **广告**:隐私政策文案补充穿山甲 SDK 信息收集披露;提供「个性化广告关闭」设置项(穿山甲 SDK 提供,P1)
- **跳转**CPS 转链一律走联盟 deeplink,不在 App 内二次改链
## 11. 开发规范约束(沿用 slogan-app 现有规范)
- Riverpod 3AsyncNotifier/Notifier/FutureProvider.familyprovider 文件放 `lib/features/<feature>/<feature>_provider.dart`
- 新页面组件放 `lib/features/commercial/`(会员中心)、`lib/features/cps/`(商品列表);广告抽象放 `lib/core/ads/`
- 图片 URL 一律 `AppConfig.resolveUrl()`;价格字段分 → 元转换写死规则(`(fen / 100).toStringAsFixed(0)`
- 所有「隐藏入口」逻辑集中在入口组件内一行判定,不扩散到业务逻辑
- 新页面必配 empty/error/loading 三态(复用 shared/widgets
@@ -1,174 +0,0 @@
# slogan-app 客户端设计方案
> 日期:2026-07-31
> 关联:slogan-agent 服务端方案(Go + GoFrame)见 slogan-agent 仓库对应文档
## 1. 项目概述
slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现,手指滑动切换方案与查看角度。
本仓库为客户端(slogan-app),Flutter 实现,一次编写双端(iOS/Android)运行,追求类原生用户操作体验。
## 2. 技术选型
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 语言/框架 | Flutter (Dart) | 一次编写双端;自绘引擎保证体验一致;动画/手势流畅 |
| 状态管理 | Riverpod | 类型安全、可测试,适合表单/异步任务/缓存类状态 |
| 网络层 | Dio + 拦截器 | JWT 自动注入、统一响应解析 `{code,message,data}`、超时重试 |
| 3D 渲染 | three_dart v1 + 渲染抽象层 | 稳定发布版(v0.3.0GLTF/GLB loader,纯 Dart 双端);flutter_scene 进 stable 后经抽象层无缝替换 |
| 图片缓存 | cached_network_image | 效果图/服装照片懒加载缓存 |
| 拍照/相册 | camera + image_picker | 大头照/全身多角度拍摄引导 |
| 本地存储 | shared_preferences | token/偏好;身形微调参数本地实时生效 |
| 手势 | 原生 GestureDetector 组合 | 方案横滑切换(PageView)+ 化身旋转拖拽/捏合缩放 + 惯性滚动 |
**3D 渲染层风险控制**`AvatarViewer` 接口抽象(loadGLB / setHairstyle / setHairColor / rotate / zoom / switchOutfit),v1 实现为 three_dartflutter_scene(官方,基于 Flutter GPU)进入 stable 后提供第二实现,业务代码零改动。
## 3. 项目结构
```
slogan-app/
├── lib/
│ ├── main.dart # 入口 + 路由 + 主题
│ ├── core/
│ │ ├── network/ # Dio 封装:JWT 拦截器/统一响应/错误码映射
│ │ ├── auth/ # 登录页 + token 管理(账号密码,复用服务端 /user/login
│ │ ├── config/ # API 地址/环境
│ │ ├── storage/ # shared_preferences 封装(token/偏好)
│ │ └── router/ # go_router(未登录 → 登录页)
│ ├── features/
│ │ ├── profile/ # Tab1 我的形象
│ │ │ ├── photo_guide/ # 拍照引导页(大头照/全身多角度拍摄指引)
│ │ │ ├── avatar_viewer/ # 3D 化身查看(AvatarViewer 抽象层实现)
│ │ │ └── body_tune/ # 滑杆微调(身高/胖瘦/肤色,本地实时)
│ │ ├── wardrobe/ # Tab2 我的衣橱
│ │ │ ├── upload/ # 服装照片上传 + 分类标签
│ │ │ └── item_grid/ # 服装资产网格/详情
│ │ ├── outfit/ # Tab3 穿搭方案
│ │ │ ├── generate/ # 生成入口(日期范围选择器/地点选择)
│ │ │ ├── task_status/ # 生成任务进度(轮询)
│ │ │ ├── plan_flow/ # 方案流(PageView 横滑切换方案)
│ │ │ ├── viewer_3d/ # 3D 方案查看(发型切换/发色取色/旋转/捏合)
│ │ │ ├── effect_images/ # 2D 效果图(正面/侧面/背面切换)
│ │ │ └── review/ # 收藏/反馈
│ │ └── commercial/ # Tab4 门店/电商
│ │ ├── stores/ # LBS 附近门店(形象设计/服装)
│ │ ├── leads/ # 导流订单/到店核销
│ │ ├── products/ # CPS 商品跳转
│ │ └── subscription/ # 会员订阅
│ └── shared/ # 组件/主题/工具(日期选择器/评分展示等)
├── assets/
│ ├── avatars/ # 模板/发型 GLB 缓存(与后端 assets 对应,v1 从服务端拉取)
│ └── images/ # 图标/占位图
└── test/ # 单元/widget 测试
```
## 4. 页面与交互设计(4 Tab 主框架)
```
┌─────────────────────────────────────────┐
│ Tab 架构(底部导航,类原生体验) │
│ ┌────────┬────────┬────────┬─────────┐ │
│ │ 我的形象│ 我的衣橱│ 穿搭方案 │ 门店/电商│ │
│ └────────┴────────┴────────┴─────────┘ │
└─────────────────────────────────────────┘
```
### Tab1 我的形象
- 首次进入:拍照引导流程(大头照 + 全身正面/侧面/背面 4 张,含姿势示例图)
- 化身构建进度(后端任务轮询)
- 3D 化身查看:单指拖拽旋转、双指捏合缩放
- 滑杆微调:身高/胖瘦/肤色,调参即时反映(本地计算,GLB 缩放参数 + 肤色材质)
### Tab2 我的衣橱
- 服装照片上传(多选)+ 分类(上衣/下装/鞋/配饰)+ 季节/风格标签自动识别(后端 AI 辅助,本地可手改)
- 网格展示 + 详情编辑/删除
### Tab3 穿搭方案(核心)
- 生成入口:日期范围选择器 + 地点选择(定位/搜索)
- 生成任务进度页(规则规划 → 方案评分 → 完成)
- 方案流:PageView 左右滑动切换 3 套方案;每套方案卡片 = 3D 化身 + 方案摘要(评分/来源标签:衣橱组合/AI 推荐)
- 3D 查看:发型点击切换 + 发色取色盘(HSV 调色实时渲染)+ 拖拽旋转 + 捏合缩放
- 主方案选定 → 触发 2D 效果图生成(3 视角:正面/侧面/背面,切换查看)
- 方案条目明细:每件服装(衣橱照片 或 电商商品图)+ 单品操作(跳转商品/加入衣橱)
- 收藏/反馈 → 回流 Agent 优化下次生成
### Tab4 门店/电商
- LBS 附近合作门店(类型筛选:形象设计/服装),导航/预约/到店核销
- CPS 商品推荐列表(跳转电商)
- 会员订阅入口与权益展示
## 5. 3D 查看器设计(核心模块)
### AvatarViewer 抽象接口
```dart
abstract class AvatarViewer extends StatelessWidget {
// 由具体实现提供(three_dart v1 / flutter_scene v2
}
abstract class AvatarViewerController {
Future<void> loadAvatar(AvatarSpec spec); // 头像 + 体型 + 皮肤贴图
Future<void> loadHairstyle(String glbUrl); // 加载发型
void setHairColor(Color color); // 发色(PBR baseColor 调色)
void setOutfit(List<OutfitLayer> layers); // 换装(简模 GLB 层)
void rotateBy(double dx, double dy); // 旋转
void zoomBy(double scale); // 缩放
void resetView();
}
```
- **v1 实现(three_dart**:加载服务端 `avatar_model.glb`(头部/身体/发型分离分层),发型切换 = 换层 + 发色材质 HSV 调整
- **性能**GLB 经服务端 glTF-Transform 压缩;发型资产首次加载后本地缓存;页面级预取下一套方案
- **降级**:GLB 加载失败 → 显示用户大头照 + 方案文本卡片(核心功能不受 3D 影响)
### 手势实现
- 方案切换:`PageView`(水平滑动 + 惯性 + 阻尼边缘效果)
- 化身旋转:`GestureDetector.onPanUpdate` → controller.rotateBy,松手无惯性(或轻量衰减)
- 缩放:`ScaleGestureRecognizer` 双指捏合,1.0-4.0 范围限制
- 发色:`HSVColorPicker` 自定义取色盘,onChanged 实时 setHairColor
## 6. 网络层与状态管理
- `ApiClient`Dio):baseUrl 可配置、token 注入、`code==0` 判定、401 自动登出、超时 30s
- 任务轮询:`outfit/task/status` 每 3s 轮询(Riverpod `StreamProvider`),任务完成自动停止
- 上传:Multipart,进度条反馈
- 缓存:效果图/服装照片 cached_network_image;化身 GLB 本地文件缓存(LRU,256MB 上限)
## 7. 拍摄引导设计
- 大头照:正面、面部无遮挡、光线均匀说明图;相机取景框对齐提示
- 全身照:距镜 2-3 米、全身入框、正面/侧面/背面 三角度示例图(类原生"手势引导"UI
- 照片本地压缩(宽边 ≤ 2048)后上传
## 8. 错误处理与加载状态
- 统一 `ErrorView` / `LoadingView` 组件(骨架屏)
- 生成任务失败:明确错误文案("衣橱为空,请先添加服装"等)+ 重试按钮
- 网络离线:离线提示 + 本地缓存优先展示(方案历史本地快照)
- 轮询超时(>10 分钟):提示"生成时间较长"并提供结果通知路径(v2 推送)
## 9. 测试策略
- 单元测试:手势计算/发色 HSV 调色/方案缓存 key 逻辑
- Widget 测试:方案流滑动切换、3D 查看器骨架降级、拍照引导流程
- 集成(v1 手工 + 冒烟脚本):登录 → 上传 → 生成 → 查看全链路
- 渲染层测试:AvatarViewer 接口 mock,业务测试不依赖具体 3D 实现
## 10. 与后端 API 对接清单
见 slogan-agent 方案第 11 节 API 路由表。App 端关键时序:
```
登录 → 上传照片(4张) → 填写身形 → [build 化身(异步)] → 上传衣橱服装
→ 生成穿搭 {日期范围, 地点} → 轮询任务 → 方案流(3D 即时查看)
→ 选主方案 → 效果图生成(异步) → 3 视角查看 → 收藏/跳转商品/门店预约
```
## 11. 开发规范约束(App 端)
- 状态管理统一 Riverpod,禁止 setState 在页面间传递业务状态
- 所有网络请求必须走 `ApiClient`,禁止散落 Dio 实例
- 3D 渲染只允许通过 `AvatarViewer` 抽象层,禁止业务代码直接依赖 three_dart 类型
- 命名:目录 `features/<domain>/`,组件 `shared/`;文件 snake_case,类 PascalCase
- 测试随功能同步编写(TDD:先写失败测试再实现)
-34
View File
@@ -1,34 +0,0 @@
**/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
-24
View File
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-1
View File
@@ -1 +0,0 @@
#include "Generated.xcconfig"
-644
View File
@@ -1,644 +0,0 @@
// !$*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 */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
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 = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
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 = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
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 */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* 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;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
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;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
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 */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift 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 = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* 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.slogan.sloganApp;
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.slogan.sloganApp.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.slogan.sloganApp.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.slogan.sloganApp.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.slogan.sloganApp;
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.slogan.sloganApp;
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 */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -1,119 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
-7
View File
@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
-16
View File
@@ -1,16 +0,0 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -1,122 +0,0 @@
{
"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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,23 +0,0 @@
{
"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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

@@ -1,5 +0,0 @@
# 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.
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
-26
View File
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
-70
View File
@@ -1,70 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Slogan App</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>slogan_app</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
-1
View File
@@ -1 +0,0 @@
#import "GeneratedPluginRegistrant.h"
-6
View File
@@ -1,6 +0,0 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
-12
View File
@@ -1,12 +0,0 @@
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.
}
}
-52
View File
@@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart';
/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK
abstract class AdsService {
/// 广告是否开通(AppConfig.pangleAppId 非空);未开通时广告位与激励均不渲染
bool get enabled;
Future<bool> showRewarded();
/// 横幅/信息流广告位(Mock 返回占位卡,P1 换原生广告组件)
Widget showBanner(BuildContext context);
}
/// 本地模拟广告(约 1 秒"播放"后返回完整观看)
class MockAdsService implements AdsService {
@override
bool get enabled => AppConfig.pangleAppId.isNotEmpty;
@override
Future<bool> showRewarded() async {
await Future.delayed(const Duration(milliseconds: 900));
return true;
}
@override
Widget showBanner(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
height: 90,
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.campaign_outlined, size: 20),
SizedBox(width: 8),
Text('广告位(P1 接入穿山甲)', style: TextStyle(fontSize: 12)),
],
),
);
}
}
final adsServiceProvider = Provider<AdsService>((ref) {
// P1AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现)
return MockAdsService();
});
-64
View File
@@ -1,64 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../network/api_client.dart';
import '../storage/token_storage.dart';
class AuthState {
final bool authenticated;
final int? userId;
final String? name;
const AuthState({
this.authenticated = false,
this.userId,
this.name,
});
}
final tokenStorageProvider = Provider<TokenStorage>((ref) => TokenStorage());
final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(tokenStorage: ref.watch(tokenStorageProvider));
client.onUnauthorized = () {
ref.read(authProvider.notifier).logout();
};
return client;
});
class AuthNotifier extends AsyncNotifier<AuthState> {
@override
Future<AuthState> build() async {
final storage = ref.watch(tokenStorageProvider);
await storage.load();
return AuthState(authenticated: storage.hasToken);
}
Future<void> login(String account, String password) async {
state = const AsyncLoading();
try {
final client = ref.read(apiClientProvider);
final data = await client.post<Map<String, dynamic>>('/user/login', {
'account': account,
'password': password,
});
final token = data['token'] as String? ?? '';
final user = data['user'] as Map<String, dynamic>? ?? {};
await ref.read(tokenStorageProvider).save(token);
state = AsyncData(AuthState(
authenticated: true,
userId: (user['id'] as num?)?.toInt(),
name: user['name'] as String?,
));
} catch (e) {
state = AsyncError(e, StackTrace.current);
}
}
Future<void> logout() async {
await ref.read(tokenStorageProvider).clear();
state = const AsyncData(AuthState());
}
}
final authProvider =
AsyncNotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
-22
View File
@@ -1,22 +0,0 @@
/// 全局配置
class AppConfig {
/// 后端地址:iOS 模拟器用 127.0.0.1Android 模拟器用 10.0.2.2;真机填局域网 IP
static const String baseUrl = 'http://127.0.0.1:3007';
/// 后端返回的相对路径(/workspace/...)拼成完整 URL
static String resolveUrl(String path) {
if (path.startsWith('http')) return path;
return '$baseUrl$path';
}
/// 穿山甲 App ID(空 = 广告未开通,广告位不渲染)
static const String pangleAppId = '';
/// 测试账号:联调/测试用真实用户数据,默认 wenwu901/123456
/// 可用 --dart-define=TEST_ACCOUNT=xxx --dart-define=TEST_PASSWORD=xxx 覆盖(build_web.sh 透传环境变量);
/// 置空则登录页不显示测试登录入口
static const String testAccount =
String.fromEnvironment('TEST_ACCOUNT', defaultValue: 'wenwu901');
static const String testPassword =
String.fromEnvironment('TEST_PASSWORD', defaultValue: '123456');
}
-77
View File
@@ -1,77 +0,0 @@
import 'package:dio/dio.dart';
import '../config/app_config.dart';
import '../storage/token_storage.dart';
import 'api_exception.dart';
/// 统一网络客户端:JWT 拦截器 + 统一响应解包(code != 0 抛 ApiException
class ApiClient {
final TokenStorage _tokenStorage;
late final Dio _dio;
/// 401 时回调(登出)
void Function()? onUnauthorized;
// ignore: prefer_initializing_formals
ApiClient({required TokenStorage tokenStorage, Dio? dio})
: _tokenStorage = tokenStorage {
_dio = dio ??
Dio(BaseOptions(
baseUrl: AppConfig.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 60),
));
_dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) {
final token = _tokenStorage.token;
if (token != null && token.isNotEmpty) {
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(res, parse);
}
Future<T> get<T>(String path,
{Map<String, dynamic>? query, T Function(dynamic data)? parse}) async {
final res = await _dio.get(path, queryParameters: query);
return _unwrap(res, parse);
}
/// multipart 上传:fields 为文本字段,fileField 为文件字段名
Future<T> upload<T>(String path, Map<String, dynamic> fields,
String fileField, String filePath,
{T Function(dynamic data)? parse}) async {
final form = FormData.fromMap({
...fields,
fileField: await MultipartFile.fromFile(filePath),
});
final res = await _dio.post(path, data: form);
return _unwrap(res, parse);
}
T _unwrap<T>(Response res, T Function(dynamic data)? parse) {
final data = res.data;
if (data is! Map<String, dynamic>) {
throw ApiException(-1, '响应格式错误');
}
final code = data['code'] as int? ?? -1;
final message = data['message'] as String? ?? '';
if (code != 0) {
throw ApiException(code, message);
}
final payload = data['data'];
if (parse != null) return parse(payload);
return payload as T;
}
}
-10
View File
@@ -1,10 +0,0 @@
/// 业务异常(后端统一响应 code != 0)
class ApiException implements Exception {
final int code;
final String message;
ApiException(this.code, this.message);
@override
String toString() => message;
}
-28
View File
@@ -1,28 +0,0 @@
import 'package:shared_preferences/shared_preferences.dart';
/// JWT token 本地存储
class TokenStorage {
static const _key = 'auth_token';
String? _token;
String? get token => _token;
bool get hasToken => _token != null && _token!.isNotEmpty;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString(_key);
}
Future<void> save(String token) async {
_token = token;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_key, token);
}
Future<void> clear() async {
_token = null;
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_key);
}
}
-171
View File
@@ -1,171 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/auth/auth_provider.dart';
import '../../core/config/app_config.dart';
import '../../shared/app_toast.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _accountCtrl = TextEditingController();
final _passwordCtrl = TextEditingController();
@override
void dispose() {
_accountCtrl.dispose();
_passwordCtrl.dispose();
super.dispose();
}
Future<void> _submit([String? acc, String? pwd]) async {
final account = (acc ?? _accountCtrl.text).trim();
final password = pwd ?? _passwordCtrl.text;
if (account.isEmpty || password.isEmpty) {
showToast('请输入账号和密码');
return;
}
await ref.read(authProvider.notifier).login(account, password);
final state = ref.read(authProvider);
if (state.hasError) {
showToast(state.error.toString().replaceFirst('Exception: ', ''));
return;
}
if (state.value?.authenticated == true && mounted) {
context.go('/home');
}
}
Future<void> _showRegisterDialog() async {
final account = TextEditingController();
final password = TextEditingController();
final name = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('注册新账号'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: account,
decoration: const InputDecoration(labelText: '账号')),
TextField(
controller: password,
obscureText: true,
decoration: const InputDecoration(labelText: '密码(至少6位)')),
TextField(
controller: name,
decoration: const InputDecoration(labelText: '昵称')),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('注册')),
],
),
);
if (result != true) return;
try {
final api = ref.read(apiClientProvider);
await api.post<Map<String, dynamic>>('/user/register', {
'account': account.text.trim(),
'password': password.text,
'name': name.text.trim(),
});
showToast('注册成功,正在登录...');
_accountCtrl.text = account.text.trim();
_passwordCtrl.text = password.text;
await _submit();
} catch (e) {
showToast('注册失败:${e.toString().replaceFirst('Exception: ', '')}');
}
}
@override
Widget build(BuildContext context) {
final auth = ref.watch(authProvider);
final loading = auth.isLoading;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(Icons.face_retouching_natural,
size: 72, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 12),
const Text('我的形象穿搭',
textAlign: TextAlign.center,
style:
TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const Text('拍出你的个人形象,生成专属穿搭方案',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 13)),
const SizedBox(height: 40),
TextField(
controller: _accountCtrl,
decoration: const InputDecoration(
labelText: '账号',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder()),
),
const SizedBox(height: 16),
TextField(
controller: _passwordCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder()),
onSubmitted: (_) => _submit(),
),
const SizedBox(height: 32),
FilledButton(
onPressed: loading ? null : _submit,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14)),
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('登录'),
),
if (AppConfig.testAccount.isNotEmpty) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: loading
? null
: () => _submit(
AppConfig.testAccount, AppConfig.testPassword),
icon: const Icon(Icons.science_outlined, size: 18),
label: Text('测试账号一键登录(${AppConfig.testAccount}'),
),
],
const SizedBox(height: 8),
TextButton(
onPressed: loading ? null : _showRegisterDialog,
child: const Text('还没有账号?注册新账号'),
),
],
),
),
),
),
);
}
}
@@ -1,237 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
import '../../features/member/member_provider.dart';
import '../../shared/app_toast.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'cps_provider.dart';
/// 联盟选品池:分类 chips + 上滑分页 + 转链跳转
class CpsProductListPage extends ConsumerStatefulWidget {
final CpsListArgs args;
const CpsProductListPage({required this.args, super.key});
@override
ConsumerState<CpsProductListPage> createState() =>
_CpsProductListPageState();
}
class _CpsProductListPageState extends ConsumerState<CpsProductListPage> {
final _scrollController = ScrollController();
String? _catCode; // null = 全部
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref
.read(cpsProductProvider(widget.args).notifier)
.loadMore();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _openLink(CpsProductInfo product, String scene, int planId) async {
try {
final link = await ref.read(cpsLinkProvider).link(ref,
productId: product.id, scene: scene, planId: planId);
if (link.isEmpty) {
showToast('暂无可跳转链接');
return;
}
final uri = Uri.parse(link);
if (uri.scheme.startsWith('http') &&
await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// 淘口令等非 URL 内容:复制到剪贴板
await _copy(link);
}
} catch (e) {
if (!mounted) return;
showToast('跳转失败:${e.toString().replaceFirst('Exception: ', '')}');
}
}
Future<void> _copy(String text) async {
final data = ClipboardData(text: text);
await Clipboard.setData(data);
if (mounted) showToast('已复制,打开淘宝即可购买');
}
@override
Widget build(BuildContext context) {
final categories = ref.watch(cpsCategoryProvider);
final products = ref.watch(cpsProductProvider(widget.args));
final member = ref.watch(memberProvider).value;
final isVip = member?.isVip ?? false;
return Scaffold(
appBar: AppBar(title: Text(widget.args.title)),
body: Column(
children: [
categories.when(
loading: () => const SizedBox(height: 48),
error: (_, _) => const SizedBox(height: 48),
data: (cats) {
final shown = widget.args.categoryCode.isNotEmpty
? cats.where((c) => c.code == widget.args.categoryCode).toList()
: cats;
if (shown.isEmpty) return const SizedBox(height: 48);
return SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: const Text('全部'),
selected: _catCode == null,
onSelected: (_) => setState(() => _catCode = null),
),
),
for (final c in shown)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(c.name),
selected: _catCode == c.code,
onSelected: (_) => setState(() => _catCode = c.code),
),
),
],
),
);
},
),
Expanded(
child: products.when(
loading: () => const LoadingView(text: '加载商品...'),
error: (e, _) => ErrorView(
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: () =>
ref.refresh(cpsProductProvider(widget.args)),
),
data: (list) {
final shown = _catCode == null
? list
: list.where((p) => p.categoryCode == _catCode).toList();
if (shown.isEmpty) {
return const EmptyView(message: '暂无联盟商品');
}
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
itemCount: shown.length,
itemBuilder: (ctx, i) {
final p = shown[i];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () =>
_openLink(p, widget.args.scene, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
p.coverUrl.isEmpty
? const Icon(Icons.shopping_bag_outlined,
size: 48, color: Colors.grey)
: Image.network(
AppConfig.resolveUrl(p.coverUrl),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.grey),
),
if (isVip)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.shade600,
borderRadius:
BorderRadius.circular(6),
),
child: const Text('VIP 返现 1.5x',
style: TextStyle(
color: Colors.white,
fontSize: 10)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
p.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'¥${p.priceText}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red.shade600),
),
if (p.commissionText.isNotEmpty)
Text(
p.commissionText,
style: const TextStyle(
fontSize: 11, color: Colors.grey),
),
],
),
),
],
),
),
);
},
);
},
),
),
],
),
);
}
}
-201
View File
@@ -1,201 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
/// CPS 联盟商品(后端 /cps/* 模型)
class CpsProductInfo {
final int id;
final String source;
final String outerId;
final String categoryCode;
final String name;
final String coverUrl;
final int priceFen;
final String shopName;
final int commissionRate; // 万分比
final String city;
const CpsProductInfo({
required this.id,
required this.source,
required this.outerId,
required this.categoryCode,
required this.name,
required this.coverUrl,
required this.priceFen,
required this.shopName,
required this.commissionRate,
required this.city,
});
String get priceText => (priceFen / 100).toStringAsFixed(0);
String get commissionText {
final pct = commissionRate / 100;
return pct > 0 ? '佣金 ${pct.toStringAsFixed(1)}%' : '';
}
}
CpsProductInfo _parseProduct(Map<String, dynamic> e) => CpsProductInfo(
id: (e['id'] as num).toInt(),
source: e['source'] as String? ?? '',
outerId: e['outer_id'] as String? ?? '',
categoryCode: e['category_code'] as String? ?? '',
name: e['name'] as String? ?? '',
coverUrl: e['cover_url'] as String? ?? '',
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
shopName: e['shop_name'] as String? ?? '',
commissionRate: (e['commission_rate'] as num?)?.toInt() ?? 0,
city: e['city'] as String? ?? '',
);
/// 联盟分类(chips
class CpsCategoryInfo {
final String code;
final String name;
final String source;
const CpsCategoryInfo({
required this.code,
required this.name,
required this.source,
});
}
final cpsCategoryProvider = FutureProvider<List<CpsCategoryInfo>>((ref) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/category/list');
final list = data['list'] as List<dynamic>? ?? [];
return list
.map((e) => CpsCategoryInfo(
code: e['code'] as String? ?? '',
name: e['name'] as String? ?? '',
source: e['source'] as String? ?? '',
))
.toList();
});
/// 商品列表页参数
class CpsListArgs {
final String title;
final String source;
final String categoryCode;
final String city;
final String scene; // 点击日志场景标记(haircut/item_buy/...
const CpsListArgs({
required this.title,
this.source = '',
this.categoryCode = '',
this.city = '',
this.scene = '',
});
@override
bool operator ==(Object other) =>
other is CpsListArgs &&
other.source == source &&
other.categoryCode == categoryCode &&
other.city == city &&
other.scene == scene;
@override
int get hashCode => Object.hash(source, categoryCode, city, scene);
}
/// 选品池分页(上滑加载更多;Riverpod 3 family 参数经构造函数注入)
class CpsProductListNotifier extends AsyncNotifier<List<CpsProductInfo>> {
CpsProductListNotifier(this.arg);
final CpsListArgs arg;
int _page = 1;
bool _hasMore = true;
@override
Future<List<CpsProductInfo>> build() async {
_page = 1;
_hasMore = true;
return _fetch(1);
}
Future<void> loadMore() async {
if (!_hasMore || state.isLoading) return;
_page += 1;
try {
final next = await _fetch(_page);
state = AsyncData([...?state.value, ...next]);
} catch (_) {
_page -= 1; // 失败回退页码,允许下次重试
}
}
Future<List<CpsProductInfo>> _fetch(int page) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/product/list',
query: {
if (arg.source.isNotEmpty) 'source': arg.source,
if (arg.categoryCode.isNotEmpty) 'category_code': arg.categoryCode,
if (arg.city.isNotEmpty) 'city': arg.city,
'page': page,
});
_hasMore = data['has_more'] as bool? ?? false;
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
}
}
final cpsProductProvider = AsyncNotifierProvider.family<
CpsProductListNotifier, List<CpsProductInfo>, CpsListArgs>(
CpsProductListNotifier.new);
/// 方案驱动推荐(plan_id + scene
final cpsRecommendProvider = FutureProvider.family<List<CpsProductInfo>,
({int planId, String scene})>((ref, args) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/plan/recommend',
query: {'plan_id': args.planId, 'scene': args.scene});
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
});
/// 衣橱升级款
final cpsUpgradeProvider =
FutureProvider.family<List<CpsProductInfo>, int>((ref, itemId) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/wardrobe/upgrade',
query: {'item_id': itemId});
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
});
/// 转链动作(POST /cps/product/link,返回 deeplink
class CpsLinkAction {
Future<String> link(
WidgetRef ref, {
required int productId,
String scene = '',
int planId = 0,
}) async {
final api = ref.read(apiClientProvider);
final data = await api.post<Map<String, dynamic>>('/cps/product/link', {
'product_id': productId,
'scene': scene,
'plan_id': planId,
});
return data['deeplink'] as String? ?? '';
}
}
final cpsLinkProvider = Provider<CpsLinkAction>((ref) => CpsLinkAction());
/// 最近优惠(会员中心)
final cpsRecentProvider = FutureProvider<List<CpsProductInfo>>((ref) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/my/recent');
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
});
-137
View File
@@ -1,137 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/ads/ads_service.dart';
import '../mine/mine_page.dart';
import '../outfit/outfit_page.dart';
/// 主框架:2 Tab(穿搭 = 造型方案制作向导 / 我的 = 个人信息管理入口)
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
int _index = 0;
bool _splashShown = false;
@override
void initState() {
super.initState();
// 开屏广告:仅广告开通时展示一次(P0 Mock 下 pangleAppId 为空 → 跳过)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_splashShown) return;
if (!ref.read(adsServiceProvider).enabled) return;
_splashShown = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _SplashAdOverlay(
onSkip: () => Navigator.of(context).pop(),
),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _index,
children: const [
OutfitPage(),
MinePage(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _index,
onDestinationSelected: (i) => setState(() => _index = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.auto_awesome_outlined),
selectedIcon: Icon(Icons.auto_awesome),
label: '穿搭'),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的'),
],
),
);
}
}
/// 开屏广告:3 秒倒计时后自动关闭,右上角可手动跳过
class _SplashAdOverlay extends StatefulWidget {
final VoidCallback onSkip;
const _SplashAdOverlay({required this.onSkip});
@override
State<_SplashAdOverlay> createState() => _SplashAdOverlayState();
}
class _SplashAdOverlayState extends State<_SplashAdOverlay> {
Timer? _timer;
int _seconds = 3;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_seconds <= 1) {
_timer?.cancel();
if (mounted) widget.onSkip();
return;
}
setState(() => _seconds -= 1);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
child: ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Stack(
children: [
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.campaign_outlined, size: 64),
SizedBox(height: 12),
Text('开屏广告位',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('P1 接入穿山甲开屏广告',
style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
Positioned(
top: 24,
right: 24,
child: OutlinedButton(
onPressed: widget.onSkip,
child: Text('跳过($_seconds'),
),
),
],
),
),
);
}
}
@@ -1,134 +0,0 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
/// 会员 provider:会员状态/套餐/下单/轮询/领奖
class MemberInfo {
final bool isVip;
final String expireAt;
final String planName;
final List<String> benefits;
const MemberInfo({
required this.isVip,
required this.expireAt,
required this.planName,
required this.benefits,
});
factory MemberInfo.fromJson(Map<String, dynamic> e) => MemberInfo(
isVip: e['is_vip'] as bool? ?? false,
expireAt: e['expire_at'] as String? ?? '',
planName: e['plan_name'] as String? ?? '',
benefits: (e['benefits'] as List<dynamic>? ?? []).cast<String>(),
);
}
/// 权益 key → 文案
const benefitLabels = {
'effect_unlimited': '无限效果图',
'ai_priority': '优先 AI 方案',
'cps_commission_x15': '返现加成 1.5x',
'store_discount': '门店折扣',
};
List<String> benefitTexts(MemberInfo m) =>
m.benefits.map((b) => benefitLabels[b] ?? b).toList();
class MemberNotifier extends AsyncNotifier<MemberInfo> {
@override
Future<MemberInfo> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/member/status');
return MemberInfo.fromJson(data);
}
Future<void> refresh() async {
state = await AsyncValue.guard(build);
}
/// 领取广告激励(服务端限频);adType: effect_extra | vip_trial
/// 返回当日剩余次数;超出限频抛 ApiException
Future<int> claimReward(String adType) async {
final api = ref.read(apiClientProvider);
final data =
await api.post<Map<String, dynamic>>('/ad/reward/claim', {'ad_type': adType});
await refresh(); // vip_trial 可能开通体验会员
return (data['reward']?['remaining_today'] as num?)?.toInt() ?? 0;
}
}
final memberProvider =
AsyncNotifierProvider<MemberNotifier, MemberInfo>(MemberNotifier.new);
class MemberPlan {
final int id;
final String name;
final int priceFen;
final int durationDays;
final List<String> features;
const MemberPlan({
required this.id,
required this.name,
required this.priceFen,
required this.durationDays,
required this.features,
});
factory MemberPlan.fromJson(Map<String, dynamic> e) => MemberPlan(
id: (e['id'] as num).toInt(),
name: e['name'] as String? ?? '',
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
durationDays: (e['duration_days'] as num?)?.toInt() ?? 30,
features: _parseFeatures(e['features'] as String? ?? ''),
);
static List<String> _parseFeatures(String s) {
try {
return (jsonDecode(s) as List<dynamic>).cast<String>();
} catch (_) {
return const [];
}
}
String get priceText =>
'¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}';
}
final memberPlanProvider = FutureProvider<List<MemberPlan>>((ref) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/member/plan/list');
final list = data['list'] as List<dynamic>? ?? [];
return list
.map((e) => MemberPlan.fromJson(e as Map<String, dynamic>))
.toList();
});
class OrderResult {
final String orderNo;
final String payUrl;
const OrderResult({required this.orderNo, required this.payUrl});
}
/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL)
Future<OrderResult> createMemberOrder(WidgetRef ref, int planId) async {
final api = ref.read(apiClientProvider);
final data =
await api.post<Map<String, dynamic>>('/member/order/create', {'plan_id': planId});
return OrderResult(
orderNo: data['order_no'] as String? ?? '',
payUrl: data['pay_url'] as String? ?? '',
);
}
/// 订单状态(支付页 2s 轮询):pending | paid | closed
Future<String> fetchOrderStatus(WidgetRef ref, String orderNo) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/member/order/status',
query: {'order_no': orderNo});
return data['status'] as String? ?? '';
}
-152
View File
@@ -1,152 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../shared/app_toast.dart';
import 'member_provider.dart';
class PayArgs {
final String orderNo;
final String payUrl;
const PayArgs({required this.orderNo, required this.payUrl});
}
enum PayPhase { launching, paying, paid, timeout, failed }
/// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s)
class PayPage extends ConsumerStatefulWidget {
final PayArgs args;
const PayPage({super.key, required this.args});
@override
ConsumerState<PayPage> createState() => _PayPageState();
}
class _PayPageState extends ConsumerState<PayPage> {
PayPhase _phase = PayPhase.launching;
Timer? _timer;
int _elapsed = 0;
@override
void initState() {
super.initState();
_start();
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
Future<void> _start() async {
try {
final ok = await launchUrl(Uri.parse(widget.args.payUrl),
mode: LaunchMode.externalApplication);
if (!ok) {
setState(() => _phase = PayPhase.failed);
return;
}
setState(() => _phase = PayPhase.paying);
} catch (e) {
setState(() => _phase = PayPhase.failed);
return;
}
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
}
Future<void> _check() async {
_elapsed += 2;
try {
final status = await fetchOrderStatus(ref, widget.args.orderNo);
if (status == 'paid') {
_timer?.cancel();
if (!mounted) return;
setState(() => _phase = PayPhase.paid);
showToast('会员开通成功');
return;
}
if (_elapsed >= 60) {
_timer?.cancel();
if (!mounted) return;
setState(() => _phase = PayPhase.timeout);
}
} catch (_) {
// 轮询失败不中断,下次再试
}
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('会员支付')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
switch (_phase) {
PayPhase.launching ||
PayPhase.paying => Column(children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
const Text('请在浏览器中完成支付,正在确认结果…'),
const SizedBox(height: 8),
Text('订单号 ${widget.args.orderNo}',
style: const TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => _check(),
child: const Text('我已完成支付'),
),
]),
PayPhase.paid => Column(children: [
Icon(Icons.check_circle, size: 64, color: scheme.primary),
const SizedBox(height: 12),
const Text('支付成功,会员已开通!',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.pop(),
child: const Text('返回会员中心'),
),
]),
PayPhase.timeout => Column(children: [
Icon(Icons.hourglass_empty, size: 64, color: Colors.orange),
const SizedBox(height: 12),
const Text('支付结果确认中'),
const SizedBox(height: 8),
const Text('可稍后到会员中心查看开通状态,以支付结果为准',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12)),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context.pop(),
child: const Text('返回'),
),
]),
PayPhase.failed => Column(children: [
Icon(Icons.error_outline, size: 64, color: scheme.error),
const SizedBox(height: 12),
const Text('无法打开支付页面'),
const SizedBox(height: 16),
OutlinedButton(
onPressed: () => context.pop(),
child: const Text('返回'),
),
]),
},
],
),
),
),
);
}
}
-457
View File
@@ -1,457 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/ads/ads_service.dart';
import '../../core/auth/auth_provider.dart';
import '../../core/config/app_config.dart';
import '../../features/cps/cps_provider.dart';
import '../../shared/app_toast.dart';
import '../../shared/widgets/loading_view.dart';
import '../member/member_provider.dart';
import '../member/pay_page.dart';
/// 我的:个人信息管理(会员卡/免费权益/最近优惠 + 形象/衣橱入口 + 退出登录)
class MinePage extends ConsumerStatefulWidget {
const MinePage({super.key});
@override
ConsumerState<MinePage> createState() => _MinePageState();
}
class _MinePageState extends ConsumerState<MinePage> {
bool _rewarding = false;
// web 上无 Platformdart:io 不可用),且 web 端默认走系统浏览器收银台
bool get _isIOS =>
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
Future<void> _openPlans() async {
final plans = await showModalBottomSheet<MemberPlan>(
context: context,
builder: (ctx) => const _PlanSheet(),
);
if (plans == null || !mounted) return;
try {
final result = await createMemberOrder(ref, plans.id);
if (!mounted || result.payUrl.isEmpty) return;
await context.push(
'/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl));
ref.read(memberProvider.notifier).refresh();
} catch (e) {
if (!mounted) return;
showToast(e.toString().replaceFirst('Exception: ', ''));
}
}
Future<void> _claimReward(String adType, String successMsg) async {
if (_rewarding) return;
final ads = ref.read(adsServiceProvider);
if (!ads.enabled) {
showToast('广告功能暂未开通');
return;
}
setState(() => _rewarding = true);
try {
final watched = await ads.showRewarded();
if (!watched) {
showToast('未完整观看,无法领取');
return;
}
final remaining =
await ref.read(memberProvider.notifier).claimReward(adType);
if (!mounted) return;
showToast('$successMsg(今日剩余 $remaining 次)');
} catch (e) {
if (!mounted) return;
showToast(e.toString().replaceFirst('Exception: ', ''));
} finally {
if (mounted) setState(() => _rewarding = false);
}
}
@override
Widget build(BuildContext context) {
final member = ref.watch(memberProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_MemberCard(
member: member,
isIOS: _isIOS,
onOpenPlans: _openPlans,
),
const SizedBox(height: 12),
const _RecentDealsCard(),
const SizedBox(height: 16),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.face_retouching_natural),
title: const Text('我的形象'),
subtitle: const Text('拍摄照片 · 身形参数 · 生成 3D 化身'),
trailing:
const Icon(Icons.chevron_right, color: Colors.grey),
onTap: () => context.push('/avatar-build'),
),
const Divider(height: 1, indent: 56),
ListTile(
leading: const Icon(Icons.checkroom),
title: const Text('我的衣橱'),
subtitle: const Text('管理服装单品,AI 生成时自动搭配'),
trailing:
const Icon(Icons.chevron_right, color: Colors.grey),
onTap: () => context.push('/wardrobe'),
),
],
),
),
if (member.value?.isVip == false) ...[
const SizedBox(height: 12),
_AdsRewardCard(
rewarding: _rewarding,
onClaim: (adType, msg) => _claimReward(adType, msg),
),
],
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go('/login');
},
icon: const Icon(Icons.logout),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
}
class _MemberCard extends ConsumerWidget {
final AsyncValue<MemberInfo> member;
final bool isIOS;
final VoidCallback onOpenPlans;
const _MemberCard(
{required this.member, required this.isIOS, required this.onOpenPlans});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
return Card(
color: scheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: member.when(
loading: () => const Text('加载会员状态...',
style: TextStyle(fontSize: 13)),
error: (e, _) => Text('会员状态加载失败:$e',
style: const TextStyle(fontSize: 12)),
data: (m) {
if (m.isVip) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.workspace_premium,
size: 30, color: scheme.primary),
const SizedBox(width: 10),
const Text('形象会员',
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.bold)),
const Spacer(),
Chip(
label: Text(m.planName),
labelStyle:
TextStyle(color: scheme.primary, fontSize: 12),
visualDensity: VisualDensity.compact,
),
]),
const SizedBox(height: 6),
Text('有效期至 ${m.expireAt}',
style:
TextStyle(fontSize: 12, color: scheme.primary)),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonalIcon(
onPressed: () => _showMemberCode(context, m),
icon: const Icon(Icons.qr_code_2, size: 18),
label: const Text('会员码'),
),
),
if (benefitTexts(m).isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final b in benefitTexts(m))
Chip(
label: Text(b),
labelStyle: const TextStyle(fontSize: 11),
visualDensity: VisualDensity.compact,
),
],
),
],
],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(children: [
Icon(Icons.workspace_premium, size: 30),
SizedBox(width: 10),
Text('形象会员',
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.bold)),
]),
const SizedBox(height: 6),
const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣',
style: TextStyle(fontSize: 12)),
const SizedBox(height: 10),
if (isIOS)
const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员',
style: TextStyle(fontSize: 11, color: Colors.grey))
else
FilledButton.icon(
onPressed: onOpenPlans,
icon: const Icon(Icons.payment, size: 18),
label: const Text('开通会员'),
),
],
);
},
),
),
);
}
}
/// 会员码(纯客户端展示,门店出示核销)
void _showMemberCode(BuildContext context, MemberInfo m) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('会员码'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.qr_code_2, size: 96),
),
const SizedBox(height: 12),
Text('${m.planName} · 有效期至 ${m.expireAt}',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
const Text('到店出示即可享受会员价与专属折扣',
style: TextStyle(fontSize: 12)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
],
),
);
}
/// 最近优惠卡:接口错误或空列表时整卡隐藏
class _RecentDealsCard extends ConsumerWidget {
const _RecentDealsCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final deals = ref.watch(cpsRecentProvider).value;
if (deals == null || deals.isEmpty) return const SizedBox.shrink();
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('最近优惠',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
for (final p in deals.take(5))
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: p.coverUrl.isEmpty
? const SizedBox(
width: 40,
height: 40,
child: Icon(Icons.shopping_bag_outlined,
color: Colors.grey),
)
: Image.network(
AppConfig.resolveUrl(p.coverUrl),
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox(
width: 40,
height: 40,
child: Icon(Icons.broken_image_outlined,
color: Colors.grey),
),
),
),
title: Text(p.name,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'¥${p.priceText}${p.shopName.isNotEmpty ? ' · ${p.shopName}' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing:
const Icon(Icons.chevron_right, size: 18, color: Colors.grey),
onTap: () => context.push('/cps-product-list',
extra: CpsListArgs(
title: '最近优惠',
source: p.source,
categoryCode: p.categoryCode,
city: p.city,
scene: 'member_benefit',
)),
),
],
),
),
);
}
}
class _AdsRewardCard extends ConsumerWidget {
final bool rewarding;
final void Function(String adType, String msg) onClaim;
const _AdsRewardCard({required this.rewarding, required this.onClaim});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('免费获取权益',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple),
title: const Text('看视频 · 效果图 +1'),
subtitle: const Text('每日最多 2 次,次日重置'),
trailing: OutlinedButton(
onPressed: rewarding
? null
: () => onClaim('effect_extra', '已获得 1 次效果图'),
child: const Text('看视频'),
),
),
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.ondemand_video, color: Colors.teal),
title: const Text('看视频 · 体验会员 1 天'),
subtitle: const Text('每日最多 1 次,含无限效果图'),
trailing: OutlinedButton(
onPressed: rewarding
? null
: () => onClaim('vip_trial', '已获得 1 天体验会员'),
child: const Text('看视频'),
),
),
],
),
),
);
}
}
class _PlanSheet extends ConsumerWidget {
const _PlanSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final plans = ref.watch(memberPlanProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('选择会员套餐',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
plans.when(
loading: () => const Padding(
padding: EdgeInsets.all(24), child: LoadingView()),
error: (e, _) => Text('套餐加载失败:$e',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey)),
data: (list) => list.isEmpty
? const Padding(
padding: EdgeInsets.all(24),
child: Text('暂未开放套餐', textAlign: TextAlign.center),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final p in list)
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
tileColor: Theme.of(context)
.colorScheme
.primaryContainer
.withValues(alpha: 0.5),
title: Text(p.name,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
subtitle: Text(
'${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}',
style: const TextStyle(fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: Text(p.priceText,
style: TextStyle(
color:
Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.bold)),
onTap: () => Navigator.pop(context, p),
),
],
),
),
],
),
),
);
}
}
-317
View File
@@ -1,317 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/app_toast.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'outfit_provider.dart';
/// 穿搭:任务参数(日期范围/地点)→ 生成造型方案;个人信息在「我的形象」管理
class OutfitPage extends ConsumerStatefulWidget {
const OutfitPage({super.key});
@override
ConsumerState<OutfitPage> createState() => _OutfitPageState();
}
class _OutfitPageState extends ConsumerState<OutfitPage> {
DateTime? _startDate;
DateTime? _endDate;
final _locationCtrl = TextEditingController();
String _occasion = '通勤';
bool _submitting = false;
static const _occasions = ['通勤', '约会', '聚会', '运动'];
@override
void dispose() {
_locationCtrl.dispose();
super.dispose();
}
Future<void> _pickStartDate() async {
final now = DateTime.now();
final date = await showDatePicker(
context: context,
firstDate: now,
lastDate: now.add(const Duration(days: 30)),
initialDate: _startDate ?? now,
);
if (date == null) return;
setState(() {
_startDate = date;
if (_endDate != null && _endDate!.isBefore(date)) {
_endDate = date;
}
});
}
Future<void> _pickEndDate() async {
final now = DateTime.now();
final first = _startDate ?? now;
final date = await showDatePicker(
context: context,
firstDate: first,
lastDate: first.add(const Duration(days: 30)),
initialDate: _endDate ?? first,
);
if (date != null) setState(() => _endDate = date);
}
String _fmt(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Future<void> _generate() async {
final start = _startDate;
final end = _endDate;
final location = _locationCtrl.text.trim();
if (start == null) {
showToast('请选择开始日期');
return;
}
if (end == null) {
showToast('请选择结束日期');
return;
}
if (location.isEmpty) {
showToast('请输入地点');
return;
}
if (end.difference(start).inDays < 1) {
showToast('日期范围至少 1 天');
return;
}
setState(() => _submitting = true);
final ok = await ref.read(generateProvider.notifier).generate(
startDate: _fmt(start),
endDate: _fmt(end),
location: location,
occasion: _occasion,
);
if (!mounted) return;
setState(() => _submitting = false);
if (ok) {
showToast('生成完成');
ref.read(generateProvider.notifier).reset();
context.push('/plan-viewer');
}
}
@override
Widget build(BuildContext context) {
final plans = ref.watch(outfitPlanProvider);
final gen = ref.watch(generateProvider);
return Scaffold(
appBar: AppBar(title: const Text('造型穿搭')),
body: RefreshIndicator(
onRefresh: () => ref.read(outfitPlanProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickStartDate,
icon: const Icon(Icons.date_range_outlined),
label: Text(_startDate == null
? '开始日期'
: _fmt(_startDate!)),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _submitting ? null : _pickEndDate,
icon: const Icon(Icons.date_range_outlined),
label: Text(_endDate == null
? '结束日期'
: _fmt(_endDate!)),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _locationCtrl,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: '地点(如:上海·外滩)',
hintText: '影响天气与穿搭建议',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const Text('场景',
style:
TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
for (final o in _occasions)
ChoiceChip(
label: Text(o),
selected: _occasion == o,
onSelected: _submitting
? null
: (_) => setState(() => _occasion = o),
),
],
),
const SizedBox(height: 16),
if (gen.phase == GeneratePhase.running)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2)),
const SizedBox(width: 8),
Expanded(
child: Text(gen.statusText,
style: const TextStyle(fontSize: 13)),
),
],
),
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _submitting ? null : _generate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
child: const Text('生成穿搭方案'),
),
),
],
),
),
),
const SizedBox(height: 16),
Text('已有方案',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
plans.when(
loading: () => const Padding(
padding: EdgeInsets.only(top: 48),
child: LoadingView(text: '加载方案...')),
error: (e, _) => Padding(
padding: const EdgeInsets.only(top: 48),
child: ErrorView(
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: () => ref.read(outfitPlanProvider.notifier).refresh(),
),
),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyView(
message: '还没有穿搭方案,先设置日期与地点生成吧'),
);
}
return Column(
children: [
for (final p in list) ...[
_PlanCard(plan: p),
const SizedBox(height: 8),
],
],
);
},
),
],
),
),
);
}
}
class _PlanCard extends ConsumerWidget {
final OutfitPlanInfo plan;
const _PlanCard({required this.plan});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.push('/plan-viewer'),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.checkroom, color: scheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(plan.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15)),
),
if (plan.isMain) ...[
const SizedBox(width: 6),
const Chip(
label: Text('主方案'),
labelStyle:
TextStyle(color: Colors.white, fontSize: 11),
backgroundColor: Colors.indigo,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
],
),
const SizedBox(height: 4),
Text(
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}'
'${plan.fromAi ? ' · AI 生成' : ' · 规则生成'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.grey),
],
),
),
),
);
}
}
@@ -1,251 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
class OutfitPlanInfo {
final int id;
final String title;
final String source; // ai / rule
final int score;
final int mainFlag;
final String dateRange;
final String location;
final int hairstyleId;
final String hairColor;
final String occasion;
const OutfitPlanInfo({
required this.id,
required this.title,
required this.source,
required this.score,
required this.mainFlag,
required this.dateRange,
required this.location,
required this.hairstyleId,
required this.hairColor,
this.occasion = '',
});
bool get isMain => mainFlag == 1;
bool get fromAi => source == 'ai';
}
class PlanItemInfo {
final int id;
final String slot; // 上衣/下装/鞋/配饰
final String source; // wardrobe / new
final int wardrobeItemId;
final String name;
final String desc;
const PlanItemInfo({
required this.id,
required this.slot,
required this.source,
required this.wardrobeItemId,
required this.name,
required this.desc,
});
bool get fromWardrobe => source == 'wardrobe';
}
class PlanEffectImageInfo {
final int id;
final String angle; // front / side / back
final String url;
final String status; // pending / rendering / done / failed
const PlanEffectImageInfo({
required this.id,
required this.angle,
required this.url,
required this.status,
});
bool get done => status == 'done' && url.isNotEmpty;
}
class PlanDetail {
final OutfitPlanInfo plan;
final List<PlanItemInfo> items;
final List<PlanEffectImageInfo> images;
final String hairstyleName;
const PlanDetail({
required this.plan,
required this.items,
required this.images,
required this.hairstyleName,
});
}
/// 方案列表
class OutfitPlanNotifier extends AsyncNotifier<List<OutfitPlanInfo>> {
@override
Future<List<OutfitPlanInfo>> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/outfit/plan/list');
final list = data['list'] as List<dynamic>? ?? [];
return list
.map((e) => OutfitPlanInfo(
id: (e['id'] as num).toInt(),
title: e['title'] as String? ?? '',
source: e['source'] as String? ?? 'rule',
score: (e['score'] as num?)?.toInt() ?? 0,
mainFlag: (e['main_flag'] as num?)?.toInt() ?? 0,
dateRange: e['date_range'] as String? ?? '',
location: e['location'] as String? ?? '',
hairstyleId: (e['hairstyle_id'] as num?)?.toInt() ?? 0,
hairColor: e['hair_color'] as String? ?? '',
))
.toList();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
Future<void> selectMain(int planId) async {
final api = ref.read(apiClientProvider);
await api.post('/outfit/plan/select-main', {'plan_id': planId});
await refresh();
}
Future<void> review(int planId, String action) async {
final api = ref.read(apiClientProvider);
await api.post('/outfit/plan/review', {'plan_id': planId, 'action': action});
}
}
final outfitPlanProvider =
AsyncNotifierProvider<OutfitPlanNotifier, List<OutfitPlanInfo>>(
OutfitPlanNotifier.new);
/// 生成任务状态(轮询后端任务状态机)
enum GeneratePhase { idle, running, done, failed }
class GenerateState {
final GeneratePhase phase;
final String statusText;
final String error;
const GenerateState({
this.phase = GeneratePhase.idle,
this.statusText = '',
this.error = '',
});
}
class GenerateNotifier extends Notifier<GenerateState> {
@override
GenerateState build() => const GenerateState();
Future<bool> generate({
required String startDate,
required String endDate,
required String location,
required String occasion,
}) async {
state =
const GenerateState(phase: GeneratePhase.running, statusText: '任务创建中...');
final api = ref.read(apiClientProvider);
final data = await api.post<Map<String, dynamic>>('/outfit/generate', {
'start_date': startDate,
'end_date': endDate,
'location': location,
'occasion': occasion,
});
final taskId = (data['task_id'] as num).toInt();
for (var i = 0; i < 90; i++) {
await Future.delayed(const Duration(seconds: 2));
final st = await api.get<Map<String, dynamic>>('/outfit/task/status',
query: {'task_id': taskId});
final status = st['status'] as String? ?? '';
final err = st['error'] as String? ?? '';
state = GenerateState(
phase: GeneratePhase.running, statusText: _statusText(status));
if (status == 'done') {
state = const GenerateState(phase: GeneratePhase.done, statusText: '完成');
break;
}
if (status == 'failed') {
state = GenerateState(
phase: GeneratePhase.failed,
error: err.isEmpty ? '生成失败,请稍后重试' : err);
return false;
}
}
await ref.read(outfitPlanProvider.notifier).refresh();
return state.phase == GeneratePhase.done;
}
String _statusText(String status) {
switch (status) {
case 'pending':
return '排队中...';
case 'planning':
return 'AI 规划穿搭中...';
case 'scoring':
return '方案评分中...';
case 'rendering':
return '生成效果图中...';
case 'failed':
return '生成失败';
default:
return '处理中...';
}
}
void reset() => state = const GenerateState();
}
final generateProvider =
NotifierProvider<GenerateNotifier, GenerateState>(GenerateNotifier.new);
/// 方案详情
final planDetailProvider = FutureProvider.family<PlanDetail, int>((ref, planId) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/outfit/plan/detail',
query: {'plan_id': planId});
final planData = data['plan'] as Map<String, dynamic>? ?? {};
final items = (data['items'] as List<dynamic>? ?? [])
.map((e) => PlanItemInfo(
id: (e['id'] as num).toInt(),
slot: e['slot'] as String? ?? '',
source: e['source'] as String? ?? 'new',
wardrobeItemId: (e['wardrobe_item_id'] as num?)?.toInt() ?? 0,
name: e['name'] as String? ?? '',
desc: e['desc'] as String? ?? '',
))
.toList();
final images = (data['images'] as List<dynamic>? ?? [])
.map((e) => PlanEffectImageInfo(
id: (e['id'] as num).toInt(),
angle: e['angle'] as String? ?? '',
url: e['url'] as String? ?? '',
status: e['status'] as String? ?? 'pending',
))
.toList();
final hairstyle = data['hairstyle'] as Map<String, dynamic>?;
return PlanDetail(
plan: OutfitPlanInfo(
id: (planData['id'] as num).toInt(),
title: planData['title'] as String? ?? '',
source: planData['source'] as String? ?? 'rule',
score: (planData['score'] as num?)?.toInt() ?? 0,
mainFlag: (planData['main_flag'] as num?)?.toInt() ?? 0,
dateRange: planData['date_range'] as String? ?? '',
location: planData['location'] as String? ?? '',
hairstyleId: (planData['hairstyle_id'] as num?)?.toInt() ?? 0,
hairColor: planData['hair_color'] as String? ?? '',
occasion: planData['occasion'] as String? ?? '',
),
items: items,
images: images,
hairstyleName: hairstyle?['name'] as String? ?? '',
);
});
@@ -1,125 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/ads/ads_service.dart';
import '../../core/config/app_config.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'outfit_provider.dart';
const _angleLabels = {
'front': '正面',
'side': '侧面',
'back': '背面',
};
/// 效果图查看:3 视角(正面/侧面/背面)切换
class PlanEffectPage extends ConsumerStatefulWidget {
final List<PlanEffectImageInfo> images;
const PlanEffectPage({super.key, required this.images});
@override
ConsumerState<PlanEffectPage> createState() => _PlanEffectPageState();
}
class _PlanEffectPageState extends ConsumerState<PlanEffectPage> {
String? _selected; // 当前角度,null = 展示全部
@override
Widget build(BuildContext context) {
final done = widget.images.where((i) => i.done).toList();
final pending = widget.images.where((i) => !i.done).toList();
final angles = widget.images.map((i) => i.angle).toSet();
final ads = ref.watch(adsServiceProvider);
return Scaffold(
appBar: AppBar(title: const Text('效果图')),
body: Column(
children: [
if (angles.length > 1)
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: const Text('全部'),
selected: _selected == null,
onSelected: (_) => setState(() => _selected = null),
),
),
for (final a in angles)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(_angleLabels[a] ?? a),
selected: _selected == a,
onSelected: (_) => setState(() => _selected = a),
),
),
],
),
),
Expanded(
child: done.isEmpty
? (pending.isEmpty
? const EmptyView(
message: '暂无效果图,选定主方案后自动生成(每日限 3 次)')
: const LoadingView(text: '效果图生成中,请稍后刷新...'))
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: _selected == null
? done.length
: done.where((i) => i.angle == _selected).length,
itemBuilder: (ctx, idx) {
final shown = _selected == null
? done
: done.where((i) => i.angle == _selected).toList();
final img = shown[idx];
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Image.network(
AppConfig.resolveUrl(img.url),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(_angleLabels[img.angle] ?? img.angle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold)),
),
],
),
);
},
),
),
// 信息流广告位:广告未开通(pangleAppId 为空)不渲染
if (ads.enabled) ads.showBanner(context),
],
),
);
}
}
@@ -1,309 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../features/cps/cps_provider.dart';
import '../../shared/widgets/avatar_viewer.dart';
import '../../shared/widgets/loading_view.dart';
import '../profile/avatar_provider.dart';
import 'outfit_provider.dart';
/// 方案流:上部 3D 化身展示 + 下部方案左右滑动切换,清单含商业化入口(到店试穿/买同款)
class PlanViewerPage extends ConsumerWidget {
const PlanViewerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final plans = ref.watch(outfitPlanProvider);
return Scaffold(
appBar: AppBar(title: const Text('穿搭方案')),
body: Column(
children: [
_buildAvatarBar(context, ref),
Expanded(
child: plans.when(
loading: () => const LoadingView(text: '加载方案...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (list) {
if (list.isEmpty) {
return const Center(child: Text('暂无方案'));
}
return PageView.builder(
itemCount: list.length,
itemBuilder: (ctx, i) => _PlanDetailView(
planId: list[i].id, key: ValueKey(list[i].id)),
);
},
),
),
],
),
);
}
/// 上部:当前用户的 3D 化身展示(帧轮播,未构建时占位)
Widget _buildAvatarBar(BuildContext context, WidgetRef ref) {
final avatar = ref.watch(avatarProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: avatar.when(
loading: () => const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => const SizedBox.shrink(),
data: (a) => AvatarViewer(
framesUrl: a.framesUrl,
title: '我的 3D 化身',
subtitle: a.built ? '' : '尚未构建,可在「我的」页完成照片与身形后生成',
height: 200,
),
),
);
}
}
class _PlanDetailView extends ConsumerWidget {
final int planId;
const _PlanDetailView({required this.planId, super.key});
/// 商业化入口:推荐为空时也跳转(source/品类为空 → 列表页展示空态)
void _openCpsList(
BuildContext context, String title, String scene, List<CpsProductInfo> rec) {
final first = rec.isEmpty ? null : rec.first;
context.push('/cps-product-list', extra: CpsListArgs(
title: title,
source: first?.source ?? '',
categoryCode: first?.categoryCode ?? '',
city: first?.city ?? '',
scene: scene,
));
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final detail = ref.watch(planDetailProvider(planId));
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(16),
child: detail.when(
loading: () => const LoadingView(text: '加载方案详情...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (d) {
final plan = d.plan;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(plan.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold)),
),
if (plan.isMain)
const Chip(
label: Text('主方案'),
labelStyle:
TextStyle(color: Colors.white, fontSize: 11),
backgroundColor: Colors.indigo,
visualDensity: VisualDensity.compact,
),
if (!plan.isMain)
Chip(
label: Text(plan.fromAi ? 'AI 生成' : '规则生成'),
labelStyle: TextStyle(
color: scheme.primary, fontSize: 11),
backgroundColor: scheme.primaryContainer,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 4),
Text('评分 ${plan.score}',
style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 12),
// 穿搭清单:标题行 + 场景延伸优惠入口
Row(
children: [
Text('穿搭清单',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: scheme.primary)),
const Spacer(),
if (plan.occasion.isNotEmpty)
OutlinedButton.icon(
onPressed: () => _openCpsList(context,
'${plan.occasion}延伸优惠', 'occasion', const []),
icon: const Icon(Icons.local_offer_outlined,
size: 16),
label: const Text('延伸优惠'),
),
],
),
const SizedBox(height: 8),
// 发型作为清单首项(含"做同款发型"入口)
if (d.hairstyleName.isNotEmpty || plan.hairColor.isNotEmpty)
_HairstyleEntryCard(
planId: plan.id,
hairstyleName: d.hairstyleName.isNotEmpty
? d.hairstyleName
: '默认',
hairColor: plan.hairColor,
onOpen: (title, scene, rec) =>
_openCpsList(context, title, scene, rec),
),
if (d.items.isEmpty)
const Text('暂无穿搭单品',
style: TextStyle(color: Colors.grey)),
for (final item in d.items)
_ItemEntryCard(
planId: plan.id,
item: item,
onOpen: (title, scene, rec) =>
_openCpsList(context, title, scene, rec),
),
],
),
),
),
],
);
},
),
);
}
}
/// 穿搭清单项:发型(含"做同款发型"入口,推荐为空也跳转)
class _HairstyleEntryCard extends ConsumerWidget {
final int planId;
final String hairstyleName;
final String hairColor;
final void Function(String title, String scene, List<CpsProductInfo> rec)
onOpen;
const _HairstyleEntryCard({
required this.planId,
required this.hairstyleName,
required this.hairColor,
required this.onOpen,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
final rec = ref
.watch(cpsRecommendProvider((planId: planId, scene: 'haircut')))
.value;
return Card(
color: scheme.primaryContainer.withValues(alpha: 0.4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
dense: true,
leading: const Icon(Icons.content_cut, size: 20),
title: Text(
'发型:$hairstyleName${hairColor.isNotEmpty ? ' · 发色:$hairColor' : ''}',
style: const TextStyle(fontSize: 13),
),
),
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => onOpen('做同款发型', 'haircut', rec ?? []),
icon: const Icon(Icons.storefront_outlined, size: 16),
label: const Text('做同款发型'),
),
),
],
),
);
}
}
/// 穿搭清单项:衣橱已有单品无入口;AI 推荐新品 → 去购买(推荐为空也跳转)
class _ItemEntryCard extends ConsumerWidget {
final int planId;
final PlanItemInfo item;
final void Function(String title, String scene, List<CpsProductInfo> rec)
onOpen;
const _ItemEntryCard({
required this.planId,
required this.item,
required this.onOpen,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scene = item.fromWardrobe ? 'item_upgrade' : 'item_buy';
final rec = ref
.watch(cpsRecommendProvider((planId: planId, scene: scene)))
.value;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ListTile(
dense: true,
leading: Icon(_slotIcon(item.slot)),
title: Text(item.name),
subtitle: Text(item.desc),
trailing: item.fromWardrobe
? const Chip(
label: Text('衣橱'),
labelStyle: TextStyle(
color: Colors.white, fontSize: 11),
backgroundColor: Colors.teal,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
)
: const Chip(
label: Text('新品'),
labelStyle: TextStyle(
color: Colors.white, fontSize: 11),
backgroundColor: Colors.orange,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
),
// 衣橱已有单品无需入口;仅 AI 推荐新品展示购买入口
if (!item.fromWardrobe)
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => onOpen('去购买', scene, rec ?? []),
icon: const Icon(Icons.shopping_cart_outlined, size: 16),
label: const Text('去购买'),
),
),
],
),
);
}
IconData _slotIcon(String slot) {
switch (slot) {
case '上衣':
return Icons.checkroom;
case '下装':
return Icons.airline_seat_legroom_normal;
case '':
return Icons.directions_walk;
case '配饰':
return Icons.watch_outlined;
default:
return Icons.style_outlined;
}
}
}
@@ -1,545 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../shared/app_toast.dart';
import '../../shared/widgets/loading_view.dart';
import 'avatar_provider.dart';
import 'body_provider.dart';
import 'photo_upload_provider.dart';
/// 肤色选项(1-5,与后端 SkinTone 对应)
const skinToneColors = <int, Color>{
1: Color(0xFFF6E3D4),
2: Color(0xFFEAC9A8),
3: Color(0xFFD9A87C),
4: Color(0xFFB97E55),
5: Color(0xFF8D5B38),
};
/// 我的形象三步流程:① 拍摄三视角全身照 → ② 填写身形参数 → ③ 生成 3D 化身
class AvatarBuildFlowPage extends ConsumerStatefulWidget {
const AvatarBuildFlowPage({super.key});
@override
ConsumerState<AvatarBuildFlowPage> createState() =>
_AvatarBuildFlowPageState();
}
class _AvatarBuildFlowPageState extends ConsumerState<AvatarBuildFlowPage> {
final _picker = ImagePicker();
int _step = 1;
int? _uploadingType;
bool _busy = false;
String? _buildError;
int _height = 170;
int _weight = 60;
int _skinTone = 3;
int _bust = 88;
int _waist = 70;
int _hip = 92;
int _shoulder = 42;
@override
void initState() {
super.initState();
final cur = ref.read(bodyProvider).value;
if (cur != null) {
_height = cur.height;
_weight = cur.weight;
_skinTone = cur.skinTone;
_bust = cur.bust;
_waist = cur.waist;
_hip = cur.hip;
_shoulder = cur.shoulder;
}
}
Future<void> _pickAndUpload(int type) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('拍照'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
],
),
),
);
if (source == null || !mounted) return;
final file = await _picker.pickImage(
source: source, maxWidth: 2048, imageQuality: 85);
if (file == null) return;
setState(() => _uploadingType = type);
try {
await ref.read(photoProvider.notifier).upload(type, file.path);
if (!mounted) return;
showToast('${PhotoType.labels[type]}上传成功');
} catch (e) {
if (!mounted) return;
showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}');
} finally {
if (mounted) setState(() => _uploadingType = null);
}
}
Future<void> _saveAndBuild() async {
setState(() {
_busy = true;
_buildError = null;
});
try {
await ref
.read(bodyProvider.notifier)
.save(_height, _weight, _skinTone, _bust, _waist, _hip, _shoulder);
await ref.read(avatarProvider.notifier).buildAvatar();
if (!mounted) return;
final a = ref.read(avatarProvider).value;
if (a?.built == true) {
showToast('3D 化身已生成');
context.go('/avatar-viewer');
} else if (a?.buildStatus == 'failed') {
setState(() => _buildError = a!.error);
} else {
showToast('构建超时,请稍后重试');
}
} catch (e) {
if (mounted) {
setState(
() => _buildError = e.toString().replaceFirst('Exception: ', ''));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final built = ref.watch(avatarProvider).value?.built ?? false;
return Scaffold(
appBar: AppBar(
title: const Text('我的形象'),
actions: [
if (built)
IconButton(
tooltip: '查看已生成的 3D 化身',
icon: const Icon(Icons.view_in_ar),
onPressed: () => context.go('/avatar-viewer'),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: _StepIndicator(
current: _step,
onTap: (i) => setState(() => _step = i)),
),
Expanded(
child: switch (_step) {
1 => _buildStep1(),
2 => _buildStep2(),
_ => _buildStep3(),
},
),
],
),
);
}
// ---- 步 1:拍摄三视角全身照 ----
Widget _buildStep1() {
final photos = ref.watch(photoProvider);
return photos.when(
loading: () => const LoadingView(text: '加载照片状态...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (list) {
final allDone =
PhotoType.all.every((t) => list.any((p) => p.type == t));
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('拍摄 3 张全身照(正面/侧面/背面),用于构建你的 3D 形象',
style: TextStyle(color: Colors.grey, fontSize: 13)),
const SizedBox(height: 12),
for (final type in PhotoType.all) ...[
_PhotoCard(
type: type,
uploaded: list.any((p) => p.type == type),
uploading: _uploadingType == type,
onTap:
_uploadingType == null ? () => _pickAndUpload(type) : null,
),
const SizedBox(height: 12),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: allDone && _uploadingType == null
? () => setState(() => _step = 2)
: null,
icon: const Icon(Icons.arrow_forward),
label: Text(allDone ? '照片已完成,下一步' : '还需拍摄剩余照片'),
),
],
);
},
);
}
// ---- 步 2:身形参数 ----
Widget _buildStep2() {
final body = ref.watch(bodyProvider);
return body.when(
loading: () => const LoadingView(text: '加载身形参数...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (_) => ListView(
padding: const EdgeInsets.all(16),
children: [
_SliderRow(
label: '身高',
value: '$_height cm',
min: 145,
max: 200,
current: _height.toDouble(),
onChanged: (v) => setState(() => _height = v.round()),
),
_SliderRow(
label: '体重',
value: '$_weight kg',
min: 40,
max: 120,
current: _weight.toDouble(),
onChanged: (v) => setState(() => _weight = v.round()),
),
_SliderRow(
label: '胸围',
value: '$_bust cm',
min: 60,
max: 130,
current: _bust.toDouble(),
onChanged: (v) => setState(() => _bust = v.round()),
),
_SliderRow(
label: '腰围',
value: '$_waist cm',
min: 50,
max: 110,
current: _waist.toDouble(),
onChanged: (v) => setState(() => _waist = v.round()),
),
_SliderRow(
label: '臀围',
value: '$_hip cm',
min: 60,
max: 130,
current: _hip.toDouble(),
onChanged: (v) => setState(() => _hip = v.round()),
),
_SliderRow(
label: '肩宽',
value: '$_shoulder cm',
min: 30,
max: 60,
current: _shoulder.toDouble(),
onChanged: (v) => setState(() => _shoulder = v.round()),
),
const SizedBox(height: 16),
const Text('肤色', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
for (var i = 1; i <= 5; i++)
Expanded(
child: GestureDetector(
onTap: () => setState(() => _skinTone = i),
child: Container(
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: skinToneColors[i],
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: _skinTone == i ? 3 : 1,
color: _skinTone == i
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
),
),
child: _skinTone == i
? const Icon(Icons.check, color: Colors.white)
: null,
),
),
),
],
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () => setState(() => _step = 3),
icon: const Icon(Icons.arrow_forward),
label: const Text('下一步'),
),
],
),
);
}
// ---- 步 3:确认并生成 ----
Widget _buildStep3() {
final photos = ref.watch(photoProvider);
final photoDone = photos.value?.isNotEmpty ?? false;
final doneCount = photoDone
? PhotoType.all
.where((t) =>
photos.value!.any((p) => p.type == t))
.length
: 0;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('形象资料',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Theme.of(context).colorScheme.primary)),
const SizedBox(height: 8),
_InfoLine(
label: '照片',
value: '$doneCount/${PhotoType.all.length} 已上传'),
_InfoLine(
label: '身高',
value: '$_height cm'),
_InfoLine(
label: '体重',
value: '$_weight kg'),
_InfoLine(
label: '胸围/腰围/臀围',
value: '$_bust / $_waist / $_hip cm'),
_InfoLine(label: '肩宽', value: '$_shoulder cm'),
_InfoLine(label: '肤色', value: '$_skinTone'),
],
),
),
),
if (_buildError != null) ...[
const SizedBox(height: 12),
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text('生成失败:$_buildError',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onErrorContainer)),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _busy ? null : _saveAndBuild,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14)),
icon: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_awesome),
label: Text(_busy ? '正在生成 3D 化身...' : '生成我的 3D 化身'),
),
const SizedBox(height: 8),
const Text('生成约需 1-3 分钟,请勿关闭页面;完成后可在查看页观看 3D 形象',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12)),
],
);
}
}
/// 顶部步骤指示器:① 拍摄照片 ② 身形参数 ③ 生成
class _StepIndicator extends StatelessWidget {
final int current;
final ValueChanged<int> onTap;
const _StepIndicator({required this.current, required this.onTap});
@override
Widget build(BuildContext context) {
const titles = ['拍摄照片', '身形参数', '生成'];
return Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0)
const Expanded(
child: Divider(indent: 8, endIndent: 8),
),
InkWell(
onTap: () => onTap(i + 1),
borderRadius: BorderRadius.circular(20),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
children: [
CircleAvatar(
radius: 14,
backgroundColor: i + 1 <= current
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
child: Text('${i + 1}',
style: TextStyle(
fontSize: 13,
color: i + 1 <= current
? Colors.white
: Colors.grey.shade600)),
),
const SizedBox(height: 4),
Text(titles[i],
style: TextStyle(
fontSize: 12,
fontWeight:
i + 1 == current ? FontWeight.bold : null,
color: i + 1 == current
? Theme.of(context).colorScheme.primary
: Colors.grey)),
],
),
),
),
],
],
);
}
}
class _PhotoCard extends StatelessWidget {
final int type;
final bool uploaded;
final bool uploading;
final VoidCallback? onTap;
const _PhotoCard({
required this.type,
required this.uploaded,
required this.uploading,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
onTap: onTap,
leading: CircleAvatar(
backgroundColor:
uploaded ? Colors.green.shade100 : scheme.primaryContainer,
child: uploading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: Icon(uploaded ? Icons.check : Icons.add_a_photo_outlined),
),
title: Text(PhotoType.labels[type]!),
subtitle: Text(PhotoType.descs[type]!),
trailing: uploaded
? const Chip(
label: Text('已上传'),
backgroundColor: Colors.green,
labelStyle: TextStyle(color: Colors.white, fontSize: 12),
)
: null,
),
);
}
}
class _SliderRow extends StatelessWidget {
final String label;
final String value;
final double min;
final double max;
final double current;
final ValueChanged<double> onChanged;
const _SliderRow({
required this.label,
required this.value,
required this.min,
required this.max,
required this.current,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const Spacer(),
Text(value,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold)),
],
),
Slider(
value: current.clamp(min, max),
min: min,
max: max,
divisions: (max - min).round(),
label: value,
onChanged: onChanged,
),
],
);
}
}
class _InfoLine extends StatelessWidget {
final String label;
final String value;
const _InfoLine({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Text(label, style: const TextStyle(fontSize: 13)),
const Spacer(),
Text(value,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
],
),
);
}
}
@@ -1,84 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
class AvatarState {
final int faceTemplateId;
final int bodyTemplateId;
final int skinToneIndex;
final String glbUrl;
final String framesUrl;
final String buildStatus; // pending / processing / done / failed
final String error;
const AvatarState({
this.faceTemplateId = 0,
this.bodyTemplateId = 0,
this.skinToneIndex = 0,
this.glbUrl = '',
this.framesUrl = '',
this.buildStatus = '',
this.error = '',
});
bool get built => buildStatus == 'done' && glbUrl.isNotEmpty;
}
class AvatarNotifier extends AsyncNotifier<AvatarState> {
@override
Future<AvatarState> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/avatar/get');
return _fromData(data);
}
AvatarState _fromData(Map<String, dynamic> data) => AvatarState(
faceTemplateId: (data['face_template_id'] as num?)?.toInt() ?? 0,
bodyTemplateId: (data['body_template_id'] as num?)?.toInt() ?? 0,
skinToneIndex: (data['skin_tone_index'] as num?)?.toInt() ?? 0,
glbUrl: data['glb_url'] as String? ?? '',
framesUrl: data['frames_url'] as String? ?? '',
buildStatus: data['build_status'] as String? ?? '',
error: data['error'] as String? ?? '',
);
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
/// 触发构建;若服务端异步则轮询直到 done/failed
Future<void> buildAvatar() async {
state = const AsyncLoading();
try {
final api = ref.read(apiClientProvider);
final data =
await api.post<Map<String, dynamic>>('/avatar/build', {});
final status = data['status'] as String? ?? '';
if (status == 'pending' || status == 'processing') {
await _poll();
} else {
await refresh();
}
} catch (e) {
state = AsyncError(e, StackTrace.current);
}
}
Future<void> _poll() async {
for (var i = 0; i < 30; i++) {
await Future.delayed(const Duration(seconds: 2));
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/avatar/get');
final status = data['build_status'] as String? ?? '';
if (status == 'done' || status == 'failed') {
state = AsyncData(_fromData(data));
return;
}
}
state = AsyncError(StateError('化身构建超时,请稍后重试'), StackTrace.empty);
}
}
final avatarProvider =
AsyncNotifierProvider<AvatarNotifier, AvatarState>(AvatarNotifier.new);
@@ -1,73 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/app_toast.dart';
import '../../shared/widgets/avatar_viewer.dart';
import '../../shared/widgets/loading_view.dart';
import 'avatar_provider.dart';
/// 3D 化身查看页
class AvatarViewerPage extends ConsumerWidget {
const AvatarViewerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final avatar = ref.watch(avatarProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的 3D 化身')),
body: avatar.when(
loading: () => const LoadingView(text: '加载化身...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (a) => ListView(
padding: const EdgeInsets.all(16),
children: [
AvatarViewer(
glbUrl: a.glbUrl,
framesUrl: a.framesUrl,
title: '我的 3D 化身',
subtitle: a.built
? '脸型模板 ${a.faceTemplateId} · 体型模板 ${a.bodyTemplateId} · 肤色 ${a.skinToneIndex}'
: '尚未构建化身',
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('构建说明',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text(
'化身基于你的三视角全身照(正面/侧面/背面)由 AI 生成 3D 形象,'
'构建过程通常需要 1-3 分钟。',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
await ref
.read(avatarProvider.notifier)
.buildAvatar();
if (context.mounted) {
showToast('化身构建完成');
}
},
icon: const Icon(Icons.refresh),
label: const Text('重新构建化身'),
),
),
],
),
),
),
],
),
),
);
}
}
@@ -1,65 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
class BodyState {
final int height;
final int weight;
final int skinTone;
final int bust;
final int waist;
final int hip;
final int shoulder;
const BodyState({
this.height = 170,
this.weight = 60,
this.skinTone = 3,
this.bust = 88,
this.waist = 70,
this.hip = 92,
this.shoulder = 42,
});
}
class BodyNotifier extends AsyncNotifier<BodyState> {
@override
Future<BodyState> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/body-measurement/get');
return BodyState(
height: (data['height'] as num?)?.toInt() ?? 170,
weight: (data['weight'] as num?)?.toInt() ?? 60,
skinTone: (data['skin_tone'] as num?)?.toInt() ?? 3,
bust: (data['bust'] as num?)?.toInt() ?? 88,
waist: (data['waist'] as num?)?.toInt() ?? 70,
hip: (data['hip'] as num?)?.toInt() ?? 92,
shoulder: (data['shoulder'] as num?)?.toInt() ?? 42,
);
}
Future<void> save(int height, int weight, int skinTone, int bust,
int waist, int hip, int shoulder) async {
final api = ref.read(apiClientProvider);
await api.post('/body-measurement/save', {
'height': height,
'weight': weight,
'skin_tone': skinTone,
'bust': bust,
'waist': waist,
'hip': hip,
'shoulder': shoulder,
});
state = AsyncData(BodyState(
height: height,
weight: weight,
skinTone: skinTone,
bust: bust,
waist: waist,
hip: hip,
shoulder: shoulder));
}
}
final bodyProvider =
AsyncNotifierProvider<BodyNotifier, BodyState>(BodyNotifier.new);
@@ -1,79 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
/// 照片类型(与后端 consts.PhotoType 对应)
class PhotoType {
static const int headshot = 1;
static const int fullFront = 2;
static const int fullSide = 3;
static const int fullBack = 4;
static const Map<int, String> labels = {
headshot: '大头照',
fullFront: '全身正面',
fullSide: '全身侧面',
fullBack: '全身背面',
};
static const Map<int, String> descs = {
headshot: '清晰正脸、光线充足',
fullFront: '站立正面全身,拍全脚底',
fullSide: '站立侧面全身,自然放松',
fullBack: '站立背面全身,露出轮廓',
};
/// 构建 3D 化身所需视角(Tripo 多视角转 3D)
static const List<int> all = [fullFront, fullSide, fullBack];
}
class UserPhotoInfo {
final int id;
final int type;
final String url;
const UserPhotoInfo({
required this.id,
required this.type,
required this.url,
});
}
class PhotoNotifier extends AsyncNotifier<List<UserPhotoInfo>> {
@override
Future<List<UserPhotoInfo>> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/user-photo/list');
final list = data['list'] as List<dynamic>? ?? [];
return list
.map((e) => UserPhotoInfo(
id: (e['id'] as num).toInt(),
type: (e['type'] as num).toInt(),
url: e['url'] as String? ?? '',
))
.toList();
}
Future<void> upload(int type, String filePath) async {
final api = ref.read(apiClientProvider);
await api.upload('/user-photo/upload', {'type': type}, 'file', filePath);
await refresh();
}
Future<void> delete(int id) async {
final api = ref.read(apiClientProvider);
await api.post('/user-photo/delete', {'id': id});
await refresh();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
/// 某类型是否已上传
bool hasType(int type) => state.value?.any((p) => p.type == type) ?? false;
}
final photoProvider =
AsyncNotifierProvider<PhotoNotifier, List<UserPhotoInfo>>(PhotoNotifier.new);
@@ -1,194 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/config/app_config.dart';
import '../../shared/app_toast.dart';
import '../../features/cps/cps_provider.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'wardrobe_provider.dart';
/// 衣橱:服装网格 + 长按删除 + 上传入口
class WardrobePage extends ConsumerStatefulWidget {
const WardrobePage({super.key});
@override
ConsumerState<WardrobePage> createState() => _WardrobePageState();
}
class _WardrobePageState extends ConsumerState<WardrobePage> {
String? _filter; // 当前分类筛选,null = 全部
/// 长按菜单:找升级款 / 删除
Future<void> _showItemMenu(WardrobeItemInfo item) async {
final action = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.upgrade_outlined),
title: const Text('找升级款'),
onTap: () => Navigator.pop(ctx, 'upgrade'),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('删除'),
onTap: () => Navigator.pop(ctx, 'delete'),
),
],
),
),
);
if (action == 'upgrade') {
await _openUpgrade(item);
} else if (action == 'delete') {
await _confirmDelete(item);
}
}
/// 升级款推荐:空结果提示后不跳转
Future<void> _openUpgrade(WardrobeItemInfo item) async {
final rec = await ref.read(cpsUpgradeProvider(item.id).future);
if (!mounted) return;
if (rec.isEmpty) {
showToast('暂无升级款');
return;
}
final first = rec.first;
context.push('/cps-product-list', extra: CpsListArgs(
title: '${item.category}升级款',
source: first.source,
categoryCode: first.categoryCode,
city: first.city,
scene: 'wardrobe_upgrade',
));
}
Future<void> _confirmDelete(WardrobeItemInfo item) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除这件服装?'),
content: Text('${item.category}」删除后不可恢复'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除')),
],
),
);
if (ok == true) {
await ref.read(wardrobeProvider.notifier).delete(item.id);
}
}
@override
Widget build(BuildContext context) {
final items = ref.watch(wardrobeProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的衣橱')),
body: Column(
children: [
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
for (final c in [null, ...wardrobeCategories])
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(c ?? '全部'),
selected: _filter == c,
onSelected: (_) => setState(() => _filter = c),
),
),
],
),
),
Expanded(
child: items.when(
loading: () => const LoadingView(text: '加载衣橱...'),
error: (e, _) => ErrorView(
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: () => ref.read(wardrobeProvider.notifier).refresh(),
),
data: (list) {
final shown = _filter == null
? list
: list.where((i) => i.category == _filter).toList();
if (shown.isEmpty) {
return EmptyView(
message: '衣橱还是空的,上传你的服装吧',
actionText: '上传服装',
onAction: () => context.go('/wardrobe-upload'));
}
return GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: shown.length,
itemBuilder: (ctx, i) {
final item = shown[i];
return GestureDetector(
onLongPress: () => _showItemMenu(item),
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: item.photoUrl.isEmpty
? const Icon(Icons.checkroom,
size: 48, color: Colors.grey)
: Image.network(
AppConfig.resolveUrl(item.photoUrl),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
'${item.category}${item.season.isNotEmpty ? ' · ${item.season}' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.bold),
),
),
],
),
),
);
},
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => context.go('/wardrobe-upload'),
icon: const Icon(Icons.add),
label: const Text('上传'),
),
);
}
}
@@ -1,77 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
/// 服装分类(与后端校验一致)
const wardrobeCategories = ['上衣', '下装', '', '配饰'];
const wardrobeSeasons = ['', '', '', '', '四季'];
class WardrobeItemInfo {
final int id;
final String photoUrl;
final String category;
final String season;
final String styleTags;
final String colorInfo;
const WardrobeItemInfo({
required this.id,
required this.photoUrl,
required this.category,
required this.season,
required this.styleTags,
required this.colorInfo,
});
}
class WardrobeNotifier extends AsyncNotifier<List<WardrobeItemInfo>> {
@override
Future<List<WardrobeItemInfo>> build() async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/wardrobe/list');
final list = data['list'] as List<dynamic>? ?? [];
return list
.map((e) => WardrobeItemInfo(
id: (e['id'] as num).toInt(),
photoUrl: e['photo_url'] as String? ?? '',
category: e['category'] as String? ?? '',
season: e['season'] as String? ?? '',
styleTags: e['style_tags'] as String? ?? '',
colorInfo: e['color_info'] as String? ?? '',
))
.toList();
}
Future<void> upload(
String filePath, {
required String category,
String season = '',
String styleTags = '',
String colorInfo = '',
}) async {
final api = ref.read(apiClientProvider);
await api.upload('/wardrobe/upload', {
'category': category,
'season': season,
'style_tags': styleTags,
'color_info': colorInfo,
}, 'file', filePath);
await refresh();
}
Future<void> delete(int id) async {
final api = ref.read(apiClientProvider);
await api.post('/wardrobe/delete', {'id': id});
await refresh();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
}
final wardrobeProvider =
AsyncNotifierProvider<WardrobeNotifier, List<WardrobeItemInfo>>(
WardrobeNotifier.new);
@@ -1,162 +0,0 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../shared/app_toast.dart';
import 'wardrobe_provider.dart';
/// 服装上传:选图 + 分类/季节/风格/颜色信息
class WardrobeUploadPage extends ConsumerStatefulWidget {
const WardrobeUploadPage({super.key});
@override
ConsumerState<WardrobeUploadPage> createState() => _WardrobeUploadPageState();
}
class _WardrobeUploadPageState extends ConsumerState<WardrobeUploadPage> {
final _picker = ImagePicker();
final _styleCtrl = TextEditingController();
final _colorCtrl = TextEditingController();
String? _imagePath;
String? _category;
String? _season;
bool _uploading = false;
@override
void dispose() {
_styleCtrl.dispose();
_colorCtrl.dispose();
super.dispose();
}
Future<void> _pickImage() async {
final file = await _picker.pickImage(
source: ImageSource.gallery, maxWidth: 2048, imageQuality: 85);
if (file == null) return;
setState(() => _imagePath = file.path);
}
Future<void> _submit() async {
if (_imagePath == null) {
showToast('请先选择服装照片');
return;
}
if (_category == null) {
showToast('请选择分类');
return;
}
setState(() => _uploading = true);
try {
await ref.read(wardrobeProvider.notifier).upload(
_imagePath!,
category: _category!,
season: _season ?? '',
styleTags: _styleCtrl.text.trim(),
colorInfo: _colorCtrl.text.trim(),
);
if (!mounted) return;
showToast('上传成功');
context.go('/wardrobe');
} catch (e) {
if (!mounted) return;
showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}');
} finally {
if (mounted) setState(() => _uploading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('上传服装')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
GestureDetector(
onTap: _uploading ? null : _pickImage,
child: Container(
height: 220,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
clipBehavior: Clip.antiAlias,
child: _imagePath == null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_photo_alternate_outlined,
size: 48, color: Colors.grey.shade500),
const SizedBox(height: 8),
const Text('点击选择服装照片',
style: TextStyle(color: Colors.grey)),
],
)
: Image.file(File(_imagePath!), fit: BoxFit.cover),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: _category,
decoration: const InputDecoration(
labelText: '分类', border: OutlineInputBorder()),
items: [
for (final c in wardrobeCategories)
DropdownMenuItem(value: c, child: Text(c)),
],
onChanged: _uploading
? null
: (v) => setState(() => _category = v),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: _season,
decoration: const InputDecoration(
labelText: '适用季节(选填)', border: OutlineInputBorder()),
items: [
for (final s in wardrobeSeasons)
DropdownMenuItem(value: s, child: Text(s)),
],
onChanged:
_uploading ? null : (v) => setState(() => _season = v),
),
const SizedBox(height: 12),
TextField(
controller: _styleCtrl,
enabled: !_uploading,
decoration: const InputDecoration(
labelText: '风格标签(选填,如:通勤、休闲)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _colorCtrl,
enabled: !_uploading,
decoration: const InputDecoration(
labelText: '颜色描述(选填,如:黑色)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _uploading ? null : _submit,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14)),
child: _uploading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Text('上传到衣橱'),
),
],
),
);
}
}
-113
View File
@@ -1,113 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'core/auth/auth_provider.dart';
import 'shared/app_toast.dart';
import 'features/auth/login_page.dart';
import 'features/cps/cps_product_list_page.dart';
import 'features/cps/cps_provider.dart';
import 'features/home/home_page.dart';
import 'features/member/pay_page.dart';
import 'features/outfit/outfit_page.dart';
import 'features/outfit/outfit_provider.dart';
import 'features/outfit/plan_effect_page.dart';
import 'features/outfit/plan_viewer_page.dart';
import 'features/profile/avatar_build_flow_page.dart';
import 'features/profile/avatar_viewer_page.dart';
import 'features/wardrobe/wardrobe_page.dart';
import 'features/wardrobe/wardrobe_upload_page.dart';
void main() {
runApp(const ProviderScope(child: SloganApp()));
}
class SloganApp extends ConsumerWidget {
const SloganApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(routerProvider);
final auth = ref.watch(authProvider);
// 路由守卫:token 失效/登出 → 回登录页;token 有效且在登录页 → 进主框架
final path =
router.routerDelegate.currentConfiguration.uri.path;
final authed = auth.value?.authenticated ?? false;
if (!auth.isLoading) {
if (!authed && path != '/login') {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) router.go('/login');
});
} else if (authed && path == '/login') {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) router.go('/home');
});
}
}
return MaterialApp.router(
title: '我的形象穿搭',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5C6BC0)),
useMaterial3: true,
appBarTheme: const AppBarTheme(centerTitle: true),
),
routerConfig: router,
);
}
}
/// 路由表:启动无 token 进登录页;有 token 进主框架
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(
navigatorKey: appNavigatorKey,
initialLocation: '/login',
routes: [
GoRoute(
path: '/login',
builder: (ctx, state) => const LoginPage(),
),
GoRoute(
path: '/home',
builder: (ctx, state) => const HomePage(),
),
GoRoute(
path: '/avatar-build',
builder: (ctx, state) => const AvatarBuildFlowPage(),
),
GoRoute(
path: '/wardrobe',
builder: (ctx, state) => const WardrobePage(),
),
GoRoute(
path: '/wardrobe-upload',
builder: (ctx, state) => const WardrobeUploadPage(),
),
GoRoute(
path: '/outfit',
builder: (ctx, state) => const OutfitPage(),
),
GoRoute(
path: '/pay',
builder: (context, state) => PayPage(args: state.extra! as PayArgs),
),
GoRoute(
path: '/plan-viewer',
builder: (ctx, state) => const PlanViewerPage(),
),
GoRoute(
path: '/plan-effect',
builder: (ctx, state) =>
PlanEffectPage(images: state.extra as List<PlanEffectImageInfo>),
),
GoRoute(
path: '/avatar-viewer',
builder: (ctx, state) => const AvatarViewerPage(),
),
GoRoute(
path: '/cps-product-list',
builder: (ctx, state) =>
CpsProductListPage(args: state.extra! as CpsListArgs),
),
],
);
});
-60
View File
@@ -1,60 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// 全局 Navigator key,供不依赖页面 context 的 toast 等场景使用
final GlobalKey<NavigatorState> appNavigatorKey = GlobalKey<NavigatorState>();
OverlayEntry? _entry;
Timer? _timer;
/// 屏幕正中央的 toast,展示时长按文字长度分档:≤8 字 2 秒,≤20 字 3 秒,更长 4 秒
void showToast(String msg) {
_timer?.cancel();
_entry?.remove();
_entry = null;
final overlay = appNavigatorKey.currentState?.overlay;
if (overlay == null) return;
final entry = OverlayEntry(
builder: (_) => Positioned.fill(
child: IgnorePointer(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(8),
),
child: Text(
msg,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.4,
),
),
),
),
),
),
);
overlay.insert(entry);
_entry = entry;
_timer = Timer(_durationFor(msg), () {
if (_entry == entry) {
entry.remove();
_entry = null;
}
});
}
Duration _durationFor(String msg) {
final len = msg.length;
if (len <= 8) return const Duration(seconds: 2);
if (len <= 20) return const Duration(seconds: 3);
return const Duration(seconds: 4);
}
-139
View File
@@ -1,139 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
/// 3D 化身查看组件
///
/// 服务端预渲染帧序列(36 帧绕 Y 轴旋转 PNG)轮播模拟 3D;
/// framesUrl 为空时降级为静态占位。
class AvatarViewer extends StatefulWidget {
final String? glbUrl;
final String? framesUrl;
final String title;
final String subtitle;
final double height;
const AvatarViewer({
super.key,
this.glbUrl,
this.framesUrl,
required this.title,
required this.subtitle,
this.height = 360,
});
@override
State<AvatarViewer> createState() => _AvatarViewerState();
}
class _AvatarViewerState extends State<AvatarViewer> {
static const int _frameCount = 36;
Timer? _timer;
int _frame = 0;
@override
void initState() {
super.initState();
if (widget.framesUrl != null && widget.framesUrl!.isNotEmpty) {
_timer = Timer.periodic(const Duration(milliseconds: 120), (_) {
setState(() => _frame = (_frame + 1) % _frameCount);
});
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final framesUrl = widget.framesUrl;
if (framesUrl != null && framesUrl.isNotEmpty) {
final frameUrl = AppConfig.resolveUrl(
'$framesUrl/frame_${_frame.toString().padLeft(3, '0')}.png');
final scheme = Theme.of(context).colorScheme;
return Container(
height: widget.height,
width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
scheme.primaryContainer,
scheme.primary.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(16),
),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
Positioned.fill(
child: Image.network(
frameUrl,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => _placeholder(context),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
widget.subtitle,
style: const TextStyle(
color: Colors.white, fontSize: 12, shadows: [
Shadow(color: Colors.black45, blurRadius: 4),
]),
),
),
],
),
);
}
return _placeholder(context);
}
Widget _placeholder(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
height: widget.height,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
scheme.primaryContainer,
scheme.primary.withValues(alpha: 0.3),
],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.accessibility_new, size: 72, color: scheme.primary),
const SizedBox(height: 12),
Text(widget.title,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
widget.subtitle.isEmpty ? '3D 渲染服务未就绪' : widget.subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
);
}
}
-33
View File
@@ -1,33 +0,0 @@
import 'package:flutter/material.dart';
/// 空态 + 引导动作
class EmptyView extends StatelessWidget {
final String message;
final String? actionText;
final VoidCallback? onAction;
const EmptyView({
super.key,
required this.message,
this.actionText,
this.onAction,
});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(message, style: const TextStyle(color: Colors.grey)),
if (actionText != null && onAction != null) ...[
const SizedBox(height: 16),
FilledButton(onPressed: onAction, child: Text(actionText!)),
],
],
),
);
}
}
-33
View File
@@ -1,33 +0,0 @@
import 'package:flutter/material.dart';
/// 错误态 + 重试
class ErrorView extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const ErrorView({super.key, required this.message, this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(message,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey)),
),
if (onRetry != null) ...[
const SizedBox(height: 16),
OutlinedButton.icon(
onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('重试')),
],
],
),
);
}
}

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