flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
# docs/demos —— Demo 学习文档索引
> 本目录为 `lib/pages/demo/` 下 **29 个独立功能模块**逐一配套的学习文档,
> 面向 Flutter 初学者:讲清**实现用法和思想**(为什么这么写),并标注踩坑记录。
**配套文档**:项目整体架构(含 Riverpod / Isar 新手教程)见 [../exam_architecture.md](../exam_architecture.md),
本目录是"每个功能怎么实现"的细节版,两者配合阅读效果最佳。
---
## 一、分类索引(29 个)
### 🧱 基础布局(13)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| align_demo.dart | 对齐与约束:一行代码把一个组件摆到任意位置 | [align_demo.md](align_demo.md) |
| wrap_demo.dart | 流式布局:小标签自动换行,放不下就换一排 | [wrap_demo.md](wrap_demo.md) |
| stack_demo.dart | 层叠布局:角标、遮罩、悬浮按钮全靠它 | [stack_demo.md](stack_demo.md) |
| grid_demo.dart | 网格布局:GridView 三列宫格(九宫格) | [grid_demo.md](grid_demo.md) |
| list_demo.dart | 长列表:ListView.builder 懒加载 + 下拉刷新 | [list_demo.md](list_demo.md) |
| sliver_demo.dart | 可折叠顶栏:SliverAppBar 收起展开 + 吸顶 | [sliver_demo.md](sliver_demo.md) |
| responsive_demo.dart | 响应式:一个页面同时适配手机/平板/桌面 | [responsive_demo.md](responsive_demo.md) |
| text_demo.dart | 文本组件:样式、行高、溢出省略号 | [text_demo.md](text_demo.md) |
| input_demo.dart | 输入框:TextField 全家桶 + 键盘类型 | [input_demo.md](input_demo.md) |
| form_demo.dart | 表单校验:GlobalKey + validator 规则 | [form_demo.md](form_demo.md) |
| theme_demo.dart | 主题:多品牌换肤、全局样式统一管理 | [theme_demo.md](theme_demo.md) |
| seed_palette_demo.dart | 种子色:ColorScheme.fromSeed 一键生成配色 | [seed_palette_demo.md](seed_palette_demo.md) |
| animation_demo.dart | 动画:隐式/显式/路由转场/Hero/CustomPaint | [animation_demo.md](animation_demo.md) |
### 🌐 网络(3)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| dio_demo.dart | 请求封装:DioClient + ApiException 统一错误 | [dio_demo.md](dio_demo.md) |
| dio_pro_demo.dart | 进阶:拦截器/重试/CancelToken/Web 兼容 | [dio_pro_demo.md](dio_pro_demo.md) |
| oss_upload_demo.dart | 阿里云 OSS 直传:STS 临时凭证 + V4 签名 | [oss_upload_demo.md](oss_upload_demo.md) |
### 💾 存储(2)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| isar_watch_demo.dart | 本地库 + 响应式:Isar 增删改查 + watch 自动刷新 | [isar_watch_demo.md](isar_watch_demo.md) |
| wrong_flow_demo.dart | 完整业务链路:接口 + 本地 + watch 三层协作 | [wrong_flow_demo.md](wrong_flow_demo.md) |
### 🧭 路由(4)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| router_demo.dart | 应用入口:路由表注册 + 首页组装 | [router_demo.md](router_demo.md) |
| router_home_page.dart | 三种跳转姿势:push 传参 / await 返回值 / pushNamed | [router_home_page.md](router_home_page.md) |
| detail_page.dart | 参数接收:构造传参 vs 命名路由 arguments | [detail_page.md](detail_page.md) |
| picker_page.dart | 选择器页:pop 带值返回(选性别) | [picker_page.md](picker_page.md) |
### 🧩 组件封装(6)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| note_picker_demo.dart | 多图选择页:加号格 + 缩略图 + 删除角标 | [note_picker_demo.md](note_picker_demo.md) |
| image_demo.dart | 图片四来源 + BoxFit + 错误兜底 | [image_demo.md](image_demo.md) |
| audio_player_demo.dart | 音频控制条组件(HTTP Range 验收) | [audio_player_demo.md](audio_player_demo.md) |
| rich_text_demo.dart | HTML 富文本渲染 + 字号/深色注入 | [rich_text_demo.md](rich_text_demo.md) |
| smooth_sheet_demo.dart | 常驻多停靠底部面板(非模态) | [smooth_sheet_demo.md](smooth_sheet_demo.md) |
| share_demo.dart | 分享五场景:文本/图片/海报截图/存相册 | [share_demo.md](share_demo.md) |
### 🎓 考试(1)
| Demo 文件 | 一句话 | 文档 |
|---|---|---|
| exam_demo.dart | 试卷练习入口:大数据滑动验证(关联主架构) | [exam_demo.md](exam_demo.md) |
---
## 二、推荐学习路径(按顺序)
```
阶段 1 布局基础(约 1 周)
align → wrap → stack → grid → list → sliver → responsive
阶段 2 文本与输入
text → input → form
阶段 3 主题与动画
seed_palette → theme → animation
阶段 4 路由导航
router → router_home_page → detail_page → picker_page
阶段 5 数据层
isar_watch → wrong_flow
阶段 6 网络层
dio → dio_pro → oss_upload
阶段 7 组件封装(进阶)
image → audio_player → rich_text → smooth_sheet → note_picker → share
阶段 8 综合实战
exam_demo → ../exam_architecture.md(完整架构)
```
> 建议:前 4 个阶段每天 1~2 个 demo,把每份文档"关键代码剖析"手敲一遍;
> 阶段 5 之后开始读 [../exam_architecture.md](../exam_architecture.md)(Riverpod/Isar 前置知识)。
---
## 三、文档公约
每份文档统一六章结构,方便快速定位:
| 章节 | 内容 |
|---|---|
| 一、这个 Demo 解决什么问题 | 业务场景 + 对照 Vue 类比 |
| 二、怎么运行 / 演示内容 | runApp 命令 + 演示点 |
| 三、核心知识点(新手向) | 速查表 + 代码块 + 为什么这么写 |
| 四、关键代码剖析 | 核心代码逐行解释 |
| 五、踩坑记录 | 编号列表,每条 = 一个真实教训 |
| 六、扩展思考 | 关联其他文档 + 真实项目落地建议 |
文档之间用相对链接互引(如 `[list_demo.md](list_demo.md)`),GitHub / IDE 均可直接跳转。
+130
View File
@@ -0,0 +1,130 @@
# 【Demo】Align 对齐布局 + Riverpod 全局用户信息
> 源文件:`lib/pages/demo/align_demo.dart` | 分类:基础布局
> 一句话:把子组件放到父容器任意位置,顺便演示本项目最小化的 Riverpod 状态读取。
---
## 一、这个 Demo 解决什么问题
新手写 UI 时常遇到一个需求:**"把某个卡片放到盒子的左下角 / 右上角 / 居中"**。
`Align` 就是 Flutter 里的"定位器"——给定一个区域,把子组件按 `alignment` 摆到指定位置。
本 Demo 额外演示了 **Riverpod 全局用户信息**的最小用法:
- 页面改成 `ConsumerWidget`,用 `ref.watch(currentUserProvider)` 读全局用户;
- 登录后任何页面都能拿到用户(用户名 / 班级 / 积分),且数据变化时卡片**自动刷新**。
> 对照 Vue3:`Align` 类似 `position: absolute` + 容器相对定位;
> `ref.watch` 类似 Pinia 的响应式 state —— 一处修改,处处生效。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const ProviderScope(
child: MaterialApp(home: AlignDemoPage()),
));
```
页面主体是一个灰色 300×300 盒子,左下角悬浮一张蓝色"用户信息卡"。
登录后卡片内容会跟着变(未登录显示"未登录")。
---
## 三、核心知识点(新手向)
### 1. Flutter 布局的两条铁律
- **父组件给子组件传"约束"(Constraints),子组件在约束范围内决定自己的"大小"**;
- 所有布局问题(溢出、撑不开)都可以回到这条规则上找原因。
### 2. Align 的工作方式
1. Align 会把自身区域**扩大到父组件允许的最大范围**(默认撑满);
2. 然后按 `alignment` 把子组件放到指定位置;
3. 子组件如果不写宽高,Align 取它的**固有尺寸**;写了就按写的来。
```dart
Align(
alignment: Alignment.bottomLeft, // 子组件放到父盒子左下角
child: Container(...), // 用户信息卡片
)
```
### 3. Alignment 坐标系
`Alignment(x, y)` 的 x、y 范围都是 **-1.0 ~ 1.0**:
| 值 | 位置 |
|---|---|
| (-1, -1) | 左上 |
| (0, 0) | 居中 |
| (1, 1) | 右下 |
| `Alignment.centerLeft` | 左中 |
常用常量:`topLeft / topCenter / topRight / centerLeft / center / centerRight / bottomLeft / bottomCenter / bottomRight`。
### 4. Riverpod 最小用法(本项目统一模式)
```dart
class AlignDemoPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider).value; // 订阅全局用户
// user 为 null = 未登录;登录了就是 CurrentUser 对象
}
}
```
- **`ref.watch` 必须在 `build` 里调用**(声明式依赖,状态变了自动重建);
- **`ref.read` 只能放回调里**(一次性读取,不建立依赖)。
---
## 四、关键代码剖析
```dart
body: Container(
width: 300.w,
height: 300.h,
color: Colors.grey[300],
child: Align(
alignment: Alignment.bottomLeft,
child: Container(
width: 240.w, // 写了宽高 → 由子组件自己决定
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 10.h),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(8.w),
),
child: Column(
mainAxisSize: MainAxisSize.min, // 卡片高度跟随内容
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('用户:${user?.userName ?? '未登录'}'),
Text('班级:${user?.className ?? '-'}'),
Text('积分:${user?.userPoints ?? 0}'),
],
),
),
),
),
```
要点:
- `mainAxisSize: MainAxisSize.min` —— Column 不占满父级,高度跟随内容(否则卡片会撑满整个 Align 区域);
- `user?.xxx ?? 默认值` —— 空安全写法:用户为空时显示占位。
---
## 五、踩坑记录
1. **用了 `ref` 的组件必须继承 `ConsumerWidget` / `ConsumerStatefulWidget`**,普通 `StatelessWidget` 拿不到 `ref`(编译报错)。
2. **`ref.watch` 放 build 里,`ref.read` 放回调里**,反了会导致不刷新或性能问题。
3. **加了 `.w/.h/.sp` 的 widget 不能再用 `const`** —— 尺寸是运行期算的,`const` 会编译报错(`const_eval_extension_method`)。
4. `Align` 的 `alignment` 只对**没被 `Positioned` 包裹**的子组件生效(`Positioned` 是 Stack 的专属,见 [stack_demo.md](stack_demo.md))。
---
## 六、扩展思考
- **`Center` 就是 `Align(alignment: Alignment.center)` 的别名**,记住 Align 就记住了 Center;
- 同族定位组件:`Padding`(内边距)、`FractionallySizedBox`(按比例尺寸)、`SizedBox`(固定尺寸);
- 想"按百分比偏移"用 `Alignment(x, y)`;想"按像素偏移"把 Align 换成 `Padding` 或包一层 `Transform.translate`;
- Riverpod 进阶:`ref.watch(provider).value` 是 `AsyncValue` 的取值方式,三态(loading/error/data)用法见 [exam_demo.md](exam_demo.md) 与 `docs/exam_architecture.md` 的"零、新手前置"章节。
+177
View File
@@ -0,0 +1,177 @@
# 【Demo】动画体系(隐式 / 显式 / Hero / CustomPaint)
> 源文件:`lib/pages/demo/animation_demo.dart` | 分类:基础布局
> 一句话:对 CSS 学 Flutter 动画 —— 隐式=transition,显式=keyframes,加上 Hero 转场和 Canvas 自绘。
---
## 一、这个 Demo 解决什么问题
新手面对 Flutter 动画容易懵:组件太多、概念太多。本页把动画体系按"对 CSS"的方式梳理成 4 块:
| 动画类型 | 对标 CSS | 一句话 |
|---|---|---|
| ① 隐式动画 | transition | 属性变了自己补过渡帧,零 controller |
| ② 显式动画 | keyframes | controller 手动驱动,精确控制时序/曲线 |
| ③ Hero 转场 | 无(原生独有) | 页面跳转时元素"飞过去" |
| ④ CustomPaint | canvas | 用 Canvas 画任意图形 |
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: AnimationDemoPage()));
```
1. 点方块:宽/圆角/颜色三属性同时变(AnimatedContainer);
2. 答题互动:选对"弹跳放大",选错"水平抖动"(TweenSequence 分段动画);
3. 点封面:共享元素飞到详情页(Hero);
4. 拖滑块:进度环实时重绘(CustomPainter drawArc)。
---
## 三、核心知识点(新手向)
### 1. 隐式 vs 显式(判断口诀)
- 只需要"A 变成 B 的过渡" → **隐式**(写一行 `duration` 完事);
- 需要"按我的节奏动起来"(回弹、抖动、连续) → **显式 controller**。
```dart
// 隐式:属性变了自动补过渡(duration 必写!漏了就瞬变)
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _boxExpanded ? 200.w : 120.w,
decoration: BoxDecoration(
color: _boxExpanded ? Color(0xFF3B82F6) : Color(0xFFFF9800),
borderRadius: BorderRadius.circular(_boxExpanded ? 45.r : 8.r),
),
)
```
### 2. 显式动画五件套
```dart
// ① 页面必须 with SingleTickerProviderStateMixin
class _State extends State<Page> with SingleTickerProviderStateMixin {
// ② controller:驱动一切(vsync 必传,duration 定义时长)
late final AnimationController _answerCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
// ③ TweenSequence:分段动画(先放大再回弹 = 两个 Tween 按权重拼)
late final Animation<double> _correctScale = TweenSequence<double>([
TweenSequenceItem(
tween: Tween(begin: 1.0, end: 1.25).chain(CurveTween(curve: Curves.easeOut)),
weight: 40,
),
TweenSequenceItem(
tween: Tween(begin: 1.25, end: 1.0).chain(CurveTween(curve: Curves.bounceOut)),
weight: 60,
),
]).animate(_answerCtrl);
// ④ 触发:每次从 0 重新播放
_answerCtrl.forward(from: 0);
// ⑤ 红线:必须 dispose
@override
void dispose() {
_answerCtrl.dispose();
super.dispose();
}
}
```
### 3. AnimatedBuilder 的 child 缓存
```dart
AnimatedBuilder(
animation: _correctScale,
child: body, // 不变的子树用 child 传进来缓存,避免 builder 反复重建
builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
)
```
### 4. Hero 共享元素
```dart
// 页面 A
Hero(tag: 'hero-demo-cover', child: Container(width: 120.w, height: 72.h, ...));
// 页面 B(同一个 tag!)
Hero(tag: 'hero-demo-cover', child: Container(width: 280.w, height: 168.h, ...));
```
两个页面里 tag 相同的 Hero,转场时自动共享动画(封面从小图"飞"成大图)。
### 5. CustomPainter 自绘进度环
```dart
class _ProgressRingPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final rect = (Offset.zero & size).deflate(stroke / 2); // 内缩一个线宽
// 背景环(画满 360°)
canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159, false, bgPaint);
// 前景环:起点 -90° = 12 点方向,顺时针画 progress 比例
canvas.drawArc(rect, -0.5 * 3.14159, 2 * 3.14159 * progress, false, fgPaint);
}
@override
bool shouldRepaint(_ProgressRingPainter oldDelegate) {
// 参数变了才重绘(滑块拖动时)
return oldDelegate.progress != progress || oldDelegate.color != color;
}
}
```
要点:`drawArc` 的角度单位是弧度,`-π/2` 才是 12 点方向(画布 0° 在 3 点方向)。
---
## 四、关键代码剖析
### 抖动动画(4 段 Tween 拼出"抖三下")
```dart
late final Animation<double> _wrongShake = TweenSequence<double>([
TweenSequenceItem(tween: Tween(begin: 0.0, end: -8.0).chain(CurveTween(curve: Curves.easeOut)), weight: 20),
TweenSequenceItem(tween: Tween(begin: -8.0, end: 8.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: 8.0, end: -6.0), weight: 30),
TweenSequenceItem(tween: Tween(begin: -6.0, end: 0.0), weight: 20),
]).animate(_answerCtrl);
```
### 对错反馈的应用(按选择状态包 Transform)
```dart
if (isRight) {
body = AnimatedBuilder(
animation: _correctScale,
child: body,
builder: (context, child) => Transform.scale(scale: _correctScale.value, child: child),
);
}
if (isWrong) {
body = AnimatedBuilder(
animation: _wrongShake,
child: body,
builder: (context, child) =>
Transform.translate(offset: Offset(_wrongShake.value, 0), child: child),
);
}
```
---
## 五、踩坑记录
1. **AnimationController 必须 dispose**,且页面必须 `with SingleTickerProviderStateMixin`(否则报 Ticker 错误);
2. **隐式动画一定要写 `duration`**,漏了就瞬变没有过渡;
3. **AnimatedBuilder 的 builder 里别放不变子树**,用 `child` 参数缓存(否则每帧重建,性能浪费);
4. **shouldRepaint 要正确比较参数**:不重绘或一直重绘都是错;
5. 多个 controller 用 `TickerProviderStateMixin`(多 Ticker),单个用 Single 版;
6. 同屏多个 FloatingActionButton 要 `heroTag: null`(见 list_demo),Hero tag 全局必须唯一,重了会断言崩溃。
---
## 六、扩展思考
- 本项目 exam 答题页的"选对弹跳/选错抖动"就是本页思路的真实落地;
- 显式动画进阶:`AnimationController.repeat()` 无限循环(loading 旋转)、`addStatusListener` 监听完成回调;
- 隐式动画家族:AnimatedOpacity / AnimatedSwitcher(切换动画)/ AnimatedPositioned / AnimatedAlign —— 都是"属性变了自动补帧";
- 性能纪律:动画组件尽量用 Transform(不触发 layout),别动 width/height 频繁重建。
+118
View File
@@ -0,0 +1,118 @@
# 【Demo】音频播放(AudioPlayerCard 控制条组件)
> 源文件:`lib/pages/demo/audio_player_demo.dart` | 分类:组件封装
> 一句话:传一个音频地址,得到一个完整控制条 —— 播放/暂停、进度拖动、倍速、循环。
---
## 一、这个 Demo 解决什么问题
课程详情页里常有"音频小节"。本页演示 `AudioPlayerCard`(`lib/core/components/audio_player_card.dart`)的用法:
```dart
const AudioPlayerCard(
url: _audioUrl, // 网络源(也可以传 asset 本地源)
title: '网络测试音频(约 6 分钟)',
autoPlay: true, // 进页自动播放
)
```
核心思想:**AudioPlayerCard 只是"控制条",不是整页** —— 它设计成可嵌进任意列表/详情页(列表项、课程小节卡片都能放)。
> 对照 Vue:相当于"封装好的 `<audio-player url="...">` 组件",内部用 just_audio 插件实现。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: AudioPlayerDemoPage()));
```
- 进页自动播放(网络流约 6 分钟);
- 体验:播放/暂停、拖动进度(网络流拖动后出现"缓冲中 X%")、倍速切换、循环;
- 切后台自动暂停(组件内部处理,页面无需操心)。
> 代码里注释掉的本地源 `assets/audio/sample-30s.mp3`:30 秒本地音频,seek 秒响应(本地文件跳播无需重新缓冲)。
---
## 三、核心知识点(新手向)
### 1. 网络音频能否"拖动进度",取决于服务器是否支持 HTTP Range
音频拖到中间播放,播放器会向服务器请求**字节范围**(Range 请求)。支持则秒跳,不支持则跳不了:
```
curl -s -D - -o NUL -H "Range: bytes=0-1023" <音频url>
```
| 响应头 | 含义 |
|---|---|
| `HTTP/1.1 206 Partial Content` | ✅ 支持,可拖动 |
| `HTTP/1.1 200 OK` | ❌ 不支持,seek 会被重置回起点 |
> 本项目踩过的坑:samplelib.com 实测**不支持 Range**(返回 200),点进度条后位置被重置回起点;
> 而且第二次打开有系统缓存 → 看似正常(会误导排查方向!)。现用 SoundHelix(curl 实测 206)。
> **接真实课程音频前,务必先 curl 验收服务器的 Range 支持。**
### 2. 网络流 seek 后的"缓冲中 X%"
进度条上的**浅色区域 = 已缓冲部分**;网络流 seek 后会有一次"缓冲中 X%"——这是 ExoPlayer 的正常机制,不是卡死。
本地文件没有这个问题,所以本地 demo 拖动"秒响应"。
### 3. 切后台自动暂停
组件内部监听应用生命周期(AppLifecycleListener),切后台自动暂停——用户听一半切走,回来继续,不用业务页管。
---
## 四、关键代码剖析
```dart
class AudioPlayerDemoPage extends StatelessWidget {
const AudioPlayerDemoPage({super.key});
// 【临时调试】网络源:观察 seek 后进度条的"缓冲区域 + 缓冲中 X%"
static const String _audioUrl =
'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3';
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('音频播放'), centerTitle: true),
body: Padding(
padding: EdgeInsets.all(16.w),
child: Column(
children: [
const AudioPlayerCard( // ← 一行引入,全部能力由组件提供
url: _audioUrl,
title: '网络测试音频(约 6 分钟)',
autoPlay: true,
),
...
],
),
),
);
}
}
```
页面本身是 `StatelessWidget`——**音频状态全部在组件内部**(状态封装是组件化的精髓)。
---
## 五、踩坑记录
1. **服务器不支持 HTTP Range 时 seek 会被重置**,且第二次打开有系统缓存会"假装正常"——验收必须用 curl 看 `206`;
2. AudioPlayerCard 是**控制条组件,不是整页**——别拿它当页面用,它设计来嵌进列表/详情;
3. 网络流 seek 后有"缓冲中 X%"是正常机制(ExoPlayer),浅色区域 = 已缓冲;
4. 本地 asset 源与网络 url 源二选一传参(`asset:` 或 `url:`),别两个都传;
5. 切后台自动暂停是组件内部处理的,业务页不要重复实现。
---
## 六、扩展思考
- 组件内部用 just_audio 插件:想了解播放器原理(音源切换、事件流)直接读 `lib/core/components/audio_player_card.dart`;
- 富文本里的 `<audio>` 标签会自动替换成 AudioPlayerCard,见 [rich_text_demo.md](rich_text_demo.md);
- 进阶"列表连续播放":在 AudioPlayerCard 上加 `setAudioSources` + 上一首/下一首(just_audio 原生支持);
- 真实项目:把 `_audioUrl` 换成课程接口返回的音频地址即可,组件层不用动。
+86
View File
@@ -0,0 +1,86 @@
# 【Demo】详情页:参数接收的两种通道
> 源文件:`lib/pages/demo/detail_page.dart` | 分类:路由
> 一句话:构造参数与 ModalRoute arguments 双通道 —— 谁来了用谁,缺谁兜底。
---
## 一、这个 Demo 解决什么问题
同一个详情页,可能被两种方式打开:
- **方式 1**:`push` 构造传参(`DetailPage(name: '张三', age: 18)`);
- **方式 3**:`pushNamed` 命名路由 + arguments(`{'name': '李四', 'age': 25}`)。
页面要**同时兼容两种入口**:优先用 arguments 里的值,没有就用构造参数兜底。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp());
```
首页分别用方式 1 / 方式 3 跳进来,观察两种取参路径的效果。
---
## 三、核心知识点(新手向)
### 1. 构造参数(类型安全)
```dart
class DetailPage extends StatelessWidget {
const DetailPage({super.key, this.name = '默认', this.age = 0});
final String name;
final int age;
...
}
```
- 带默认值 → 不传参也能打开,不会崩;
- 类型安全:传错类型编译期报错。
### 2. 命名路由 arguments(运行期 Map)
```dart
final args = ModalRoute.of(context)?.settings.arguments;
final argMap = args is Map<String, dynamic> ? args : null;
```
- `ModalRoute.of(context)` 拿到当前路由 → `.settings.arguments` 取出 pushNamed 传的参数;
- arguments 是 dynamic,**必须判空强转**:
- 不是 Map(或没传)→ `argMap` 为 null;
- `argMap?['name']` 取不到时用 `?? name` 回退构造参数。
### 3. 双通道合并取值(本页精髓)
```dart
Text('姓名:${argMap?['name'] ?? name}'),
Text('年龄:${argMap?['age'] ?? age}'),
```
"arguments 优先,构造参数兜底"——两种入口都兼容,缺参不崩。
---
## 四、关键代码剖析
```dart
// 手动 pop(AppBar 的返回箭头也是 pop,效果一样)
OutlinedButton(
onPressed: () => Navigator.pop(context),
child: const Text('返回上一页'),
)
```
---
## 五、踩坑记录
1. `arguments` 是 dynamic,**直接用会崩**(`args['name']` 对非 Map 类型运行期报错)——先 `is Map` 判断;
2. `ModalRoute.of(context)?.settings.arguments` 有 `?`,路由可能为 null;
3. 构造参数给默认值,保证"零参数也能打开页面"(debug 直达页面时很有用);
4. 命名路由注册在 [router_demo.md](router_demo.md) 的 routes 表里。
---
## 六、扩展思考
- 选择器页 pop 带值见 [picker_page.md](picker_page.md);
- 参数多了建议封装成强类型对象(`class DetailArgs`),避免 Map 满天飞;
- 真实项目(go_router)用 `GoRoute` 的 `arguments` 或 `extra` 传对象,原理一致。
+129
View File
@@ -0,0 +1,129 @@
# 【Demo】Dio 网络请求(封装 + 三态 UI)
> 源文件:`lib/pages/demo/dio_demo.dart` | 分类:网络
> 一句话:GET/POST/报错一次跑通,学"统一请求入口 + 加载/成功/失败三态 UI"的页面范式。
---
## 一、这个 Demo 解决什么问题
新手第一次接接口时的完整范式:
1. 请求期间要显示 loading、按钮要禁用(防重复点击);
2. 错误要统一翻译成中文提示(封装层做,页面不碰底层细节);
3. **await 之后 setState 前必须判 mounted**(用户可能已切走页面)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: DioDemoPage()));
```
4 个按钮:
- GET 单条:`PostApi.fetchPost(1)`;
- GET 列表:`PostApi.fetchPosts(userId: 1)`(jsonplaceholder mock);
- POST 新建:`PostApi.createPost(...)`;
- 故意报错:请求不存在的路径,看统一错误提示。
请求/响应日志在控制台打印(`DioClient` 拦截器里 debugPrint 完整 URL)。
---
## 三、核心知识点(新手向)
### 1. 项目的网络分层(重点!)
```
页面(本 Demo)→ PostApi(业务接口,只定义"有哪些接口")
→ DioClient(封装层:baseUrl/超时/拦截器/错误翻译)
→ Dio(底层库)
```
- `PostApi` 只关心业务:`fetchPost(1)`、`fetchPosts(userId: 1)`;
- `DioClient` 统一处理:日志、超时、把 `DioException` 翻译成中文 `ApiException`;
- 页面 **只 catch `ApiException`**,不用关心 Dio 的细节类型。
### 2. 统一请求入口 _run(页面范式)
```dart
Future<void> _run(Future<void> Function() action) async {
setState(() {
_loading = true;
_error = null;
});
try {
await action();
} on ApiException catch (e) {
if (!mounted) return;
setState(() => _error = '请求失败:${e.message}');
} catch (e) {
if (!mounted) return;
setState(() => _error = '未知异常:$e');
} finally {
if (mounted) setState(() => _loading = false); // 无论成败都复位
}
}
```
- **`finally` 里复位 `_loading`**:成功/失败都要恢复,忘记写就会永远转圈;
- 每个具体请求只是往 `_run` 里传一个闭包,**loading 逻辑只写一遍**。
### 3. 三态 UI
```dart
// 加载中:转圈
if (_loading) Center(child: CircularProgressIndicator());
// 失败:红底错误卡
if (_error != null)
Container(
color: const Color(0xFFFDECEA),
child: Text(_error!, style: const TextStyle(color: Color(0xFFD32F2F))),
);
// 成功:结果卡(SelectableText 可复制)
Container(
child: SelectableText(_resultBody.isEmpty ? '(暂无数据)' : _resultBody),
)
```
### 4. 按钮防重
```dart
FilledButton.icon(
onPressed: _loading ? null : _fetchOne, // loading 时禁用
...
)
```
---
## 四、关键代码剖析
```dart
/// GET 单条
Future<void> _fetchOne() async {
await _run(() async {
final post = await PostApi.fetchPost(1);
if (!mounted) return;
setState(() {
_resultTitle = 'GET /posts/1 成功';
_resultBody = 'id: ${post.id}\nuserId: ${post.userId}\ntitle: ${post.title}';
});
});
}
```
---
## 五、踩坑记录
1. **await 之后 setState 前必须 `if (!mounted) return`** —— 异步期间页面可能已销毁,直接 setState 会报错;
2. **catch 顺序**:先 `on ApiException`(业务错误),再 `catch (e)`(兜底未知异常);
3. 别在页面里直接 catch `DioException` —— 封装层的翻译就白做了;
4. loading 复位放 `finally`,不是 try 里 —— 否则异常路径永远转圈;
5. 真实项目 URL 用相对路径(baseUrl 在 DioClient 统一配),别在页面写死域名。
---
## 六、扩展思考
- 生产级能力(取消/进度/重试)见 [dio_pro_demo.md](dio_pro_demo.md);
- 上传文件(FormData + onSendProgress)见 [oss_upload_demo.md](oss_upload_demo.md);
- 页面更进阶的写法:用 Riverpod `AsyncNotifierProvider` 管理请求状态,AsyncValue 自带 loading/error/data 三态(见 exam 架构文档);
- 错误重试策略:`DioClient` 已内置指数退避重试(只对网络层错误),开关见 dio_pro_demo。
+137
View File
@@ -0,0 +1,137 @@
# 【Demo】Dio 生产级能力(进度 / 取消 / 重试)
> 源文件:`lib/pages/demo/dio_pro_demo.dart` | 分类:网络
> 一句话:下载进度条、页面销毁自动取消请求、网络错误自动重试 —— 生产环境三件套。
---
## 一、这个 Demo 解决什么问题
[dio_demo.md](dio_demo.md) 讲了基础请求,本页升级到生产级场景:
1. **下载进度**:`onReceiveProgress` 实时百分比 + 进度条;
2. **请求取消**:用户手动取消 + **页面销毁自动取消**(防泄漏);
3. **失败重试**:网络层错误自动指数退避重试 N 次(控制台看 ↻ 日志)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: DioProDemoPage()));
```
- "开始下载":下载约 2MB 的视频文件,进度条动起来;
- "取消下载":手动中断;
- "重试演示":请求不存在的域名 → DNS 解析失败 → 自动重试 3 次(指数退避)→ 抛"网络异常";
- 顶部开关控制是否启用重试,方便对比。
---
## 三、核心知识点(新手向)
### 1. 为什么需要 CancelToken
页面销毁但请求还在跑 → Future 没人接,响应回调还可能在 setState(对已销毁的 State setState 会报错/泄漏)。
做法:
```dart
CancelToken? _token;
Future<void> _startDownload() async {
final token = CancelToken(); // 每个请求新建一个(取消后不可复用!)
_token = token;
try {
await DioClient.instance.get(
_bigUrl,
cancelToken: token,
onReceiveProgress: (received, total) {
if (!mounted || token.isCancelled) return;
setState(() => _progress = total <= 0 ? 0 : received / total);
},
retries: _retryEnabled ? 2 : 0,
);
} on ApiException catch (e) {
// 取消也会走到这里("请求已取消"),统一复位 UI
if (!mounted) return;
setState(() { _log += '✖ ${e.message}\n'; _downloading = false; });
}
}
@override
void dispose() {
_token?.cancel('页面已销毁'); // 页面销毁 → 自动取消在途请求(核心防泄漏点)
super.dispose();
}
```
### 2. 进度回调的三个坑
```dart
onReceiveProgress: (received, total) {
if (!mounted || token.isCancelled) return; // ① 回调频率高,必判 mounted
setState(() => _progress = total <= 0 ? 0 : received / total); // ② total<=0 除零兜底
},
```
- `total <= 0`:响应没有 Content-Length 时 total 为 -1,直接除会算出负数进度;
- 进度回调每秒触发很多次,每次 setState 前判 mounted 是性能与安全双重保险。
### 3. 重试为什么只对"网络层错误"生效
| 错误类型 | 可重试? | 原因 |
|---|---|---|
| connectionError(断网/域名解析失败)、超时、闪断 | ✅ | 网络恢复后大概率成功 |
| 主动取消(用户意图) | ❌ | 用户不要了还重试 = 反人性 |
| badResponse(4xx/5xx) | ❌ | 重试大概率同样失败,POST 还会**重复提交数据** |
这是生产级的取舍,见 `dio_client.dart` 的 `_canRetry`。
### 4. Web 调试的 CORS 坑
- `application/json` 是**非简单头**,跨域 GET 会先发 **OPTIONS 预检**,静态服务器不支持就 405 被拦;
- 下载媒体/图片时用 headers 覆盖成 **text/plain 简单头**,浏览器不再预检直接放行(真机无此问题)。
```dart
headers: const {'Content-Type': 'text/plain'}, // Web 调试必加!
```
---
## 四、关键代码剖析
```dart
// 手动取消
void _cancelDownload() {
_token?.cancel('用户点击取消');
setState(() => _log += '⏹ 已发起取消,等待请求中断...\n');
}
```
取消后请求抛 `DioExceptionType.cancel` → 封装层翻译成 `ApiException('请求已取消')` → 走 onError 分支。
```dart
// 重试演示:Stopwatch 统计总耗时
final stopwatch = Stopwatch()..start();
try {
await DioClient.instance.get('https://no-such-domain-abcxyz.com/api/test',
retries: _retryEnabled ? 3 : 0);
} on ApiException catch (e) {
stopwatch.stop();
setState(() => _log = '✖ ${e.message}\n⏱ 共耗时 ${stopwatch.elapsed.inMilliseconds}ms\n'
'(重试开关:${_retryEnabled ? '开' : '关'})');
}
```
---
## 五、踩坑记录
1. **`onReceiveProgress` 里 setState 前必须判 mounted**(回调频率高,页面销毁后必崩);
2. **`total <= 0` 时进度除零**,要兜底成 0;
3. **CancelToken 一旦取消就不能复用**,每次请求要新建;
4. **取消走 onError 分支**(DioExceptionType.cancel),不是"不走回调"——UI 复位逻辑要放对地方;
5. Web 跨域下载:Content-Type 改 text/plain 绕过预检;
6. 上传进度同理:`onSendProgress: (sent, total)`,笔记图片上传的下一步就接它。
---
## 六、扩展思考
- 重试策略进阶:`RetryInterceptor` 的 retryableExtra / 重试次数按接口配置;
- 下载文件落盘:`dio` 的 `download()` + 进度回调 + `path_provider` 存沙盒;
- 上传场景:[oss_upload_demo.md](oss_upload_demo.md) 的 OSS 直传 PUT 就带 onSendProgress;
- 页面级取消还可以用 `DisposeBag` 模式统一管理多个 token(本项目单 token 够用)。
+109
View File
@@ -0,0 +1,109 @@
# 【Demo】试卷练习入口(大数据滑动验证页)
> 源文件:`lib/pages/demo/exam_demo.dart` | 分类:考试(exam)
> 一句话:试卷练习的入口页 —— 选题目数量(50/100/200)→ 进入真实考试页,验证 PageView 大数据滑动不卡。
---
## 一、这个 Demo 解决什么问题
真实考试页(`lib/features/exam/exam_page.dart`)是**本项目的核心功能**,完整架构见
[exam_architecture.md](../exam_architecture.md)(建议先读)。本页是它的**演示入口**,回答三个问题:
1. **100 道题滑动卡不卡?** —— PageView.builder 懒加载:只构建视口 ±1 页,滑出的页面自动回收;
2. **四种题型怎么共存?** —— 按 `question.type` switch 分发到单选/多选/判断/解答卡片;
3. **答一半退出,进度还在吗?** —— Riverpod(内存主状态)+ Isar(AnswerRecord 备份层),重进自动恢复。
> 对照 Vue:对标 swiper 大数据瓶颈——Vue 会把所有 DOM 挂上,Flutter 的 PageView.builder 只构建看得见的 ±1 页。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ExamDemoPage()));
```
1. **SegmentedButton 选数量**(50/100/200 题)→ 点"开始练习";
2. 快速左右滑动感受流畅度(`allowImplicitScrolling` 预构建相邻页);
3. 答几题 → 退出 → 重新进入 → SnackBar 提示"已恢复上次答题进度(X 题),定位到第 Y 题";
4. 交卷 → 自动判分(客观题)+ 按 paperId 清理本地进度。
---
## 三、核心知识点(新手向)
### 1. 数量选择 = Riverpod 全局状态
```dart
class ExamDemoPage extends ConsumerWidget { // ① 用 ConsumerWidget 才能 ref
...
final size = ref.watch(paperSizeProvider); // ② watch:变化自动重建
SegmentedButton<int>(
segments: [for (final s in _sizes) ButtonSegment(value: s, label: Text('$s 题'))],
selected: {size},
onSelectionChanged: (selection) {
ref.read(paperSizeProvider.notifier).setSize(selection.first); // ③ 回调里 read
},
)
```
- **build 里 `ref.watch`**(响应式重建),**回调里 `ref.read`**(只读一次不订阅)——Riverpod 的黄金规则;
- `paperSizeProvider` 是全局的:入口页选的 50 题,考试页读到的就是 50 题(跨页共享状态)。
### 2. 进入考试页 = 普通跳转
```dart
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExamPage()),
);
```
考试页通过同一个 `paperSizeProvider` 拿题量——**传参交给状态管理,路由只负责跳转**(对比 [router_home_page.md](router_home_page.md) 的构造传参)。
### 3. 三个架构要点(页面里直接写了卡片)
| 要点 | 说明 |
|---|---|
| PageView.builder 懒加载 | 只构建视口 ±1 页,滑出即回收;`allowImplicitScrolling` 预构建相邻页 → 100 题滑动不掉帧 |
| 题型分发(QuestionCard) | 按 `question.type` switch 到单选/多选/判断/解答视图;组件**纯展示不碰 provider**,ExamPage 唯一桥接 Riverpod |
| 续答持久化(Riverpod + Isar) | 答案主状态在内存,每次作答异步写 AnswerRecord(Isar);退出重进自动恢复,交卷后按 paperId 清理 |
---
## 四、关键代码剖析
```dart
static const List<int> _sizes = [50, 100, 200];
// 入口页数据流:
// SegmentedButton 选择 → paperSizeProvider(全局)
// → ExamPage 同读该 provider 生成 N 道题 → PageView.builder 懒加载渲染
FilledButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExamPage()),
);
},
icon: const Icon(Icons.play_arrow),
label: Text('开始练习($size 题)'), // 直接展示当前全局题量
)
```
`_ArchPoint` 是纯展示卡片(图标 + 标题 + 描述),无状态无逻辑——学习型页面的"知识卡片"惯例。
---
## 五、踩坑记录
1. **ConsumerWidget 才能用 ref**:普通 StatelessWidget 里没有 `ref` 参数(要自己 `Consumer` 包一层);
2. `ref.read` 只用在**回调**里,build 里用 `ref.watch`——反了会导致不响应或多余重建;
3. 本页是入口,**真正的考试逻辑在 ExamPage**(`lib/features/exam/exam_page.dart`),别在本页找答案判分逻辑;
4. Web 调试时 Isar 为空实现 → 续答自动降级为纯内存(不落盘),这是设计好的,不是 bug;
5. 想快速体验"恢复进度",答几题后**直接退出页面**(系统返回),而不是杀进程——内存态还在。
---
## 六、扩展思考
- **完整架构必读**:[exam_architecture.md](../exam_architecture.md)(零、新手前置 → 一到九章,含 Riverpod/Isar 教程);
- 题干是 HTML 富文本,渲染方式见 [rich_text_demo.md](rich_text_demo.md);
- 题目/答案的本地持久化模式(Store + watch)见 [isar_watch_demo.md](isar_watch_demo.md);
- 答题记录的"接口 + 本地"双通道思路见 [wrong_flow_demo.md](wrong_flow_demo.md)。
+186
View File
@@ -0,0 +1,186 @@
# 【Demo】Form 表单校验(7 个知识点)
> 源文件:`lib/pages/demo/form_demo.dart` | 分类:基础布局
> 一句话:注册表单校验的完整范式 —— validator、时机、跨字段对比、协议勾选、两段式校验。
---
## 一、这个 Demo 解决什么问题
做注册/登录表单时新手最头疼的几件事,本页一次讲全:
必填/长度/正则/密码强度校验、确认密码对比、协议勾选校验、
**validator 不能 await** 时怎么做异步查重、防重复提交、重置表单。
### 和 Vue 的对照(关键差异)
| Vue | Flutter |
|---|---|
| v-model 双向绑定 + rules 规则对象 | TextEditingController + 每个字段自带 `validator` 函数 |
| `validate()`(async-validator) | `formKey.currentState.validate()` 统一触发 |
**Flutter 的 validator 是同步函数,返回 `String?`**:
- 返回 `null` → 校验通过;
- 返回字符串 → 错误文本(自动显示在输入框下方红字)。
没有"规则对象",每个字段的规则就写在它自己的 validator 里,**就近维护**。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: FormDemoPage()));
```
5 个输入框(用户名/手机号/邮箱/密码/确认密码)+ 协议勾选 + 提交/重置:
- 不填就提交 → 红字提示;填错即时提示(onUserInteraction);
- 提交通过 → 模拟 1 秒异步查重 → "注册成功";
- 重置 → 清空所有输入和校验状态。
---
## 三、核心知识点(新手向)
### 1. Form 的骨架
```dart
final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); // Form 的唯一钥匙
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // 校验时机
child: Column(children: [ TextFormField(validator: ...), ... ]),
)
// 提交 / 重置都靠这把钥匙
_formKey.currentState!.validate();
_formKey.currentState?.reset();
```
### 2. 三种校验时机(AutovalidateMode)
| 模式 | 行为 | 建议 |
|---|---|---|
| `disabled`(默认) | 只在点提交 validate() 时校验 | 简单表单 |
| `always` | build 时立刻校验 | **慎用**:空表单满屏红字 |
| `onUserInteraction` | 用户碰过的字段才即时校验 | **推荐** |
### 3. validator 写法范式
```dart
// 必填 + 长度
String? _validateName(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入用户名';
if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符';
return null;
}
// 正则全匹配:^...$ 必须写!不然 11 位之后还能续数字
String? _validatePhone(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入手机号';
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(s)) return '手机号格式不正确';
return null;
}
// 跨字段对比:validator 里读另一个 controller
String? _validatePwd2(String? v) {
if (v == null || v.isEmpty) return '请再次输入密码';
if (v != _pwdC.text) return '两次密码不一致';
return null;
}
```
### 4. 两段式校验(重点!validator 不能 await)
```dart
// ① 同步校验:所有 validator 逐个执行
if (!_formKey.currentState!.validate()) {
_toast('有字段未通过校验,请检查红字提示');
return;
}
// ② 同步通过 → 再单独做异步业务校验(查重/验证码)
setState(() => _submitting = true);
await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求
if (!mounted) return; // 异步后必须判 mounted 才能用 context
```
### 5. 协议勾选:FormField\<bool> 校验"非输入型"字段
```dart
FormField<bool>(
initialValue: _agreed,
validator: (v) => (v ?? false) ? null : '请先勾选同意协议',
builder: (field) {
return Column(children: [
InkWell(
onTap: () {
field.didChange(!(field.value ?? false)); // 更新 FormField 自身
setState(() => _agreed = field.value ?? false); // 同步外部镜像
},
child: ...,
),
// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染!
if (field.hasError)
Text(field.errorText!, style: const TextStyle(color: Colors.red)),
]);
},
)
```
### 6. 防重复提交 + 重置
```dart
// 提交中按钮禁用(onPressed: null 自动置灰 + 换 loading 转圈)
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting ? CircularProgressIndicator() : const Text('提交'),
)
// 重置:reset() 只清校验错误和 FormField 状态,不碰 controller.text!
void _reset() {
_formKey.currentState?.reset();
_nameC.clear(); // 清空输入要手动 clear()
...
}
```
---
## 四、关键代码剖析
通用输入框抽取(本页把 label/hint/图标/校验规则全部参数化,避免 5 个输入框复制粘贴):
```dart
Widget _buildField({
required String label,
required TextEditingController controller,
required String? Function(String?) validator,
TextInputType? keyboardType,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
}
```
---
## 五、踩坑记录
1. **`formState.reset()` 不碰 `controller.text`** —— 清空输入必须手动 `clear()`;
2. **validator 是同步函数,不能 await** —— 异步校验(查重/验证码)放 validate() 通过之后单独做;
3. **正则要 `^...$` 全匹配**,否则 `RegExp(r'1[3-9]\d{9}')` 能匹配"123456789012345";
4. 所有 controller 都要 dispose(本页 5 个);
5. 加了 `.w/.h/.sp` 的 widget 不能 `const`;
6. 异步(await)之后用 context 必须先 `if (!mounted) return`。
---
## 六、扩展思考
- 校验规则多了以后,可以把 validator 抽成独立函数文件(如 `lib/core/validators/`),表单只负责组装;
- 真实注册流程:本页"模拟查重"处换成 `DioClient` 请求(见 [dio_demo.md](dio_demo.md));
- 密码强度可以升级为正则组合:`(?=.*[A-Za-z])(?=.*\d)` 就是"必须同时含字母和数字"的零宽断言写法;
- 表单 + 状态管理:把 controller 和提交逻辑挪进 Riverpod `NotifierProvider`,页面只剩纯 UI(见架构文档的 ExamPage 分层思想)。
+92
View File
@@ -0,0 +1,92 @@
# 【Demo】GridView 网格布局(三种构造)
> 源文件:`lib/pages/demo/grid_demo.dart` | 分类:基础布局
> 一句话:九宫格/相册/功能宫格都靠它,三种构造方式一次看懂。
---
## 一、这个 Demo 解决什么问题
"功能宫格(4×2 图标)"、"相册缩略图(3 列)"、"课程卡片墙"这类**多列等宽网格**布局。
GridView 就是 Flutter 的网格容器,支持**懒加载**,大量数据不卡。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: GridDemoPage()));
```
一屏并排演示三种构造:
1. `GridView.count`:固定 3 列,写死 children;
2. `GridView.builder`:4 列,`itemCount: 100` 懒加载;
3. `GridView.extent`:每项最多 80 宽,**屏幕越宽列数自动变多**。
---
## 三、核心知识点(新手向)
### 1. 三个常用构造怎么选
| 构造 | 特点 | 适用 |
|---|---|---|
| `GridView.count` | 固定列数,children 一次性全创建 | 少量固定网格(< 30) |
| `GridView.builder` | 懒加载,`itemCount` + `itemBuilder` | **大量数据首选**(100+ 条) |
| `GridView.extent` | 按"每项最大宽度"自适应列数 | 响应式场景(Pad 变列数) |
### 2. 核心参数
```dart
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4, // 列数
mainAxisSpacing: 8.w, // 主轴(上下行)间距
crossAxisSpacing: 8.w, // 交叉轴(左右列)间距
childAspectRatio: 1, // 每项 宽:高 比例,默认 1 = 正方形
),
itemCount: 100,
itemBuilder: (context, index) => _GridItem(index: index),
)
```
### 3. 重点记忆:GridView 的本质
**GridView = CustomScrollView + 1 个 SliverGrid**(框架内部自动包的简化套餐)。
- 页面只有网格 → `GridView.builder` 直接用;
- 页面要混排(折叠 AppBar + 网格 + 列表)→ 用 `CustomScrollView + slivers`(见 [sliver_demo.md](sliver_demo.md))。
---
## 四、关键代码剖析
```dart
// Demo3:extent 自适应列数
GridView.extent(
maxCrossAxisExtent: 80.w, // 每项最多 80 宽 → 屏幕宽时自动多列
mainAxisSpacing: 8.w,
crossAxisSpacing: 8.w,
childAspectRatio: 1,
children: [for (int i = 0; i < 12; i++) _GridItem(index: i)],
)
```
> 响应式网格更精细的做法见 [responsive_demo.md](responsive_demo.md):
> 用 `LayoutBuilder` 拿真实宽度,`(width / 280).floor().clamp(1, 4)` 算列数。
---
## 五、踩坑记录
1. **GridView 放 Column 里必须 SizedBox 定高或 Expanded**,否则报
`Vertical viewport was given unbounded height`(和 ListView 同一个坑)。
2. **`childAspectRatio` 太小(如 0.5)时,格子内文字会被挤压溢出** —— 调大比例或缩小字号。
3. 本页外层 `SingleChildScrollView` 只是"展示台"(为了并排演示 3 种构造),
真实网格页**别学这个外层** —— 网格自己就能滚。
4. 想"网格 + 下拉刷新",用 `RefreshIndicator` 包 GridView(写法同 [list_demo.md](list_demo.md) 的列表刷新)。
---
## 六、扩展思考
- **`SliverGridDelegateWithMaxCrossAxisExtent`**:同 extent 效果的另一种 delegate 写法;
- 相册九宫格真实案例见 [note_picker_demo.md](note_picker_demo.md):
GridView + "加号格" + 右上角删除角标(Stack 组合),多一格用 `itemCount: _images.length + 1`;
- 性能提示:builder 系列(builder/extent.builder)是懒加载,`count` 不是 —— 大列表务必 builder。
+212
View File
@@ -0,0 +1,212 @@
# 【Demo】Image 图片组件(四种来源 + BoxFit + 缓存 + 错误兜底)
> 源文件:`lib/pages/demo/image_demo.dart` | 分类:组件封装
> 一句话:网络图(带缓存)/ 本地资源图 / 圆角图 / 圆形头像 -- 图片加载的四个必会姿势。
---
## 一、这个 Demo 解决什么问题
图片是 App 最高频的组件,但新手常踩:加载失败白屏、图片变形、圆角/圆形不会切、网络图每次重下载不缓存。
本页用四个区块讲清:
| 区块 | 用法 | 知识点 |
|---|---|---|
| ① 网络图片(带缓存) | `CachedNetworkImage` | `BoxFit.cover` + `progressIndicatorBuilder` 下载进度 + `errorWidget` 兜底 |
| ② 本地资源图 | `Image.asset` | 必须在 pubspec.yaml 注册 assets |
| ③ 圆角网络图 | `ClipRRect` 包 `CachedNetworkImage` | 图片本身没变,只是画出来带圆角 |
| ④ 圆形头像 | `CircleAvatar` + `CachedNetworkImageProvider` | `backgroundImage` 走缓存 + 失败时显示首字母 |
> 对照 Vue:`<img src>` 的 Flutter 版本,但多了"裁切模式"、"磁盘缓存"和"错误兜底"这三个必须处理的点。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ImageDemoPage()));
```
- ① 首次加载显示下载进度条;再次进入本页(或同 URL 出现在列表页)秒出 -- 走本地缓存,不重新下载;
- ② 本地 logo 图等比完整显示;
- ③ 圆角大图(radius 60 接近胶囊),加载中转圈;
- ④ 圆形头像:加载失败时显示"头"字兜底。
- 断网/改错地址 -> 所有网络图都显示"图片加载失败"占位图(不白屏)。
---
## 三、核心知识点(新手向)
### 1. 图片来源的几种方式
```dart
CachedNetworkImage(imageUrl: url) // 网络图片 + 本地缓存(生产推荐)
CachedNetworkImageProvider(url) // 同上,但返回 ImageProvider(头像场景)
Image.network(url) // 网络图片(无缓存,每次重下载,仅作对比)
Image.asset('assets/...') // 本地资源(先注册 pubspec.yaml)
Image.file(File(...)) // 本地磁盘文件(需 path_provider,本页不演示)
Image.memory(bytes) // 内存字节流(base64 等,见 share_demo 截图预览)
```
### 2. 为什么用 CachedNetworkImage 而不是 Image.network
`Image.network` 每次都重新发 HTTP 请求下载图片,没缓存、列表页性能差、流量浪费。
`cached_network_image`(baseflow 维护,6.9k likes)底层用 `flutter_cache_manager` 把图片写入
本地缓存目录,二次加载直接读本地文件,对标 Web 端浏览器 HTTP 缓存。它还提供三段式回调,
比 `Image.network` 的 `loadingBuilder`(依赖 `ImageChunkEvent`,常常拿不到进度)更可靠:
```dart
CachedNetworkImage(
imageUrl: _netUrl,
fit: BoxFit.cover,
// 加载中占位 / 下载进度(两者二选一,progressIndicatorBuilder 更直观)
progressIndicatorBuilder: (context, url, downloadProgress) =>
CircularProgressIndicator(value: downloadProgress.progress),
// 失败兜底:断网/404/格式错 -> 占位图,不白屏
errorWidget: (context, url, error) => _buildErrorBox(),
)
```
> 注意:`placeholder` 和 `progressIndicatorBuilder` **二选一**,后者会覆盖前者。
> `downloadProgress.progress` 是 `double?`(总大小未知时为 null),需判空再赋给 `value`。
### 2.1. 新增依赖后必须 Hot Restart(原生端常见“假死 loading”根因)
现象:你改了 `pubspec.yaml`、新增插件后,只点 Hot Reload,Android/iOS 模拟器里可能出现
`CachedNetworkImage` 一直处于 loading(不进 errorWidget、不出图);但完整重启后恢复正常。
原因:原生插件注册发生在启动阶段(GeneratedPluginRegistrant),Hot Reload 不会重新执行注册流程。
解决:停止运行 → 重新 `flutter run` / Hot Restart 一次。
### 3. 头像场景用 CachedNetworkImageProvider
```dart
CircleAvatar(
backgroundImage: CachedNetworkImageProvider(_netUrl), // 同样走缓存
onBackgroundImageError: (error, stackTrace) {}, // 失败不崩
child: Text('头'), // 背景加载失败时显示首字母
)
```
`CachedNetworkImage` 是 Widget,适合直接渲染;`CachedNetworkImageProvider` 是 ImageProvider,
适合塞给 `CircleAvatar.backgroundImage`、`BoxDecoration.image` 等需要 ImageProvider 的场景。
### 4. BoxFit 裁切模式速记
| 值 | 行为 | 用途 |
|---|---|---|
| `cover` | 等比缩放填满并**裁掉多余** | 头像/封面(最常用) |
| `contain` | 等比缩放**完整显示**(有留白) | logo、二维码 |
| `fill` | **拉伸填满**(会变形) | 少用 |
| `fitWidth` / `fitHeight` | 按宽/高撑满 | 特殊场景 |
### 5. 错误兜底:不白屏
```dart
CachedNetworkImage(
imageUrl: _netUrl,
width: 300.w,
height: 180.h,
fit: BoxFit.cover,
errorWidget: (context, url, error) =>
_buildErrorBox(width: 300.w, height: 180.h), // 断网/404/格式错 -> 占位图
)
```
Flutter 3.x 网络图**务必给 `errorWidget`**,否则加载失败抛异常白屏。
占位图 `_buildErrorBox` 接收和原图一样的宽高 -> 布局不跳变(加载中->失败,UI 不抖动)。
### 6. 圆角 ≠ 图片属性,是"裁剪"组件
```dart
ClipRRect(
borderRadius: BorderRadius.circular(60.r), // 裁剪器
child: CachedNetworkImage(...), // 图片本身没变
)
```
---
## 四、关键代码剖析
```dart
class ImageDemoPage extends StatelessWidget {
const ImageDemoPage({super.key});
static const String _netUrl =
'https://hx.23544.com/StaticResource/2026-07-29/ranking3Position1785292048369.png';
/// 加载中占位:progress 为 null 转圈,非 null 显示百分比进度条
Widget _buildLoadingBox({
required double width,
required double height,
required double? progress,
}) {
return Container(
width: width,
height: height,
color: const Color(0xFFF0F2F5),
child: Center(
child: progress == null
? SizedBox(
width: 24.w,
height: 24.w,
child: const CircularProgressIndicator(strokeWidth: 2),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24.w,
height: 24.w,
child: CircularProgressIndicator(
value: progress, strokeWidth: 2),
),
SizedBox(height: 8.h),
Text('${(progress * 100).toInt()}%',
style: TextStyle(fontSize: 12.sp, color: Color(0xFF888888))),
],
),
),
);
}
/// 统一的加载失败占位图(接收和原图一样的宽高 -> 布局不跳变)
Widget _buildErrorBox({required double width, required double height}) {
return Container(
width: width,
height: height,
color: const Color(0xFFF0F2F5),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.broken_image_outlined, size: 40.w, color: Color(0xFFB0B4BC)),
SizedBox(height: 8.h),
Text('图片加载失败', style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888))),
],
),
),
);
}
}
```
- `progressIndicatorBuilder` 用 `downloadProgress.progress` 驱动进度条,二次加载命中缓存时直接跳过该回调(秒出);
- 占位图接收和原图一样的宽高 -> 加载中 -> 成功/失败,UI 不抖动。
---
## 五、踩坑记录
1. **网络图必须 errorWidget/errorBuilder**:加载失败(断网/404/格式错)不给兜底就白屏;
`CachedNetworkImage` 用 `errorWidget`,`Image.network` 用 `errorBuilder`;
2. **新增插件后必须 Hot Restart**:只 Hot Reload 可能导致原生插件未重新注册,表现为一直 loading;
3. **Image.asset 必须先注册**:`pubspec.yaml` 的 `assets:` 列表里没有的路径直接运行期报错;
4. Android **release 包要 INTERNET 权限**(`AndroidManifest.xml` 已加)--debug 默认有,release 没有会黑图;
5. 图片"变形"先查 `fit`:忘了写默认是 `fill` 拉伸,封面图要用 `cover`;
6. 圆角用 `ClipRRect` 包,**不是**给图片加 borderRadius(Image 没有这个属性,Container 才有);
7. `placeholder` 与 `progressIndicatorBuilder` **二选一**:同时写后者生效,前者被忽略;
`downloadProgress.progress` 是 `double?`,总大小未知时为 null,直接塞给 `CircularProgressIndicator.value` 前要判空;
8. **缓存命中后不再回调进度**:二次加载直接读本地缓存,`progressIndicatorBuilder`/`placeholder` 都不会触发,
表现为"秒出" -- 这正是缓存生效的标志,不是没生效;
9. `Image.file` 在 Web 编译不过(`dart:io`),跨端项目用 `Image.memory` 替代(见 [note_picker_demo.md](note_picker_demo.md));
10. `cached_network_image` 在 **Web 端缓存能力有限**(仅内存缓存,刷新即失效),原生端(Android/iOS)走磁盘缓存。
---
## 六、扩展思考
- 多图选择页的缩略图渲染(原生/Web 双端)见 [note_picker_demo.md](note_picker_demo.md);
- 截图字节流用 `Image.memory` 预览见 [share_demo.md](share_demo.md);
- 富文本里的 `<img>` 渲染见 [rich_text_demo.md](rich_text_demo.md);
- 图片墙布局用 [grid_demo.md](grid_demo.md) 的 GridView;网络图片预览(双指缩放)用 `photo_view`。
+109
View File
@@ -0,0 +1,109 @@
# 【Demo】TextField 输入框(登录表单)
> 源文件:`lib/pages/demo/input_demo.dart` | 分类:基础布局
> 一句话:输入框 + 密码"小眼睛"切换 —— 表单最基础的零件。
---
## 一、这个 Demo 解决什么问题
做一个最简单的登录表单:手机号输入框 + 密码输入框(可切换明文/密文)。
核心是搞懂 **TextEditingController** 的用法——Flutter 里输入框不叫"双向绑定",
而是"控制器持有值,代码里随时读写"。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: InputDemoPage()));
```
- 手机号框:弹数字键盘(`keyboardType: TextInputType.phone`);
- 密码框:右侧眼睛图标切换密文/明文;
- 点"登录":控制台打印两个输入框的内容。
---
## 三、核心知识点(新手向)
### 1. 对照 Vue 的 v-model
| Vue | Flutter |
|---|---|
| `v-model="phone"` | `TextEditingController` + `controller.text` |
| 框架自动同步 | 手动 `controller.text` 读写 |
Flutter 没有隐式的双向绑定:`controller.text` 改了就同步到输入框,
输入框内容变了 `controller.text` 也会读到 —— 但**没有"响应式"**:
想根据输入内容实时刷新 UI(如"输入满 11 位才能点登录"),要自己 `addListener` 或 `setState`。
### 2. TextEditingController 生命周期(红线)
```dart
final TextEditingController _userCtrl = TextEditingController();
@override
void dispose() {
_userCtrl.dispose(); // 必须在 dispose 释放,防内存泄漏
super.dispose();
}
```
> 凡是用 `Controller` 命名的东西(Text/Scroll/Animation/Tab),基本都要 dispose。
### 3. 密码眼睛切换
```dart
bool _showPwd = false;
TextField(
controller: _pwdCtrl,
obscureText: !_showPwd, // 密文/明文由状态决定
decoration: InputDecoration(
labelText: "密码",
prefixIcon: const Icon(Icons.lock),
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_showPwd ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _showPwd = !_showPwd),
),
),
)
```
### 4. InputDecoration 常用参数
- `labelText`:浮动标签(聚焦时缩小浮到边框上);
- `hintText`:灰色占位提示;
- `prefixIcon`:左侧图标;`suffixIcon`:右侧(按钮/图标);
- `border: OutlineInputBorder()`:带边框的圆角样式(默认是下划线)。
---
## 四、关键代码剖析
```dart
// 读取输入内容(点登录时)
String user = _userCtrl.text;
String pwd = _pwdCtrl.text;
if (kDebugMode) {
print("账号:$user,密码:$pwd"); // kDebugMode:只在 debug 包打印
}
```
要点:
- `kDebugMode`(来自 `package:flutter/foundation.dart`)保证打印不会出现在 release 包;
- 真实项目里读取后要走校验(见 [form_demo.md](form_demo.md))和登录接口(见 [dio_demo.md](dio_demo.md))。
---
## 五、踩坑记录
1. **Scaffold 没有 `padding` 参数!** 外间距要写在 body 的 `Padding` 上(新手必踩)。
2. `controller` 忘了 dispose → 内存泄漏(Flutter 分析器会提示)。
3. `suffixIcon` 用 `IconButton` 而不要用普通 `Icon` 包 `GestureDetector` —— IconButton 自带点击区域和涟漪反馈。
4. 想"输入即校验"别自己写监听,直接上 Form(见 form_demo)。
---
## 六、扩展思考
- 进阶校验:`TextFormField` + `validator`([form_demo.md](form_demo.md))—— 本项目表单校验全家桶;
- 真实登录页还要:防重复提交、加载动画、错误提示、密码安全存储(本项目用 flutter_secure_storage,见架构文档存储层);
- 输入法控制:`textInputAction: TextInputAction.next` 让回车键变成"下一项",最后一项变"完成"。
+119
View File
@@ -0,0 +1,119 @@
# 【Demo】Isar watch 响应式(错题本)
> 源文件:`lib/pages/demo/isar_watch_demo.dart` | 分类:存储
> 一句话:主动查库是"浅水区",`watch()` 订阅数据库是"深水区" —— 数据一变列表自动刷新。
---
## 一、这个 Demo 解决什么问题
本地数据库(Isar)的基本用法是 `findAll()` **主动查库**:数据变了要自己再查/刷新。
但页面要的是"数据库一变,UI 自动更新"——这就是 **watch 响应式**:
`watch()` 订阅数据库,写入/删除事件**自动推送**,配合 `StreamBuilder` 自动重建列表。
> 对照前端:watch ≈ 前端框架的"数据源订阅"——UI 直接订阅数据库,
> 写入和展示彻底解耦,不用手动通知刷新。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: IsarWatchDemoPage()));
```
- 上方:输入题干 → "加入错题"(写入 Isar);
- 下方:`watchAll()` 订阅的列表 **无需任何刷新** 自动更新(`fireImmediately` 首帧先出当前数据);
- 对比区:一个"手动查询"按钮模拟浅水区做法(要自己点才更新);
- 点列表项 → 删除 → watch 同样自动收到删除事件。
> ⚠ Isar 仅 native 可用:Web 端本页显示降级提示,请用 Android/iOS/桌面端看效果。
---
## 三、核心知识点(新手向)
### 1. watch 的用法
```dart
StreamBuilder<List<dynamic>>(
// 响应式核心:数据库变化自动推送;fireImmediately 首帧先出一份当前数据
stream: WrongQuestionStore.instance.watchAll(),
builder: (context, snapshot) {
if (snapshot.hasError) return const Text('监听出错');
final items = snapshot.data ?? const <dynamic>[];
// items 变化即重建列表
},
)
```
- `watchAll()` 返回 `Stream<List>`,页面必须用 `StreamBuilder` 消费(StreamBuilder 自动管理订阅/取消);
- **`fireImmediately: false` 时首帧是空的** —— 通常要开 `true`,首帧先出一份当前数据。
### 2. 为什么经 store 而不是直接用 AppDatabase
Isar 集合成员只在 native 编译,**web 直接引用会编译失败**。
`WrongQuestionStore` 做了**条件导出**(io 真实现 / web 空实现),
页面永远只面向 store,web 端用 `kIsWeb` 降级提示。
(原理详见 `docs/exam_architecture.md` 的 Isar 教程章节。)
### 3. 页面不 import 模型,用 dynamic 访问字段
```dart
// item 是 WrongQuestion 对象(页面不 import 模型,用 dynamic 访问)
final title = item.title as String? ?? '无题干';
final wrongAt = item.wrongAt as DateTime?;
```
这样页面层与 Isar 模型层彻底解耦,web 编译也不受影响。
### 4. 写入必须 writeTxn
```dart
await WrongQuestionStore.instance.add(
questionId: DateTime.now().millisecondsSinceEpoch % 100000,
title: title,
wrongAnswer: '选错了',
);
```
写操作必须 `writeTxn` 包住(store 内部已封装),页面不用管。
---
## 四、关键代码剖析
```dart
// 添加错题(写入 Isar → watch 自动推送 → 列表自动更新)
Future<void> _addWrong() async {
final title = _titleC.text.trim();
if (title.isEmpty) { /* SnackBar 提示 */ return; }
await WrongQuestionStore.instance.add(...);
_titleC.clear(); // 清空输入框(controller 直改,无需 setState)
}
// 删除(watch 也会收到删除事件,列表自动减少)
Future<void> _delete(dynamic item) async {
final id = item.id as int;
await WrongQuestionStore.instance.remove(id);
}
// 手动查询(对比:非响应式,必须自己点才更新)
Future<void> _manualQuery() async {
final all = await WrongQuestionStore.instance.findAll();
if (!mounted) return;
setState(() => _manualCount = all.length);
}
```
---
## 五、踩坑记录
1. **watch 返回的是 Stream,必须配 StreamBuilder**(或手动 cancel),否则订阅泄漏;
2. **`fireImmediately: false` 时首帧为空** —— 列表会闪一下"空",通常要开 `true`;
3. **写操作必须 writeTxn 包住**(store 已处理,绕过 store 裸写会报错);
4. **Web 端无法跑 Isar**(条件导出空实现),只能真机/桌面看效果;
5. `dynamic` 访问字段要 `as` 强转或判空,别直接 `.title`(分析器不报错,运行期可能崩)。
---
## 六、扩展思考
- 完整链路(接口 + Isar + watch)见 [wrong_flow_demo.md](wrong_flow_demo.md);
- 条件导出 + 三件套(io/web)是本项目 web 兼容的核心手段,见架构文档"零、新手前置";
- Isar 还有 `watchLazy()`(只通知不传数据)、`watchObject(id)`(单对象订阅);
- 进阶:`filter + sortBy + offset/limit` 组合查询,配合 watch 做"分页错题本"。
+144
View File
@@ -0,0 +1,144 @@
# 【Demo】ListView 列表(懒加载 / 刷新 / 触底加载 / 回顶)
> 源文件:`lib/pages/demo/list_demo.dart` | 分类:基础布局
> 一句话:列表是 App 的骨架 —— 本页把列表的"基础、分隔、下拉刷新、上拉加载、回到顶部"一次讲透。
---
## 一、这个 Demo 解决什么问题
列表占了移动 App 80% 的页面形态。本 Demo 演示列表开发的全套基础:
1. `ListView(children)` vs `ListView.builder` 的区别(重点!);
2. `ListView.separated` 自动分隔线;
3. `RefreshIndicator` 下拉刷新;
4. `ScrollController` 监听触底 → 上拉加载更多;
5. 滚动超过 200px 显示"回到顶部"悬浮按钮。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ListDemoPage()));
```
- Demo1:一次性渲染的 ListView(公告 + ListTile + for 循环混装);
- Demo2:可下拉刷新、可触底加载更多、带回到顶部按钮的完整列表。
---
## 三、核心知识点(新手向)
### 1. 最核心的区别(面试常问)
| 写法 | 行为 | 适用 |
|---|---|---|
| `ListView(children: [...])` | **一次性创建并渲染全部** item | 少量(<30)或异类组件混装 |
| `ListView.builder` | **懒加载**,只渲染可见项,滚到才创建 | 大量数据(聊天记录、长列表) |
```dart
ListView.builder(
itemCount: _items.length, // 数据总条数(决定滚动范围)
itemBuilder: (context, index) => ListTile(...), // 按下标生成每一行
)
```
### 2. 同族 API
- `ListView.builder`:纯列表,没有分隔线;
- `ListView.separated`:自动插分隔线,多一个 `separatorBuilder`(比在 item 里手动加 Divider 规范)。
### 3. 下拉刷新三件套
```dart
RefreshIndicator(
onRefresh: _onRefresh, // 必须返回 Future(转圈直到 Future 完成)
child: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), // 内容不满一屏也能下拉!
...
),
)
```
### 4. 触底加载的原理
`ScrollController.addListener` 监听滚动位置:
```dart
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadMore(); // 距底部 200px 时预加载
}
```
- `pos.pixels`:当前滚了多少;
- `pos.maxScrollExtent`:总共能滚多少;
- `hasClients`:控制器是否已挂载到滚动组件上(没挂载就访问会崩)。
### 5. Dart 3 记录类型(顺带学会)
```dart
List<(int, String)> _items = []; // (编号, 来源标签) 元组
final (num, tag) = _items[index]; // 解构
final newStart = _items.first.$1 + 100; // $1 取第一个字段
```
---
## 四、关键代码剖析
### 防重入的 _loadMore
```dart
Future<void> _loadMore() async {
if (_isLoadingMore || !_hasMore) return; // 防重入:滚动事件会高频触发
_isLoadingMore = true;
setState(() {}); // 先刷新,显示底部 loading
await Future.delayed(const Duration(milliseconds: 800)); // 模拟网络
if (!mounted) return; // 异步后必须判 mounted
setState(() {
_items = [..._items, ...List.generate(10, (i) => (start + i, '加载更多'))];
_page++;
_hasMore = _page < 5; // 模拟:到第 5 页后没有更多
});
_isLoadingMore = false;
}
```
### 回到顶部按钮(Stack + AnimatedOpacity + IgnorePointer)
```dart
Positioned(
right: 8,
bottom: 8,
child: AnimatedOpacity(
opacity: _showBackToTop ? 1 : 0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
// opacity: 0 的按钮还能被点到!必须 IgnorePointer 禁用点击
ignoring: !_showBackToTop,
child: FloatingActionButton.small(
heroTag: null, // 避免多个 FAB 的 Hero 动画冲突
onPressed: _scrollToTop,
child: const Icon(Icons.arrow_upward, color: Colors.white),
),
),
),
)
```
`_scrollToTop` = `_scrollController.animateTo(0, duration: 300ms, curve: Curves.easeOut)`。
> 显隐判断用 `if (shouldShow != _showBackToTop)` 而不是直接 setState —— 滚动事件每像素都触发,
> 只有状态真的变了才重建,避免性能浪费。
---
## 五、踩坑记录
1. **ListView 放进 Column/Row 必须给固定高度(SizedBox)或 Expanded**,否则报
`Vertical viewport was given unbounded height` —— 列表是"无限高"的,放在"无限高"的 Column 里矛盾。
2. **RefreshIndicator 必须配 `AlwaysScrollableScrollPhysics()`**,否则内容不满一屏时拉不动。
3. **`ScrollController` 必须在 dispose 释放**(与 TextEditingController 同理,防内存泄漏)。
4. **异步后 setState 前必须 `if (!mounted) return`**——await 期间用户可能已切走页面。
5. 触底判断用 `maxScrollExtent - 200` 提前量,不然在底部"刚好"才加载会有白屏等待感。
---
## 六、扩展思考
- **性能红线**:长列表用 builder,不要用 children + for;1000 条数据一次性渲染会掉帧;
- 数据源接 Riverpod 后,`_items` 换成 `ref.watch(xxxProvider)`,刷新逻辑交给 provider(见 exam 系列);
- 分页的通用模式:`page` 页码 + `hasMore` 标记 + `_isLoadingMore` 防重入,三件套缺一不可;
- 想整页只滚一次(AppBar 折叠 + 列表混排)→ 升级到 [sliver_demo.md](sliver_demo.md) 的 CustomScrollView。
+140
View File
@@ -0,0 +1,140 @@
# 【Demo】笔记图片选择页(多图选择 + 缩略图网格 + 删除角标)
> 源文件:`lib/pages/demo/note_picker_demo.dart` | 分类:组件封装
> 一句话:学生上传笔记前的"选图 + 预览 + 确认"页 —— 网格缩略图、右下角加号、底部提交按钮。
---
## 一、这个 Demo 解决什么问题
笔记上传前要先选图、预览、确认。本页是一个真实的**多图选择页**:
```
选图(PickerService 弹层)→ 网格缩略图预览 → 删除不满意的 → 底部提交(预留上传接口)
```
它演示了三个可复用的套路:
1. **加号格子**:GridView 比图片多一格,点它弹选图层;
2. **删除角标**:每张图右上角一个小圆叉(Stack + Positioned);
3. **双端缩略图**:同一份代码在原生 / Web 都能显示缩略图(`PickerService.buildThumb`)。
> 对照 Vue:相当于"el-upload 多图模式 + 预览删除"的前端部分,上传接口那步留了 TODO。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: NotePickerPage()));
```
- 点加号 → 弹「拍照/相册」底部弹层(原生是微信风格相册,可多选;Web 降级 image_picker);
- 选完图回到网格,右上角小叉可删;
- 选满 9 张后加号自动消失;
- 提交按钮显示 `上传笔记(n/9)`,未选图时点击提示"请先选择笔记图片"。
---
## 三、核心知识点(新手向)
### 1. 网格多一格 = 加号位
```dart
GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, // 3 列
mainAxisSpacing: 10.h,
crossAxisSpacing: 10.w,
),
itemCount: _images.length + 1, // 关键:多一格给"加号"
itemBuilder: (context, index) {
if (index == _images.length) { // 最后一格
if (_images.length >= _maxCount) {
return const SizedBox.shrink(); // 满了:占位空格
}
return _buildAddCell(); // 没满:加号
}
return _buildImageCell(index); // 前面是已选图片
},
)
```
### 2. 删除角标 = Stack + Positioned
```dart
Stack(
fit: StackFit.expand,
children: [
ClipRRect(borderRadius: BorderRadius.circular(10.r), child: thumb),
Positioned(
top: 4.h, right: 4.w, // 钉在右上角
child: GestureDetector(
onTap: () => _onRemoveTap(index),
child: Container( // 20x20 黑色半透明圆
width: 20.w, height: 20.h,
decoration: const BoxDecoration(
color: Colors.black54, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 12, color: Colors.white),
),
),
),
],
)
```
### 3. 双端缩略图:永远走服务层,别自己判断平台
```dart
Widget _buildThumb(PickedImage image) {
return PickerService.buildThumb(image);
// 原生 → AssetEntityImage(实体缩略图,秒开不卡顿)
// Web → Image.memory(字节)
}
```
### 4. 提交按钮的"禁用态"套路
```dart
FilledButton(
onPressed: _uploading ? null : _onSubmit, // null = 禁用(置灰)
child: Text(_uploading ? '上传中...' : '上传笔记(${_images.length}/$_maxCount)'),
)
```
`onPressed` 传 `null` 就是禁用,这是 Flutter 的通用约定(比 Vue 的 `:disabled` 更内建)。
---
## 四、关键代码剖析
```dart
Future<void> _onAddTap() async {
final picked = await PickerService.instance.showPickSheet(
context,
maxCount: _maxCount - _images.length, // 剩余可选张数(限总数 9)
);
if (picked == null || picked.isEmpty) return; // 用户取消
setState(() => _images.addAll(picked)); // 不可变对象直接合并
}
```
提交时预留了上传接口位(等接口文档到了替换 TODO):
```dart
// TODO: 上传接口待用户提供(UploadApi.uploadImages 已预留位置)
// final urls = await UploadApi.instance.uploadImages(_images);
// 成功后把 urls 提交给"创建笔记"接口,再 Navigator.pop 返回。
await Future<void>.delayed(const Duration(milliseconds: 500)); // 模拟请求
```
---
## 五、踩坑记录
1. **千万别在 GridView 里用 `Image.file` 判断平台**——`dart:io` 在 Web 编译不过,必须用 `AssetEntityImage`(原生)/ `Image.memory`(Web);
2. `PickedImage` 是不可变对象:直接 `addAll` 进列表再 `setState` 即可,**不需要深拷贝**;
3. 上传前校验:没选图时点击要提示,别发空请求;
4. `setState` 里 `_images.removeAt(index)` 即可删图,网格随 `itemCount` 变化自动收缩;
5. 满 9 张时加号格用 `SizedBox.shrink()` 占位而不是直接不渲染,**保持网格布局稳定**。
---
## 六、扩展思考
- 选图弹层本身是另一个封装(`showPickSheet` 内部是 `showModalBottomSheet`),见 [smooth_sheet_demo.md](smooth_sheet_demo.md) 的"模态 vs 非模态"对比;
- 上传动作的通用包装(loading 防重入、finally 复位)见 [share_demo.md](share_demo.md) 的 `_sharing` 模式;
- 真实项目:把 TODO 处换成 `UploadApi.uploadImages`,再把返回的 url 列表交给"创建笔记"接口——本页结构不用动。
+121
View File
@@ -0,0 +1,121 @@
# 【Demo】OSS 直传(客户端签名 + PUT 上传)
> 源文件:`lib/pages/demo/oss_upload_demo.dart` | 分类:网络
> 一句话:选图 → 后端分配路径 → 拿 STS → V4 签名 → PUT 直传 OSS,全程带进度。
---
## 一、这个 Demo 解决什么问题
图片上传不走"客户端 → 业务服务器 → OSS"的转发链路(浪费带宽、服务器压力大),
而是 **STS 临时凭证 + 客户端直传 OSS**:后端只负责分配路径和签发凭证,图片字节直接传阿里云。
### 完整链路(与 UniApp ossUpload.js 对照)
```
选图 → 后端分配路径(GetXxxOSSUploadUrl)
→ 拿 STS 凭证(GetAliOSSSetting)
→ V4 签名
→ PUT 直传 OSS(带进度)
→ 返回 CDN URL
```
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: OssUploadDemoPage()));
```
1. 选择上传场景:头像 / 笔记 / AI判题(`UploadScene` 枚举,对应后端 mode);
2. "选择图片"(走 PickerService,见 [note_picker_demo.md](note_picker_demo.md));
3. "上传到 OSS":读字节 → 4 步链路 → 显示 CDN URL + 进度。
> ⚠ 需要后端 OSS 接口可访问(本地/测试环境)。
---
## 三、核心知识点(新手向)
### 1. 双端读取图片字节(不用 dart:io!)
```dart
// web :image_picker 已读好字节
// 原生 :photo_manager 的 AssetEntity.originBytes(纯 Dart API)
final Uint8List bytes = image.bytes ??
await image.entity?.originBytes ??
Uint8List(0);
if (bytes.isEmpty) { /* 提示重新选择 */ }
```
**关键:页面不 import `dart:io`,Web 才能编译**(dart:io 在 Web 是禁用的)。
双端都能拿字节 → 上传逻辑统一。
### 2. 上传入口(OssUploader 封装)
```dart
final url = await OssUploader.uploadImage(
bytes: bytes,
fileName: fileName,
scene: _scene, // UploadScene 枚举:头像/笔记/AI判题
onSendProgress: (sent, total) {
if (total > 0) {
setState(() => _progress = sent / total); // total<=0 兜底
}
},
);
```
### 3. 403 的含义
**403 = 签名或 STS 有问题** —— 看控制台 LogInterceptor 打印的响应体定位:
- AccessDenied:签名计算不一致(method/path/时间戳对不上);
- InvalidAccessKeyId / ExpiredToken:STS 凭证失效;
- SignatureDoesNotMatch:V4 签名算法细节错。
---
## 四、关键代码剖析
```dart
// 场景名映射(switch 表达式,Dart 3)
static String _sceneName(UploadScene s) => switch (s) {
UploadScene.avatar => '头像',
UploadScene.note => '笔记',
UploadScene.aiGrading => 'AI判题',
};
// 场景选择(上传中禁用切换)
Wrap(
spacing: 10.w,
children: UploadScene.values
.map((s) => ChoiceChip(
label: Text(_sceneName(s)),
selected: _scene == s,
onSelected: _uploading ? null : (_) => setState(() => _scene = s),
))
.toList(),
)
// 上传按钮(没选图或上传中 → 禁用)
FilledButton.icon(
onPressed: (_image == null || _uploading) ? null : _upload,
icon: const Icon(Icons.cloud_upload_outlined),
label: Text(_uploading ? '上传中...' : '上传到 OSS'),
)
```
---
## 五、踩坑记录
1. **别用 `Image.file` 双端判断** —— dart:io 在 Web 编译不过,统一走 bytes;
2. **403 优先查签名**:method/URL 编码/时间格式与后端约定不一致是头号原因;
3. `total <= 0` 时进度要兜底(和 dio_pro_demo 同一个坑);
4. 上传中要禁用场景切换和重复点击(`_uploading` 统一控制);
5. 文件名生成:`image.name ?? 'image_时间戳.jpg'`,避免空名。
---
## 六、扩展思考
- **防盗链/私有读**:OSS 私有 Bucket 的 URL 需要签名,展示时再向后端换"临时公开 URL";
- 断点续传/分片:大文件(视频)用 OSS 分片上传 API,`uploadFile` 封装在 OssUploader 里扩展;
- 与 [dio_pro_demo.md](dio_pro_demo.md) 对照:PUT 直传的进度就是 `onSendProgress` 的应用;
- 笔记图片多图上传:先全部直传拿 URL,再一次性提交给"创建笔记"接口(见 note_picker_demo 的 TODO 注释)。
+107
View File
@@ -0,0 +1,107 @@
# 【Demo】选择器页:pop 带值返回
> 源文件:`lib/pages/demo/picker_page.dart` | 分类:路由
> 一句话:路由返回值的经典用法 —— 点"男/女",把结果带回上一个页面。
---
## 一、这个 Demo 解决什么问题
"选性别"这类**选择器页**:打开 → 点选项 → 关闭并把结果带回调用页。
本页是 34 行的最小完整例子,专门讲清 **pop 的第二个参数**。
```
首页 await push 选择器页 → 用户点"男" → Navigator.pop(context, '男')
→ 首页的 await 拿到 '男' → 弹 SnackBar 展示
```
> 对照 Vue:等价于 `this.$emit('select', '男')` 后关闭弹窗——Flutter 里返回值走 `pop` 的第二个参数。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp()); // 从首页"方式 2"按钮进入本页
```
首页点"选择性别" → 本页两个选项(男/女)→ 点任一 → 回到首页并弹 SnackBar 显示结果;
按系统返回键(不选)→ 首页提示"未选择"。
---
## 三、核心知识点(新手向)
### 1. pop 的第二个参数就是"返回值"
```dart
ListTile(
title: const Text('男'),
onTap: () => Navigator.pop(context, '男'), // 第二个参数:返回给上一页的值
),
```
### 2. 与 push 侧配对使用
```dart
// 接收侧(router_home_page.dart)
final result = await Navigator.push<String>(context, MaterialPageRoute(...));
// 返回侧(本页)
Navigator.pop(context, '男');
```
- `pop(context, value)` 的值类型与 `push<String>` 的泛型对应;
- **用户没选(系统返回键 / 手势返回)→ 没调 pop 带值 → 接收侧拿到 `null`**,所以接收侧要 `?? '未选择'` 兜底。
### 3. pop 之后,页面就销毁了
`Navigator.pop` 会把当前路由出栈并销毁。所以**pop 之后再不能用本页的 context**(页面没了)——这也是"await 之后判 mounted"问题的另一面。
---
## 四、关键代码剖析
```dart
class PickerPage extends StatelessWidget {
const PickerPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('选择性别')),
body: Column(
children: [
ListTile(
leading: const Icon(Icons.male, color: Color(0xFF3B82F6)),
title: const Text('男'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.pop(context, '男'), // ← 带值返回
),
Divider(height: 1.h, indent: 16, endIndent: 16),
ListTile(
leading: const Icon(Icons.female, color: Color(0xFFF06292)),
title: const Text('女'),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.pop(context, '女'),
),
],
),
);
}
}
```
全程没有 `setState`、没有返回值声明——**StatelessWidget 就够了**,选择器页是最典型的"纯跳转 + 带值返回"场景。
---
## 五、踩坑记录
1. **pop 不带值 = 返回 null**:用系统返回键关页时不会触发任何 `pop(context, value)`,接收侧必须处理 null;
2. **类型要配对**:`push<String>` 对应 `pop(context, '男')`;如果你 push 没写泛型,pop 的值类型就是 dynamic,接收侧要强转;
3. pop 之后本页 context 已失效,别再 await 什么(本页没有异步,天然安全);
4. 更多返回值的真实场景:日期选择、城市选择、筛选条件——结构都和本页一致。
---
## 六、扩展思考
- 接收侧完整代码见 [router_home_page.md](router_home_page.md)(`await push` + `context.mounted` + `?? '未选择'`);
- 参数**传入**的两种通道见 [detail_page.md](detail_page.md)(构造参数 vs 命名路由 arguments);
- 进阶:`Navigator.pop` 可以返回任意类型(对象、Map),比如"选了一个城市"返回整个城市模型,接收侧依然按泛型拿。
+118
View File
@@ -0,0 +1,118 @@
# 【Demo】响应式布局:一套代码适配手机 / Pad
> 源文件:`lib/pages/demo/responsive_demo.dart` | 分类:基础布局
> 一句话:屏幕适配 ≠ 响应式布局 —— 一个是"等比缩放",一个是"改变结构"。
---
## 一、这个 Demo 解决什么问题
同一个页面,手机单列列表、Pad 双列卡片墙,**一套代码自动切换**。
新手最容易混淆的概念在这里讲清楚。
### 两个维度(务必分清)
| 概念 | 手段 | 解决什么 |
|---|---|---|
| **屏幕适配** | flutter_screenutil(`.w/.h/.sp`) | 不同屏幕比例不变形(尺寸等比缩放) |
| **响应式布局** | 断点 + LayoutBuilder(本 Demo) | 不同屏幕宽度改变布局**结构**(单列/双列) |
> 两者配合使用:尺寸用 `.w/.h/.sp` 缩放,结构用断点切换。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ResponsiveDemoPage()));
```
- 顶部模式条实时显示"手机模式 / Pad 模式 + 当前宽度 dp";
- 手机(<600dp)→ 单列 ListTile 列表;
- Pad(≥600dp)→ 多列卡片网格(列数按宽度自适应)。
---
## 三、核心知识点(新手向)
### 1. 断点标准(Material 官方)
| 宽度 | 设备 |
|---|---|
| < 600dp | 手机 |
| 600 ~ 839dp | 小平板 / 折叠屏 |
| >= 840dp | 大平板 / 桌面 |
> ⚠ 关键:判断的是**屏幕宽度**,不是 Platform!
> iOS 也有 iPad,Android 也有平板 —— 用平台判断永远分不清它们。
### 2. 两种拿宽度的方式
```dart
MediaQuery.sizeOf(context).width // 整个屏幕宽度
LayoutBuilder(...) // 父容器实际给你的可用宽度
```
- 页面铺满屏幕时两者一样;
- 页面内有**可伸缩区域**(侧栏收起/展开)时必须用 `LayoutBuilder`;
- 本 Demo 用 `LayoutBuilder`,因为能拿到最真实的可用宽度。
### 3. 核心切换逻辑
```dart
LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final isTablet = width >= _tabletBreakpoint; // 600dp
// 列数自适应:每列至少 280dp 宽,最多 4 列
final columns = (width / _minCardWidth).floor().clamp(1, 4);
return isTablet
? _buildGrid(columns) // Pad:多列网格
: _buildList(); // 手机:单列列表
},
)
```
---
## 四、关键代码剖析
```dart
// 手机列表项
ListView.separated(
padding: EdgeInsets.all(12.w),
itemCount: _items.length,
separatorBuilder: (_, _) => SizedBox(height: 8.h),
itemBuilder: (context, i) => _ListTileItem(item: _items[i]),
)
// Pad 卡片网格(同一个数据源 _items,只是换了一种展示容器)
GridView.builder(
padding: EdgeInsets.all(12.w),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columns, // 由宽度算出来的列数
mainAxisSpacing: 12.w,
crossAxisSpacing: 12.w,
childAspectRatio: 1.4, // 宽高比,数字大了卡片变矮
),
itemCount: _items.length,
itemBuilder: (context, i) => _CardItem(item: _items[i]),
)
```
**关键思想:数据不变,变的是"容器"** —— 同一份 `_items`,手机渲染成列表项,Pad 渲染成卡片。
组件只负责"怎么显示一条数据",页面负责"按宽度选容器"。
---
## 五、踩坑记录
1. **别用 `Platform.isAndroid` 判断手机/Pad** —— 永远用宽度断点;
2. `MediaQuery.sizeOf(context).width` 拿的是全屏宽,页面嵌在可伸缩容器里会算错 —— 用 LayoutBuilder;
3. `(width / 280).floor()` 在窄屏可能算出 0 列 —— 必须 `.clamp(1, 4)` 兜底;
4. 断点值用常量集中管理(`_tabletBreakpoint = 600`),别散落在各页面写死魔法数字。
---
## 六、扩展思考
- **官方推荐**:`flutter/foundation` 的 `defaultTargetPlatform` + 宽度断点结合;或引入 `responsive_builder` 包;
- 平板横竖屏切换:竖屏 8.4" ≈ 800dp,横屏 1280dp —— 断点要覆盖两种情况;
- 进阶模式:`LayoutBuilder` + `CustomScrollView`([sliver_demo.md](sliver_demo.md))组合做"响应式长列表";
- 本项目 theme_demo 的 `PageTheme` 按页面层级给配色,响应式按宽度给结构 —— 两个维度正交,可自由组合。
+123
View File
@@ -0,0 +1,123 @@
# 【Demo】富文本渲染(RichTextContent:HTML → 题目/解析)
> 源文件:`lib/pages/demo/rich_text_demo.dart` | 分类:组件封装
> 一句话:后端给的 HTML(题目/解析/听力材料)渲染成富文本 —— 还支持字号调节、深色模式、自定义样式。
---
## 一、这个 Demo 解决什么问题
教育 App 的题目/解析是**后端返回的 HTML 字符串**(`<h3>`、`<img>`、`<table>`、`<audio>`…),
前端要把它渲染成漂亮的可交互富文本。本页演示 `RichTextContent`(`lib/core/components/rich_text_content.dart`):
- 完整渲染:标题、粗体、**上标下标**、`<ul>` 选项、`<table>` 表格;
- `<audio>` 标签**自动替换成本项目的 AudioPlayerCard**;
- 学生端能力:**字号 Aa± 实时调节、深色模式切换**(通过 `textStyle` 注入);
- 自定义样式:`.answer` 类 → 红色加粗(`customStylesBuilder`)。
> 对照 Vue:相当于 `v-html` + 样式定制,但 Flutter 没有浏览器引擎,得用 flutter_html 这类包逐标签渲染。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: RichTextDemoPage()));
```
- 顶部控制栏:滑块拖字号(12~24)、切深色开关 → 正文实时变化;
- 页面里:化学方程式上下标、本地图片(`asset://` 协议)、表格、听力音频、视频、红色加粗的答案 B。
---
## 三、核心知识点(新手向)
### 1. 一行渲染 HTML
```dart
RichTextContent(
html: _html, // 后端返回的 HTML 字符串
// 字号 + 深色模式都通过 textStyle 注入(学生端统一入口)
textStyle: TextStyle(
fontSize: _fontSize,
height: 1.6,
color: _dark ? Colors.white70 : const Color(0xFF333333),
),
)
```
**`textStyle` 注入是核心设计**:页面不重建 HTML、不改渲染器,只改一个 TextStyle 就全局换字号/颜色——学生端"无障碍调节"的标准做法。
### 2. 自定义样式:customStylesBuilder
```dart
customStylesBuilder: (element) {
if (element.className.contains('answer')) {
return const {'color': '#EF4444', 'font-weight': 'bold'};
}
return null; // 其他元素不特殊处理
},
```
后端 HTML 里写 `<span class="answer">B</span>` → 渲染成红色加粗。**按 class 定制样式**,不用后端改结构。
### 3. 本地图片:asset:// 协议
```html
<img src="asset://assets/img/luola_logo.png" width="160" height="160"/>
```
`asset://` + 资源路径 = 加载本地资源图片(最稳,不受网络影响)。实际后端返回完整 URL 或相对路径(相对路径需业务层拼 baseUrl)。
### 4. audio 标签自动替换
```html
<audio src="asset://assets/audio/sample-30s.mp3" title="听力材料(30 秒)" controls></audio>
```
组件内部检测 `<audio>` → 替换成 `AudioPlayerCard`(见 [audio_player_demo.md](audio_player_demo.md))——听力题直接能播。
---
## 四、关键代码剖析
```dart
static const String _html = '''
<h3>第 1 题 · 阅读理解</h3>
<p>光合作用是植物利用光能将<strong>二氧化碳</strong>和<strong>水</strong>合成有机物并释放氧气的过程。化学方程式:6CO<sub>2</sub> + 6H<sub>2</sub>O → C<sub>6</sub>H<sub>12</sub>O<sub>6</sub> + 6O<sub>2</sub>,面积用 x<sup>2</sup> 表示。</p>
<img src="asset://assets/img/luola_logo.png" width="160" height="160"/>
<ul>
<li>A. 光合作用只发生在夜间</li>
<li>B. 光合作用会释放氧气</li>
</ul>
<table border="1" cellpadding="6">
<tr><th>条件</th><th>有光</th><th>黑暗</th></tr>
<tr><td>释放 O₂</td><td>√</td><td>×</td></tr>
</table>
<audio src="asset://assets/audio/sample-30s.mp3" title="听力材料(30 秒)" controls></audio>
<p><b>【解析】</b>正确答案:<span class="answer">B</span>。</p>
''';
```
字号控制条(12~24 共 12 档):
```dart
Slider(
value: _fontSize,
min: 12,
max: 24,
divisions: 12, // 档位数 → 滑块只能停在整数
activeColor: const Color(0xFF3B82F6),
onChanged: (v) => setState(() => _fontSize = v),
)
```
---
## 五、踩坑记录
1. **textStyle 只影响文本**:图片、表格、AudioPlayerCard 是各自样式——深色模式下卡片白底不随主题变(业务侧需按需适配);
2. **内联 CSS 只支持常用属性**,复杂布局会降级(flutter_html 的边界,别拿它当浏览器);
3. 公式(LaTeX)本 demo 未含:以后在 `RichTextContent` 的 `customWidgetBuilder` 里扩展(如 flutter_math_fork);
4. 相对路径图片要业务层拼 baseUrl,别把裸相对路径直接塞进 html;
5. 深色模式只是 `_dark` 布尔 + 换色,**页面底色/AppBar 也要跟着换**(本页手动处理了,主题化见 [theme_demo.md](theme_demo.md))。
---
## 六、扩展思考
- `<audio>` 替换成的播放器组件见 [audio_player_demo.md](audio_player_demo.md);
- 富文本在真实考试页的运用见 [exam_demo.md](exam_demo.md)(题干 HTML 渲染);
- 学生端"字号调节 + 深色模式"这类**偏好设置**,进阶做法是存进全局状态(Riverpod),见 [exam_architecture.md](../exam_architecture.md) 的 Riverpod 章节;
- 真实项目:`_html` 换成接口返回的题目 HTML,`asset://` 换成 CDN 完整 URL 即可。
+99
View File
@@ -0,0 +1,99 @@
# 【Demo】路由导航:应用入口(RouterApp)
> 源文件:`lib/pages/demo/router_demo.dart` | 分类:路由
> 一句话:一个只负责"组装"的入口文件 —— 注册路由表、指定首页。
---
## 一、这个 Demo 解决什么问题
把路由导航 Demo 的三个页面(首页 / 详情页 / 选择器页)组装成一个可运行的应用。
演示的是**组件化结构 + 命名路由表**:
```
lib/pages/demo/router_demo.dart → 应用入口(只组装,不写页面)
lib/pages/demo/router_home_page.dart → 首页(三个跳转按钮)
lib/pages/demo/detail_page.dart → 详情页(接收参数)
lib/pages/demo/picker_page.dart → 选择器页(pop 带值返回)
```
> 对照 Vue:RouterApp ≈ `main.ts + router/index.ts`;每个页面一个文件 ≈ `views/` 目录。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp());
```
首页三个按钮,演示三种跳转姿势:
1. push + 构造函数传参;
2. push 选择器并 **await 接收 pop 返回的结果**;
3. pushNamed 命名路由 + arguments 传参。
---
## 三、核心知识点(新手向)
### 1. 命名路由表
```dart
MaterialApp(
title: '路由导航 Demo',
theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)),
// 命名路由表:name -> 页面构造(详情页在这里注册)
routes: {
'/detail': (context) => const DetailPage(),
},
home: const RouterHomePage(),
)
```
- 路由名规范:`/xxx` 开头,如 `/detail`、`/user/info`;
- 注册后才能用 `Navigator.pushNamed(context, '/detail')`;
- `home` 是应用启动后的第一个页面(相当于 Vue 的默认路由)。
### 2. 命名规范
| 内容 | 规范 |
|---|---|
| 文件名 | snake_case(小写下划线):`router_home_page.dart` |
| 类名 | PascalCase:`RouterHomePage` |
| 引入 | 跨文件用 `import` |
| 文件组织 | **每个公开类一个文件** |
---
## 四、关键代码剖析
```dart
class RouterApp extends StatelessWidget {
const RouterApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '路由导航 Demo',
theme: ThemeData(colorSchemeSeed: const Color(0xFF3B82F6)),
routes: {
'/detail': (context) => const DetailPage(),
},
home: const RouterHomePage(),
);
}
}
```
---
## 五、踩坑记录
1. **入口文件只负责组装**,别在里面堆业务代码——页面跳转见 [router_home_page.md](router_home_page.md);
2. 命名路由的 `arguments` 是 dynamic,详情页取值要判空强转(见 [detail_page.md](detail_page.md));
3. 真实项目路由更复杂(嵌套/守卫/转场),用 go_router 或 auto_route 包,本项目 demo 用原生 Navigator 讲清原理。
---
## 六、扩展思考
- 三种跳转姿势的详细讲解见 [router_home_page.md](router_home_page.md);
- 参数接收的两种方式见 [detail_page.md](detail_page.md);
- 真实项目里 MaterialApp 在 `lib/main.dart`,路由表在 `core/router/` 或 go_router 的配置里——原理与本页一致。
+104
View File
@@ -0,0 +1,104 @@
# 【Demo】路由首页:三种跳转姿势
> 源文件:`lib/pages/demo/router_home_page.dart` | 分类:路由
> 一句话:push 传参 / await 接返回值 / pushNamed 命名路由 —— 跳转的三个基本姿势。
---
## 一、这个 Demo 解决什么问题
页面跳转是 App 的基本操作。本页演示三种姿势及其适用场景:
1. **push + 构造函数传参**:参数少、类型安全,最常用;
2. **push 并等待返回结果**:从"选择器页"带值回来(选性别等);
3. **pushNamed 命名路由 + arguments**:按路由名跳转,参数以 Map 传递。
> 核心思想:**页面跳转是"入栈/出栈"**(Navigator 是个栈)。
> push = 压栈,pop = 出栈;pop 时带值 → push 的 await 拿到值。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const RouterApp());
```
- 方式 1:跳详情页(张三,18);
- 方式 2:跳选择器页,选"男/女" → 返回首页弹 SnackBar 显示选择结果;
- 方式 3:跳详情页(李四,25,走命名路由)。
---
## 三、核心知识点(新手向)
### 1. 方式 1:push + 构造函数传参(类型安全,首选)
```dart
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const DetailPage(name: '张三', age: 18),
),
);
```
- 参数是 Dart 强类型:写错类型编译期就报错;
- 页面构造写在 `builder` 里(路由是"惰性构建",进入页面才执行)。
### 2. 方式 2:await push 接收 pop 返回值
```dart
onPressed: () async {
// 注意:push 返回的是 Future,用 await 接收 pop 带回来的值
final result = await Navigator.push<String>(
context,
MaterialPageRoute(builder: (_) => const PickerPage()),
);
if (context.mounted) { // await 之后必须判 mounted!
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('你选择了:${result ?? '未选择'}')),
);
}
},
```
- 泛型 `<String>` 声明返回类型;
- 用户点系统返回键(没有 pop 带值)→ result 为 null,`?? '未选择'` 兜底;
- **await 后 context.mounted 必须判**(页面可能已被销毁)。
### 3. 方式 3:pushNamed 命名路由 + arguments
```dart
Navigator.pushNamed(
context,
'/detail', // 必须在 MaterialApp.routes 里注册过
arguments: {'name': '李四', 'age': 25},
);
```
- arguments 是 `dynamic`(任意类型),取值需判空强转(见 [detail_page.md](detail_page.md));
- 适合路由名集中管理的场景,代价是**丢失了类型安全**。
---
## 四、关键代码剖析
```dart
// 页面底部常驻的知识点提示(学习型 Demo 的惯例)
Text(
'知识点:页面跳转是【入栈/出栈】。'
'返回页 pop 时带值 → 接收页 await push 拿到值。'
'命名路由的 arguments 是 dynamic,取值需判空强转。',
)
```
---
## 五、踩坑记录
1. **`await` 之后用 `context` 必须先判 `context.mounted`**(Flutter 3.7+ 的推荐写法,替代旧式 `if (!mounted) return`);
2. `pushNamed` 没在 routes 注册 → 运行期 `onUnknownRoute` 报错/黑屏;
3. 忘了 `await push` 就收不到 pop 返回值;
4. 返回结果用泛型约束:`Navigator.push<String>`,别让类型随意流转。
---
## 六、扩展思考
- pop 带值的一方见 [picker_page.md](picker_page.md)(`Navigator.pop(context, '男')`);
- 参数接收的两种通道见 [detail_page.md](detail_page.md);
- 真实项目推荐 go_router:声明式路由 + 守卫 + 深链,但底层仍是 Navigator 这套入栈/出栈机制。
+91
View File
@@ -0,0 +1,91 @@
# 【Demo】Seed 色板验证页(colorSchemeSeed 原理)
> 源文件:`lib/pages/demo/seed_palette_demo.dart` | 分类:基础布局
> 一句话:所有"没写死颜色"的 Material 组件,换一个 seed 就整体变色 —— 本页就是证据。
---
## 一、这个 Demo 解决什么问题
新手经常困惑:`colorSchemeSeed` 到底有什么用?为什么有的页面换主题变色,有的不变?
答案:**MaterialApp 的 `theme.colorSchemeSeed` 会自动生成整套 ColorScheme**,
页面里**不指定颜色**的 Material 组件(FilledButton/Checkbox/Switch/进度圈/聚焦输入框等)
颜色全部取自这套色板。本页把所有"不写死颜色"的组件摆在一起,换 seed 看整体变色。
---
## 二、怎么运行 / 演示内容
```bash
flutter run # 默认品牌 → 本页所有组件 SkyBlue 蓝
flutter run --dart-define=BRAND=chucai # 换品牌 → 本页所有组件变 Red 红
```
`--dart-define` 是编译期变量,由 `core/config/app_config.dart` 读取并决定 `seedColor`。
> 对照登录页为什么不变色:登录页所有颜色写死 `Color(0xFF3B82F6)` → 永远蓝色,不随主题走。
> **写死了就不跟主题,没写死才跟** —— 这是理解主题系统的关键。
---
## 三、核心知识点(新手向)
### 1. 色板怎么生成的
```dart
ThemeData(colorSchemeSeed: seedColor)
```
Material 的 ColorScheme.fromSeed 算法从种子色推出一整套颜色:
primary / onPrimary / secondary / tertiary / error / surface 等几十个语义色。
组件默认取语义色:按钮取 primary、进度圈取 primary、Checkbox 选中取 primary……
### 2. 本页验证清单(全部未写死颜色)
- 按钮组:FilledButton / OutlinedButton / TextButton;
- Checkbox / Switch / ChoiceChip 选中态;
- CircularProgressIndicator / LinearProgressIndicator;
- TextField 聚焦边框 + 光标。
### 3. 取色值显示
```dart
String _hex(Color c) {
final v = c.toARGB32() & 0xFFFFFF; // Flutter 3.27+ 用 toARGB32 取色值
return '#${v.toRadixString(16).padLeft(6, '0').toUpperCase()}';
}
```
---
## 四、关键代码剖析
```dart
// 当前品牌主题(来自 AppConfig,启动时由 --dart-define 决定)
final brandTheme = AppConfig.theme;
// 展示 seed 色块 + 品牌信息
Container(
width: 56.w, height: 56.h,
decoration: BoxDecoration(color: brandTheme.seedColor, ...),
),
Text('主题 ${brandTheme.name} · seed ${_hex(brandTheme.seedColor)}'),
// 一个不写颜色的组件(示例)—— 它的颜色来自当前 seed 色板
FilledButton(onPressed: () {}, child: const Text('FilledButton')),
```
---
## 五、踩坑记录
1. **`.w/.h` 的 widget 不能 const** —— 本页 CircularProgressIndicator 外面套的 SizedBox 就踩过
(`const_eval_extension_method` 编译错误);
2. `toARGB32()` 是新 API,旧的 `.value` 已废弃;
3. 别在页面里 `Theme.of(context).colorScheme.primary` 到处取 —— 组件不写颜色才是正道;
4. `--dart-define` 是编译期常量,热重载不生效,改品牌要重新 run。
---
## 六、扩展思考
- 二级主题(页面级细节)见 [theme_demo.md](theme_demo.md);
- 想精确控制色板:`ColorScheme.fromSeed(seedColor: x, primary: y, ...)` 手动覆盖某些槽位;
- 动态切换主题(运行时换色):Theme 局部覆盖或 AnimatedTheme,不依赖 --dart-define。
+150
View File
@@ -0,0 +1,150 @@
# 【Demo】分享功能演示(ShareService 五个场景)
> 源文件:`lib/pages/demo/share_demo.dart` | 分类:组件封装
> 一句话:文本分享 / 选图分享 / Widget 截图生成海报分享 / 截图预览 / 保存相册 —— 一个服务类覆盖全部分享场景。
---
## 一、这个 Demo 解决什么问题
"分享"是 App 的标配能力,但场景很多:分享文字、分享图片、**把 UI 截图成海报分享**、保存到相册。
本页演示 `ShareService`(`lib/core/services/share_service.dart`)的 5 个场景:
| # | 场景 | 方法 | 用途 |
|---|---|---|---|
| 1 | 分享文本/链接 | `shareText(text:)` | 邀请链接、文案 |
| 2 | 选图后分享 | `shareImage(filePath:, text:)` | 分享单张图片 |
| 3 | Widget 截图 → 分享 | `shareFromWidget(context:, text:, builder:)` | **攻克海报分享** |
| 4 | Widget 截图预览 | `captureWidget(context:, builder:)` | 截图不发,先看效果 |
| 5a | 选图保存相册 | `saveToGallery(filePath:)` | 存图 |
| 5b | 截图保存相册 | `saveToGalleryFromBytes(bytes:)` | 海报存图 |
> 核心思想:**"分享"是平台能力,不是页面能力** —— 封装成 Service 单例,任何页面一行调用。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ShareDemoPage()));
```
- ① 输入框改文案 → 分享文本;
- ② 选一张图 → 系统分享面板;
- ③ 点"截图并分享" → 海报 Widget 被截成图片 → 分享面板;
- ④ 点"仅截图预览" → 弹窗显示截图 + 图片大小(KB);
- ④ 场景的两个按钮:`选图→保存` / `截图→保存` 写入系统相册。
---
## 三、核心知识点(新手向)
### 1. 场景 3 的核心:Widget → 图片 → 分享
```dart
final result = await ShareService.instance.shareFromWidget(
context: context,
text: '我攻克了一道难题,快来挑战!',
builder: (_) => _buildPosterWidget( // 任意 Widget 都能截图
userName: '张三同学',
taskName: '二次函数综合练习',
score: 98,
),
);
```
海报就是一个**普通 Container**(300 宽、渐变背景、纯 UI 无交互)——不是图片素材,是代码画出来的。
分享时被 `captureWidget` 截成 PNG 字节,再交给系统分享。
### 2. 场景 4:截图预览 = bytes → Image.memory
```dart
final bytes = await ShareService.captureWidget(context: context, builder: ...);
if (bytes == null) { _showResult('截图失败'); return; }
showDialog(
context: context,
builder: (ctx) => Dialog(
child: Column(children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(bytes, width: 280), // 字节流直接渲染
),
Text('图片大小:${(bytes.length / 1024).toStringAsFixed(1)} KB'),
]),
),
);
```
`captureWidget` 返回 `Uint8List?`——**null 表示截图失败**,必须判空。
### 3. 防重入 + 复位:异步按钮的三段式
```dart
setState(() => _sharing = true); // ① 锁
try {
...await 分享...
} catch (e) {
_showResult('分享失败:$e');
} finally {
if (mounted) setState(() => _sharing = false); // ③ 无论成败都解锁
}
// 按钮:_shareButton(onPressed: _sharing ? null : onPressed) ② 置灰
```
`_sharing` 置灰所有分享按钮,防连点重复弹系统面板;`finally` 保证异常也不卡死按钮。
### 4. Web 端限制:路径分享不可用
```dart
final filePath = picked.first.path;
if (filePath == null) {
_showResult('Web 端暂不支持文件路径分享'); // Web 拿不到真实文件路径
return;
}
```
---
## 四、关键代码剖析
```dart
/// 模拟"攻克海报"布局(实际项目中从业务数据构建)
Widget _buildPosterWidget({
required String userName,
required String taskName,
required int score,
}) {
return Container(
width: 300,
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
gradient: LinearGradient( // 海报背景:紫蓝渐变
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF667EEA), Color(0xFF764BA2)],
),
borderRadius: BorderRadius.all(Radius.circular(16)),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.school, color: Colors.white, size: 40), // Logo
const Text('爱学蝶变', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
...
Text('$score', style: const TextStyle(color: Colors.white, fontSize: 48, fontWeight: FontWeight.w800, height: 1)),
]),
);
}
```
海报 Widget 的纪律:**固定宽度 + 纯展示 + 不依赖页面状态**(参数全传入)——这样截图永远稳定。
---
## 五、踩坑记录
1. **captureWidget 必须在页面挂载后调用**(context 有效),否则截图 null;
2. **bytes 判空**:`if (bytes == null) return;`,别直接 `Image.memory(bytes!)` 崩掉;
3. Web 端 `picked.first.path` 为 null——图片分享/存相册要走别的通道,代码里已降级提示;
4. 所有按钮共享 `_sharing` 一把锁:分享中所有分享按钮都禁用,防止并发弹面板;
5. `finally` 里必须判 `mounted` 再 setState(await 期间页面可能已销毁);
6. 海报是"代码画的图",字体/图标依赖系统字体,跨设备效果略有差异——正式海报可换用真实图片素材。
---
## 六、扩展思考
- 选图用的 `PickerService.showPickSheet` 与缩略图渲染见 [note_picker_demo.md](note_picker_demo.md);
- 异步三段式(锁 + try/catch + finally 复位)同样用在 [note_picker_demo.md](note_picker_demo.md) 的上传和 [dio_demo.md](dio_demo.md) 的请求里;
- 真实项目:"攻克海报"数据来自答题结果接口,把 `_buildPosterWidget` 的参数换成接口返回的 userName/taskName/score 即可;
- `ShareService` 内部用 share_plus / screenshot / gal 插件——想学封装思想可以直接读它的源码。
+114
View File
@@ -0,0 +1,114 @@
# 【Demo】CustomScrollView + Sliver 混排布局
> 源文件:`lib/pages/demo/sliver_demo.dart` | 分类:基础布局
> 一句话:折叠 AppBar + 网格 + 长列表"整页只滚一次",靠 Sliver 碎片拼装。
---
## 一、这个 Demo 解决什么问题
页面要同时包含"可折叠的大图 AppBar + 网格区块 + 长列表区块"时:
- **ListView 不能嵌套 ListView/GridView**(同向滚动会冲突,控制权打架);
- 正解是 `CustomScrollView`:把所有区块变成 Sliver"碎片"拼在一起,**共用一个滚动控制器,整页只滚一次**。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: SliverDemoPage()));
```
页面从上到下:折叠大图 AppBar → 网格标题 → 12 格网格 → 列表标题 → 50 条列表,
下滑时 AppBar 收起固定,其余内容连续滚动。
---
## 三、核心知识点(新手向)
### 1. Sliver 是什么
Sliver = "碎片"。`CustomScrollView` 把多个 sliver 拼成一个统一滚动视图。
### 2. 常用 sliver 对照表
| Sliver | 作用 | 对应普通组件 |
|---|---|---|
| `SliverAppBar` | 可折叠/固定的 AppBar | AppBar |
| `SliverToBoxAdapter` | 把普通组件(文字/卡片)塞进滚动流 | 任意 Widget |
| `SliverGrid.builder` | 网格区块(懒加载) | GridView |
| `SliverList.builder` | 列表区块(懒加载) | ListView.builder |
| `SliverPadding` | 给某个 sliver 加内边距 | Padding |
### 3. 核心结构
```dart
CustomScrollView(
slivers: [
SliverAppBar( // ① 折叠 AppBar
expandedHeight: 180.h,
pinned: true, // 滚动时固定在顶部
flexibleSpace: FlexibleSpaceBar(
title: Text('Sliver 系列 Demo'),
background: DecoratedBox(...), // 展开时的大图
),
),
SliverToBoxAdapter( // ② 普通文字标题
child: Padding(...Text('网格区块(SliverGrid)')),
),
SliverPadding( // ③ 网格区块(带外边距)
padding: EdgeInsets.all(16.w),
sliver: SliverGrid.builder(...),
),
SliverList.builder( // ④ 列表区块
itemCount: 50,
itemBuilder: ...,
),
],
)
```
### 4. 判断口诀(什么时候用 Sliver)
- 页面是"**一个网格**"或"**一个列表**" → `GridView` / `ListView` 更简单;
- 页面是"**折叠头 + 网格 + 长列表**"混排 → `CustomScrollView + slivers`。
---
## 四、关键代码剖析
### 折叠 AppBar(本页的灵魂)
```dart
SliverAppBar(
expandedHeight: 180.h, // 展开时高度(大图区域)
pinned: true, // 上滑时缩成一条标题栏并固定在顶部
backgroundColor: Color(0xFF3B82F6),
flexibleSpace: FlexibleSpaceBar(
title: Text('Sliver 系列 Demo'),
centerTitle: true,
background: DecoratedBox( // 展开时显示的大图(这里是渐变)
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF8E6FE8)],
),
),
),
),
)
```
---
## 五、踩坑记录
1. **slivers 里的组件必须是 Sliver 家族**,普通 Widget(如 Container)直接放会报类型错误——
要用 `SliverToBoxAdapter` 包一层;
2. **同向嵌套滚动是禁区**:CustomScrollView 里不能再放 ListView/GridView;
3. `SliverAppBar` 不设 `pinned` 时,上滑后 AppBar 会完全滚出屏幕(floating 可以浮回)——按产品需求选;
4. Sliver 的懒加载与 GridView/ListView 相同:builder 系列才懒加载。
---
## 六、扩展思考
- **Sliver 与 GridView 的本质关系**(重点记忆):
`GridView = CustomScrollView + 1 个 SliverGrid`,两者都是"单个滚动 + 懒加载",区别只在混排能力;
- 进阶 Sliver:`SliverPersistentHeader`(吸顶 Tab)、`SliverAnimatedList`(增删动画列表)、`SliverAppBar` 的 `snap` 模式;
- 电商首页 = Sliver 混排的教科书场景:搜索栏吸顶 + 轮播 + 宫格 + 瀑布流商品;
- 对照 [responsive_demo.md](responsive_demo.md):Sliver 解决"滚动结构",响应式解决"不同宽度下的结构",两者可叠加。
+125
View File
@@ -0,0 +1,125 @@
# 【Demo】常驻多停靠底部面板(PersistentSnapSheet)
> 源文件:`lib/pages/demo/smooth_sheet_demo.dart` | 分类:组件封装
> 一句话:模拟"附近学校"地图页 —— 背景可交互 + 底部常驻拖拽面板,3 个停靠点自动吸附。
---
## 一、这个 Demo 解决什么问题
地图类页面常见的交互:**底部常驻一个可拖拽面板**,像抽屉一样有多个停靠位置。
本页模拟教育 App 的"附近学校"页面:
| 停靠点 | 比例 | 显示内容 |
|---|---|---|
| 收起 | 12% 屏高 | 只有手柄 + "附近学校"标题 |
| 半屏 | 50% 屏高 | 前 2 所学校 + "查看全部" |
| 展开 | 85% 屏高 | 完整学校列表,内部 ListView 可滚动 |
关键交互:面板**自由拖拽、松手自动吸附**最近停靠点;**背景(地图)始终可交互**(非模态);面板内列表滚到顶继续下拉可收起面板。
> 核心组件:`PersistentSnapSheet`(`lib/core/components/persistent_snap_sheet.dart`),是对 smooth_sheets 包的封装。
> 对照 Vue:`showBottomSheet` / 抽屉组件的"常驻版"——不挡背景、可多档位。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: SmoothSheetDemoPage()));
```
- 拖拽面板松手 → 观察自动吸附最近停靠点;
- 收起状态点地图背景 → 背景仍可交互(非模态);
- 展开面板后滚动列表到底再下拉 → 面板收起;
- 顶部 3 个按钮可代码控制面板跳到指定停靠点。
---
## 三、核心知识点(新手向)
### 1. 三行配置:停靠点比例
```dart
/// 3 个停靠点:底部收起 / 半屏 / 大半屏
static const List<double> _snapFactors = [0.12, 0.5, 0.85];
```
`snapFactors` 是**占屏高的比例**(0.12 = 12% 屏高),组件负责换算成像素并做吸附动画。
### 2. 结构:Stack 背景层 + 面板层
```dart
Scaffold(
body: Stack(
children: [
_buildMapBackground(context), // 背景层:模拟地图(真实项目换地图 SDK)
PersistentSnapSheet( // 面板层:常驻面板
snapFactors: _snapFactors,
child: Column(
children: [
_buildHandle(context), // 手柄区域
Expanded(child: _buildSheetContent(context)), // 面板内容
],
),
),
],
),
)
```
### 3. 经典拖拽指示条(36×4 圆角小横条)
```dart
Container(
width: 36.w,
height: 4.h,
decoration: BoxDecoration(
color: const Color(0xFFD1D5DB),
borderRadius: BorderRadius.circular(2.r),
),
)
```
这是 iOS/微信风格的"可拖拽暗示":看到它就明白面板能拉。
### 4. 非模态:面板盖住背景,但不拦截背景交互
`PersistentSnapSheet` 是**常驻布局组件**(不是 `showModalBottomSheet` 弹窗),
面板下面的地图照常能点——这是"常驻面板"和"模态弹层"的本质区别。
---
## 四、关键代码剖析
```dart
/// 面板内部内容:分割线 + 可滚动学校列表(与面板手势联动)
Expanded(
child: ListView.separated(
padding: EdgeInsets.symmetric(horizontal: 16.w),
itemCount: _schools.length,
separatorBuilder: (_, _) => Divider(height: 1.h, color: const Color(0xFFF1F5F9)),
itemBuilder: (context, index) => _SchoolListTile(
school: _schools[index],
index: index,
),
),
),
```
- 面板里放 **Expanded + ListView**:面板多高,列表多高;
- 列表滚到顶部继续下拉 → 手势被组件接住 → 面板收起(smooth_sheets 的核心联动,本页零代码实现);
- 列表项 `_SchoolListTile` 用图标 + 双行文本 + 导航按钮组成,颜色按 `index % 8` 轮换(8 色数组)。
---
## 五、踩坑记录
1. **snapFactors 是屏高比例,不是固定像素**——改屏幕/横竖屏后停靠点自动适配,别写死 300.0 这类像素值;
2. 想"盖住背景不可交互"的场景(确认弹窗)**别用 PersistentSnapSheet**,用 `showModalBottomSheet`(见 [note_picker_demo.md](note_picker_demo.md) 的选图弹层);
3. 面板内滚动列表必须用 **Expanded 包裹**,否则列表高度 = 内容高度,滚不动;
4. 手柄区域 `HitTestBehavior.opaque` 保证空白处也能接住拖拽手势;
5. 真实项目把 `_buildMapBackground` 换成高德/百度地图 SDK 组件即可,面板层完全不用动。
---
## 六、扩展思考
- 对比模态弹层:`showModalBottomSheet` 的用法见 [note_picker_demo.md](note_picker_demo.md)(PickerService 内部);
- 面板里的列表复用 [list_demo.md](list_demo.md) 的 `ListView.separated` 知识点;
- 面板内容进阶:搜索框 + 学校筛选(联动 [input_demo.md](input_demo.md) 的 TextField 知识);
- 组件本体 `PersistentSnapSheet` 在 `lib/core/components/persistent_snap_sheet.dart`——读它 = 学 smooth_sheets 包的使用。
+119
View File
@@ -0,0 +1,119 @@
# 【Demo】Stack + Positioned 层叠布局
> 源文件:`lib/pages/demo/stack_demo.dart` | 分类:基础布局
> 一句话:UI 重叠 = CSS 的 `position: absolute`,本页附赠一个真实踩过的"全 Positioned 尺寸崩溃"大坑。
---
## 一、这个 Demo 解决什么问题
做"头像 + 右上角角标"、"卡片 + 半透明蒙层"、"悬浮按钮"这类**元素重叠**的 UI。
Flutter 用 `Stack` 实现:
- `Stack` = 图层容器(**后写的子组件盖在上面**);
- `Positioned` = 给子组件做绝对定位(**只能直接放在 Stack 的 children 里**)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: StackDemoPage()));
```
页面演示 5 块内容:
1. 基础重叠:蓝色盒子 + 右上角悬空角标 + 居中文字;
2. `Positioned.fill` 半透明蒙层;
3. 坑①复现:全 Positioned 时 Stack 尺寸异常;
4. 修复方案 1:SizedBox 固定宽高;
5. 修复方案 2:非 Positioned 底层组件撑尺寸。
---
## 三、核心知识点(新手向)
### 1. Stack 的两个核心参数
```dart
Stack(
alignment: Alignment.topLeft, // 对【没被 Positioned 包裹】的子组件做默认对齐
clipBehavior: Clip.none, // 允许子组件绘制超出 Stack 边界(默认 Clip.hardEdge 会裁剪)
children: [...],
)
```
### 2. Positioned 的用法
```dart
Positioned(
top: 10, // 距 Stack 上边
right: -10, // 负值 = 超出边界(配合 clipBehavior: Clip.none 才能显示)
child: ...,
)
Positioned.fill( // 填满整个 Stack —— 做蒙层最方便
child: Container(color: Colors.black54),
)
```
### 3. 布局规则一句话
Stack 的尺寸 = **非 Positioned 子组件**决定的(取其中最大的那个);
Positioned 组件只是"贴"在上面,不参与 Stack 自身尺寸计算。
---
## 四、关键代码剖析(含大坑)
### 坑①:全部子组件都是 Positioned → Stack 尺寸异常
```dart
// 真实行为:Stack 尺寸不由内容决定,直接取父约束最大值(constraints.biggest)
Stack(
children: [
Positioned(top: 10, left: 10, child: Text("A")),
],
)
```
- 放在**有界约束**里(如 SizedBox 300×60)→ Stack 占满整个 300×60,而不是收缩到文字大小;
- 裸放在 **Column 等无界高度**里 → debug 模式直接断言崩溃:
`A Stack requires bounded constraints`,**整个 body 都画不出来**(只看到标题栏)。
### 修复方案 1:给 Stack 套 SizedBox 固定宽高
```dart
SizedBox(
width: 300.w,
height: 80.h,
child: Stack(children: [Positioned(top: 10, left: 10, child: Text("A"))]),
)
```
### 修复方案 2:放一个非 Positioned 的底层组件撑起大小
```dart
Stack(
children: [
ColoredBox( // 非 Positioned 底层组件:由它决定 Stack 尺寸
color: Color(0xFFE8E8E8),
child: SizedBox(width: 300.w, height: 80.h),
),
Positioned(top: 10, left: 10, child: Text("A")),
],
)
```
> 经验:**Stack 里永远留一个"地基"**(非 Positioned 的底层组件或外层 SizedBox),
> 既避免崩溃,也方便后续加 Positioned 不担心尺寸突变。
---
## 五、踩坑记录
1. **Positioned 只能直接作为 Stack 的 children**,中间套了其他 Widget 会失效(编译警告)。
2. **做悬浮角标务必 `clipBehavior: Clip.none`**,否则负偏移部分被裁掉看不见。
3. **全 Positioned 的 Stack 在无界约束下崩溃**(见上),这是本页作者真实遇到过的 bug:页面只剩标题。
4. `Container` 在 Flutter 3.44 已不是 const 构造,外层 `Center` 别写 `const`(编译报错)。
5. 想让内容很短也能拉动滚动(演示页效果),用 `physics: BouncingScrollPhysics()` / `AlwaysScrollableScrollPhysics()`。
---
## 六、扩展思考
- 蒙层进阶:`Positioned.fill` + 半透明色 + 点击手势 = 简易弹窗/引导层;
- 角标进阶:红点角标(Badge 组件)本质也是 Stack + Positioned 的封装;
- 与 [list_demo.md](list_demo.md) 对照:列表右下角的"回到顶部"FAB 就是 `Stack + Positioned` 实现的;
- 与 [smooth_sheet_demo.md](smooth_sheet_demo.md) 对照:地图背景 + 底部面板就是"Stack 背景层 + 面板层"的组合。
+148
View File
@@ -0,0 +1,148 @@
# 【Demo】Text 文本组件全家桶
> 源文件:`lib/pages/demo/text_demo.dart` | 分类:基础布局
> 一句话:样式、对齐、截断、行高、富文本、可点击协议文本 —— 文本相关的细节一次看全。
---
## 一、这个 Demo 解决什么问题
Text 是 Flutter 里出现频率最高的组件,但细节坑不少:
- `textAlign` 为什么有时不生效?
- 超长文字怎么显示省略号?
- "已阅读并同意《用户协议》"这种**部分文字可点击**怎么做?
- 系统字体缩放怎么适配?
本页 7 个区块逐一演示。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: TextDemoPage()));
```
1. 基础样式(fontSize / color / fontWeight / fontStyle);
2. 对齐 textAlign(左/中/右);
3. 截断三件套(ellipsis / clip / softWrap:false);
4. 行高 height 对比(默认 / 1.2 / 2);
5. 字间距 letterSpacing / 词间距 wordSpacing;
6. 富文本 Text.rich + TextSpan + 可点击协议;
7. 字体缩放 textScaler。
---
## 三、核心知识点(新手向)
### 1. Text 全家桶速记
| 场景 | 组件 |
|---|---|
| 单样式文字 | `Text` |
| 富文本拼接(不同颜色/可点击段落) | `Text.rich` + `TextSpan` |
| 全局默认样式 | `DefaultTextStyle`(MaterialApp 自带,Text 会继承) |
### 2. textAlign 生效的前提
**Text 必须有宽度约束**,对齐才有空间生效:
```dart
Container(
width: 300.w,
child: Column(
// 关键:stretch 让每个子组件横向拉满,textAlign 才有空间生效
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('左对齐', textAlign: TextAlign.left),
Text('居中对齐', textAlign: TextAlign.center),
Text('右对齐', textAlign: TextAlign.right),
],
),
)
```
> 新手最常见的疑惑:在 Column 里写 `textAlign: center` 不居中 —— 因为 Text 宽度只包住文字。
> 要么 `crossAxisAlignment: center`(让 Text 居中),要么 `stretch` + `textAlign`(让文字在拉满的宽度里居中)。
### 3. 截断三件套(必须配齐)
```dart
Text(
'很长很长...',
maxLines: 1,
overflow: TextOverflow.ellipsis, // 末尾加省略号
)
```
- `ellipsis`:省略号;`clip`:直接裁掉;`softWrap: false` + `visible`:真·溢出画到盒子外;
- 前提同样是**有界宽度**(外面套固定宽 Container)。
### 4. 行高与间距
```dart
TextStyle(height: 1.2) // 行高倍数(1.0 = 紧凑,2.0 = 松散)
TextStyle(letterSpacing: 4) // 字间距(中文按字)
TextStyle(wordSpacing: 8) // 词间距(按空格分词,中文基本无效)
```
### 5. 富文本 + 可点击(协议文本的官方姿势)
```dart
Text.rich(
TextSpan(
children: [
TextSpan(text: '已阅读并同意', style: TextStyle(color: Color(0xFF888888))),
TextSpan(
text: '《用户协议》',
style: TextStyle(color: Color(0xFF3B82F6), fontWeight: FontWeight.bold),
recognizer: _agreementRecognizer, // 点击手势(必须 dispose!)
),
],
),
)
```
`TapGestureRecognizer` 必须放在 **StatefulWidget 里创建,并在 dispose() 里释放**(与 createState 一一对应,防内存泄漏)。
### 6. 字体缩放(Flutter 3.x)
```dart
Text('线性放大 1.5 倍', textScaler: TextScaler.linear(1.5));
// 读取系统缩放:MediaQuery.textScalerOf(context)
```
---
## 四、关键代码剖析
```dart
class _AgreementRichTextState extends State<_AgreementRichText> {
// late final:整个 State 生命周期只创建一次,复用同一个 recognizer
late final TapGestureRecognizer _agreementRecognizer = TapGestureRecognizer()
..onTap = _handleAgreementTap;
void _handleAgreementTap() {
// 点击回调可能晚于 dispose(点击瞬间页面被切走),用 mounted 兜底
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('点了《用户协议》')),
);
}
@override
void dispose() {
_agreementRecognizer.dispose(); // 关键:释放 recognizer
super.dispose();
}
}
```
---
## 五、踩坑记录
1. **`textScaleFactor` 已废弃**(Flutter 3.x)→ 用 `textScaler` / `MediaQuery.textScalerOf(context)`;
2. **`withOpacity` 已废弃** → 用 `withValues(alpha: x)`;
3. textAlign 不生效 = 没有宽度约束(用 stretch 或固定宽);
4. 截断不生效 = 没有 `maxLines`(三件套要配齐);
5. `TapGestureRecognizer` 不 dispose 会内存泄漏(页面反复进出后崩溃/卡顿)。
---
## 六、扩展思考
- 富文本进阶:本项目的 `RichTextContent`([rich_text_demo.md](rich_text_demo.md))渲染 HTML 富文本(题目/解析),
底层就是 TextSpan 体系的自动生成;
- 全局字体缩放:`MaterialApp(builder: ...)` 里统一包 `MediaQuery` 改造,实现"阅读字号调节"(见 rich_text_demo 的 Aa±);
- 文本选择:`SelectableText` 让用户可复制(本项目的日志区就用它)。
+128
View File
@@ -0,0 +1,128 @@
# 【Demo】品牌换肤(两级主题模型)
> 源文件:`lib/pages/demo/theme_demo.dart` | 分类:基础布局
> 一句话:换品牌 = 一级换整套色板 + 二级换页面细节,一套代码全 App 变色。
---
## 一、这个 Demo 解决什么问题
教育 App 常有"多品牌运营"需求:不同机构定制不同主色、页面配色、甚至标题文案。
本 Demo 演示项目核心的**两级主题模型**:
```
一级 = 品牌:seedColor 生成整套 Material 色板(全 App 共享)
二级 = 页面细节(PageTheme):按页面层级(home/detail/result)
区分背景色 / 头部色 / 强调色 / 专属 banner / 标题文案
```
页面只声明"我是哪个层级",细节从品牌对象按层级查出来:
**换品牌 → 一级色板全换 + 二级细节全换(一套代码)**。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: ThemeDemoPage()));
```
- 顶部 ChoiceChip 切换品牌(从注册表 `brandThemes` 自动生成按钮,新增品牌自动出现);
- "一级"区:显示 seed 色与生成的 primary 对比 + 跟随主题的按钮/进度条;
- "二级"区:home/detail/result 三个层级各渲染一个页面骨架预览(背景/头部/强调色/文案全跟随品牌)。
---
## 三、核心知识点(新手向)
### 1. 主题的两层结构(模型已解耦,见 `core/theme/`)
| 层 | 内容 | 存放 |
|---|---|---|
| 模型 + 注册表 | `BrandTheme` / `PageTheme` / `PageLevel` + `brandThemes` | `core/theme/brand_theme.dart` |
| 每品牌配置 | 只有具体色值/文案 | `core/theme/brands/xxx_theme.dart` |
新增一个品牌 = 新建一个配置文件 + 注册表加一行,**业务代码零改动**。
### 2. 一级:colorSchemeSeed 的原理
```dart
ThemeData(colorSchemeSeed: _brand.seedColor)
```
传一个种子色,Material 自动生成整套 ColorScheme(primary/secondary/tertiary...),
**没写死颜色的 Material 组件全部自动跟随**(FilledButton/Checkbox/Switch/进度条/聚焦输入框)。
验证页见 [seed_palette_demo.md](seed_palette_demo.md)。
> ⚠ colorSchemeSeed 生成的 primary 不是精确色值(Material 会调和),
> 要求精确用 `ColorScheme.fromSeed(...)` 后手动覆盖 primary。
### 3. 二级:PageTheme 按层级查询
```dart
// 页面只声明层级(本 demo 直接取,真实项目从路由/参数拿)
final page = _brand.pages[PageLevel.home]!;
// 使用:背景 page.bg、头部 page.headerBg、强调 page.accent、banner page.banner
```
- banner 存资源路径(如 `assets/img/banner_sky.png`),页面 `Image.asset(page.banner!)` 即可跟随换图;
- 本 demo 没准备两套图片,用"强调色渐变块"模拟 banner 占位(代码里标注了替换点)。
### 4. 局部主题覆盖(不依赖全局 MaterialApp 的切换法)
```dart
return Theme(
data: ThemeData(colorSchemeSeed: _brand.seedColor),
child: Scaffold( ... ), // 页面内组件都从局部主题取色
);
```
> 真实项目把 `_brand` 换成 Riverpod Provider + MaterialApp 的 theme,切换点移到根层。
---
## 四、关键代码剖析
```dart
// 品牌切换(注册表驱动,新增品牌自动出现)
Wrap(
spacing: 8,
children: [
for (final b in brandThemes)
ChoiceChip(
label: Text(b.name),
selected: _brand.name == b.name,
onSelected: (_) => setState(() => _brand = b),
),
],
)
// 二级细节消费示例(页面骨架预览)
Container(
color: page.bg, // ← 二级:背景
child: Column(children: [
Container(
color: page.headerBg, // ← 二级:头部色
child: Text(page.title), // ← 二级:标题文案
),
// banner 占位:真实项目替换成 Image.asset(page.banner!)
Container(decoration: BoxDecoration(gradient: LinearGradient(colors: [page.accent, page.headerBg]))),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: page.accent), // ← 二级:强调色
child: const Text('本页主按钮'),
),
]),
)
```
---
## 五、踩坑记录
1. **colorSchemeSeed 生成的 primary 不精确** —— 需要精确色值时手动覆盖;
2. **局部 `Theme(data:)` 必须包在 Scaffold 外面**,AppBar 才能取到(包在 body 里 AppBar 取不到);
3. **PNG 位图不跟主题着色** —— 能用矢量(IconData)就别放多份图;
4. 深色模式要单独配 `darkTheme` + `themeMode`,不是简单换个 seed;
5. `.w/.h/.sp` 的组件不能 const(`const_eval_extension_method`)。
---
## 六、扩展思考
- 持久化:品牌选择存 shared_preferences,启动时读出来传给 MaterialApp 的 theme;
- 动态切换动画:`AnimatedTheme` 包 MaterialApp,换主题有过渡动画;
- 与 [seed_palette_demo.md](seed_palette_demo.md) 对照:一个验证"seed 生效",一个演示"两级模型的消费";
- 更重的多主题需求可引入 `flex_color_scheme` 包,本项目的轻量模型够用且无依赖。
+144
View File
@@ -0,0 +1,144 @@
# 【Demo】Wrap 自动换行布局 + Chip 标签家族
> 源文件:`lib/pages/demo/wrap_demo.dart` | 分类:基础布局
> 一句话:Row 一行排不下就溢出报错,Wrap 会自动换行 —— 做"标签流"必备。
---
## 一、这个 Demo 解决什么问题
"搜索历史标签"、"科目标签"、"技能标签"这类**数量不固定**的横向排列场景:
- `Row` 一行排不下会**直接溢出报错**(黄色条纹警告),不会换行;
- `Wrap` 空间不够时**自动换到下一行**,是标签流的正解。
本 Demo 还完整演示了 Flutter 的 **Chip 标签家族**:`Chip` / `ActionChip` / `FilterChip`,
以及一个**像素级可控的自绘标签 `CustomTag`**(设计稿要求精确高度/间距时的选择)。
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: WrapDemoPage()));
```
页面是一个 Wrap,里面混排了 8 种 Chip 样式 + 自绘 CustomTag:
带图标、可删除、可点击、选中态、深色底、胶囊圆角……一屏看完所有变体。
---
## 三、核心知识点(新手向)
### 1. Wrap 的核心属性
```dart
Wrap(
direction: Axis.horizontal, // 排布方向(默认水平)
spacing: 24, // 同一行内 item 之间的水平间距
runSpacing: 20, // 行与行之间的垂直间距(run = "行/批")
children: [...],
)
```
### 2. 对比记忆
| 组件 | 行为 |
|---|---|
| `Row` | 横向排列,**溢出报错**,不换行 |
| `Wrap` | 横向排列,**空间不足自动换行** |
| `Column` | 纵向排列,溢出报错(想纵向换行用 `Wrap(direction: Axis.vertical)`) |
### 3. Chip 家族速查(Flutter 3.x 注意!)
| 组件 | 用途 | 必填回调 |
|---|---|---|
| `Chip` | 纯展示 / 可删除标签 | `onDeleted` 可选 |
| `ActionChip` | 可点击的标签(**旧 `Chip.onPressed` 已移除**) | `onPressed` |
| `FilterChip` | 带选中态的标签(**旧 `Chip.selected` 已移除**) | `onSelected` |
```dart
// 可删除
Chip(
label: const Text("Flutter"),
deleteIcon: Icon(Icons.close, size: 16.w),
onDeleted: () => print("删除 Flutter"),
)
// 可点击(Flutter 3.x 用 ActionChip)
ActionChip(
label: const Text("UniApp"),
onPressed: () => print("点击 UniApp"),
)
// 选中态(Flutter 3.x 用 FilterChip)
FilterChip(
label: const Text("JavaScript"),
selected: true,
selectedColor: const Color(0xFF4CAF50),
onSelected: (_) {},
)
```
### 4. 自绘标签 CustomTag 为什么存在
标准 Chip 的高度/内边距是 Material 规范定的,**设计稿要求"固定 24px 高、图标与文字精确 4px"时**,
Chip 调不精确 —— 这时用 `Container + Row` 手写一个,完全可控:
- `InkWell` 提供点击涟漪(`borderRadius` 让涟漪跟随圆角);
- `TextStyle(height: 1)` 去掉文字默认行高,避免把容器撑高。
---
## 四、关键代码剖析
```dart
// 自绘标签骨架(像素级可控的核心结构)
return Material(
color: Colors.transparent, // 消除 InkWell 默认的白底
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(4.r),
child: Container(
height: 24.h, // 精确高度
padding: EdgeInsets.only(left: 6.w, right: 6.w),
decoration: BoxDecoration(
color: const Color(0xFFEDF2FF),
borderRadius: BorderRadius.circular(4.r),
border: Border.all(color: const Color(0xFFD6E4FF)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 12.w, color: iconColor),
SizedBox(width: 4.w), // 图标与文字精确 4px
],
Text(label, style: TextStyle(fontSize: 12.sp, height: 1)),
if (onDeleted != null) ...[
SizedBox(width: 2.w),
GestureDetector(onTap: onDeleted, child: Icon(Icons.close, size: 12.w)),
],
],
),
),
),
)
```
要点:
- `Material + InkWell` 组合才有水波纹;`InkWell` 直接包 `Container` 时涟漪会被容器背景盖住;
- 用 `if (xx != null) ...[]` 实现"可选 icon / 可选删除叉"——传 `null` 就不渲染。
---
## 五、踩坑记录
1. **`Chip.onPressed` / `Chip.selected` 在 Flutter 3.x 已移除**,分别改用 `ActionChip` / `FilterChip`(旧代码会编译报错)。
2. `Wrap` 外层要给它**有界的宽度**(比如直接当 body 用,或放 `Padding` 里),否则换行无从谈起。
3. 自绘标签里文字务必 `height: 1`,否则默认行高会让"24px 高"变成"30px+ 高"。
4. `print` 只适合 Demo 调试,真实项目用 `debugPrint` 或日志组件(见 dio_demo 的注释)。
---
## 六、扩展思考
- **`Flow`**:Wrap 的高阶版,自定义换行算法(间距均匀分布、瀑布流),性能更好但代码量大;
- 标签流 + 搜索历史的真实场景:数据存本地(shared_preferences)→ 读取渲染 Wrap → 点击标签直接搜索;
- Chip 的 `shape: StadiumBorder()` = 胶囊全圆角,`avatar` 放小图标、`labelPadding` 控制文字内边距;
- 本项目 `theme_demo` 里的品牌切换、`note_picker_demo` 里的"加号格",都用了 Wrap/网格思路,可对照学习。
+128
View File
@@ -0,0 +1,128 @@
# 【Demo】接口 + Isar + watch 完整链路(答错记错题)
> 源文件:`lib/pages/demo/wrong_flow_demo.dart` | 分类:存储
> 一句话:回答"现在不都调接口吗?" —— 接口、本地库、响应式三层协作的真实业务链路。
---
## 一、这个 Demo 解决什么问题
答错一题,要同时完成三件事:
1. **接口上报**(远端):服务端记录错题 —— 可能失败、可能慢;
2. **本地落库**(Isar):错题本快照,**离线也能看**;
3. **watch 响应式**:本地库一变,错题列表自动刷新。
```
流程:答错 → ① 上报接口(失败也继续)→ ② 写本地错题本 → ③ watch 自动推送 → 列表自动更新
```
---
## 二、怎么运行 / 演示内容
```dart
runApp(const MaterialApp(home: WrongFlowDemoPage()));
```
- 三道内置题,答错 → 完整链路日志(接口 → 本地 → watch);
- "模拟断网"按钮:跳过接口,只写本地(演示离线兜底);
- 下方错题本列表:watch 订阅,零手动刷新。
> ⚠ 依赖 Isar(native only),Web 端降级提示。
---
## 三、核心知识点(新手向)
### 1. 为什么接口失败本地仍记录(离线兜底)
学生端弱网是常态。**错题是"本地刚需数据"**——先落库先能用;
联网后再**增量同步**回服务端(真实项目用 sync 队列,错题加 `isSynced` 字段标记,
本例为演示未做,代码里有注释)。
```dart
// ① 上报接口:失败也继续(本地兜底)
await _reportToServer(wrongAnswer: _options[index]);
// ② 写本地错题本:watch 自动推送 → 下方列表自动更新
await WrongQuestionStore.instance.add(
questionId: 100 + _qIndex,
title: _question,
wrongAnswer: _options[index],
);
```
### 2. 别把本地写入放接口 onError 里!
```dart
try {
await DioClient.instance.post('/posts', data: {...});
_appendLog('📡 接口上报成功');
} on ApiException catch (e) {
// 接口失败不阻断:本地仍要记,联网后再同步
_appendLog('📡 接口上报失败:${e.message}');
_appendLog(' ↳ 本地仍记录,联网后增量同步(sync 队列)');
} finally {
if (mounted) setState(() => _reporting = false);
}
```
**独立的两步**:上报和落库互不依赖。
如果写在 onError 里,就成了"只有失败才记录"——在线答错的题反而不进错题本,逻辑就反了。
### 3. 对比"纯接口 + 手动刷新"
不 watch 就得:答完错题 → 手动通知错题 tab 刷新(事件总线/页面重建)。
watch 让 UI 直接订阅数据库,**写入和展示彻底解耦**。
### 4. Dart 3 记录类型题库
```dart
// 题库:(题干, 选项, 正确答案索引)
static const List<(String, List<String>, int)> _bank = [
('7 × 8 = ?', ['54', '56', '58', '63'], 1),
('Flutter 官方推荐的状态管理?', ['Provider', 'Riverpod', 'BLoC', 'GetX'], 1),
];
String get _question => _bank[_qIndex].$1; // 题干
List<String> get _options => _bank[_qIndex].$2; // 选项
int get _rightIndex => _bank[_qIndex].$3; // 正确答案索引
```
---
## 四、关键代码剖析
```dart
// 答题:对 → 不记录;错 → 接口上报 + 本地错题(watch 自动刷新)
Future<void> _onPick(int index) async {
if (_reporting) return; // 上报中防重入
final isRight = index == _rightIndex;
setState(() { _selected = index; _isRight = isRight; });
if (isRight) {
_appendLog('✅ 回答正确,不记错题');
} else {
_appendLog('❌ 答错「${_question}」(选了 ${_options[index]}),进入链路...');
await _reportToServer(wrongAnswer: _options[index]); // ① 上报
await WrongQuestionStore.instance.add(...); // ② 落库
_appendLog('💾 已写入本地错题本 → watch 自动刷新列表');
}
if (!mounted) return;
setState(() { _qIndex = (_qIndex + 1) % _bank.length; _selected = null; _isRight = null; });
}
```
---
## 五、踩坑记录
1. **watch 只在 native 可用**(web 空实现),本页 `kIsWeb` 降级提示;
2. **写库必须在 writeTxn 内**(store 已封装);
3. **接口上报和本地写入是两个独立步骤**,别把本地写入放接口 onError 里;
4. 上报中要防重入(`_reporting` 禁用选项点击),否则连点会重复上报;
5. 异步(await)之后 setState 前判 mounted。
---
## 六、扩展思考
- **sync 队列进阶**:错题表加 `isSynced` 字段 → 启动/联网时查 `isSynced == false` 批量补报 → 成功后置位;
- 与 [isar_watch_demo.md](isar_watch_demo.md) 对照:一个讲"watch 本身",一个讲"完整业务链路";
- 上报失败的重试策略可复用 `DioClient` 的指数退避(见 [dio_pro_demo.md](dio_pro_demo.md));
- 真实项目:本页的 `_bank` 换成接口拉取的题,`WrongQuestionStore.add` 换成带 userId 的写入。