08. Flutter 鸿蒙实战 08:故事详情页,编辑、删除、重新生成与底部弹层

故事详情不只展示正文,还要处理编辑、删除、语音、活化和数据同步。
详情页职责
StoryDetailPage 是整个项目里逻辑最复杂的页面之一。它要显示故事、照片、人物、地点、时间、语音讲述、照片活化状态,还要支持编辑、删除、重新生成故事。文件位于 lib/features/stories/story_detail_page.dart。
页面接收预加载数据:
dart
class StoryDetailPage extends StatefulWidget {
const StoryDetailPage({
super.key,
required this.storyId,
required this.repository,
this.initialStory,
this.initialPhotoInfo,
this.initialVideoFilePath,
});
}
这样从列表进入详情时,可以先展示已有内容,不必等详情接口全部完成。
初始化和刷新
详情页 initState 中先使用初始数据,如果缺少再请求:
dart
_story = widget.initialStory;
_photoInfo = widget.initialPhotoInfo;
_syncAnimationPolling(_photoInfo);
if (_story == null || _photoInfo == null) {
_load();
} else {
unawaited(_load(forceRefresh: false));
}
这种模式适合移动端详情页:先展示缓存,再后台校正。用户看到内容更快,数据也不会长期停留在旧状态。
编辑故事
编辑采用底部弹层,不跳新页面:
dart
final draft = await showModalBottomSheet<StoryEditDraft>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (_) => _StoryEditSheet(story: story),
);
移动端表单用底部弹层比新页面轻。故事详情仍在背景里,用户知道自己正在编辑哪条故事。
保存和同步元数据
保存故事时,不只更新故事正文,还会同步照片的年代、地点、人物等元数据:
dart
final shouldOfferRegenerate = _shouldOfferRegenerate(story, draft);
final contentChanged = story.content.trim() != draft.content.trim();
final updated = await widget.repository.update(story.id, draft);
await widget.repository.updatePhotoMetadata(
updated.photoId,
era: draft.era,
location: draft.location,
persons: draft.persons,
);
故事和照片元数据是两套数据,但它们在业务上有关联。用户修改地点、人物后,照片本身也应该同步更新,否则地图、故事详情、照片管理页会出现不一致。
是否需要重新生成
当年代、地点、日期、人物变化时,旧故事正文可能不再准确。项目用 _shouldOfferRegenerate 判断是否提示重新生成:
dart
bool _shouldOfferRegenerate(Story story, StoryEditDraft draft) {
return story.era.trim() != draft.era.trim() ||
story.location.trim() != draft.location.trim() ||
!_sameDate(story.storyDate, draft.storyDate) ||
!_sameStringList(story.persons, draft.persons);
}
这个逻辑比"保存后总是重生成"更合理。只改标题或正文时,不需要重新生成;改影响故事语义的字段时,再给用户选择。
删除确认
故事删除必须二次确认。这个项目里删除故事不等于删除原始照片,删除成功后返回列表并刷新缓存。这里的数据边界必须说清楚:故事、照片、活化视频、语音缓存不是同一个东西。

详情页一致性处理
编辑前先保留原始状态
详情页编辑不是简单把表单提交出去。保存前要先拿旧 story 和新 draft 做对比,判断哪些字段真的发生变化。
dart
final shouldOfferRegenerate = _shouldOfferRegenerate(story, draft);
final contentChanged = story.content.trim() != draft.content.trim();
contentChanged 决定是否清理旧语音状态,shouldOfferRegenerate 决定是否提示重新生成故事。先比较再保存,页面就能根据变化类型做不同处理。
正文变化要清理语音状态
如果用户编辑了故事正文,旧语音已经不再对应新正文。项目里保存后会检查 contentChanged,然后重置音频状态。这个细节能避免"文字是新的,播放还是旧的"的错位体验。
详情页状态拆分
详情页要同时处理展示和编辑
故事详情页不是单纯展示正文。它还要处理编辑、删除、重新生成、语音播放、视频播放、照片元数据同步等动作。为了避免状态混乱,页面把关键状态拆成多个布尔值:
dart
bool _saving = false;
bool _deleting = false;
bool _regenerating = false;
bool _audioLoading = false;
bool _audioLoaded = false;
bool _audioPlaying = false;
这些状态分别对应不同按钮和区域。保存中不应该禁用音频播放,音频加载中也不应该影响删除确认。状态拆得细,页面交互才不会一忙就全灰。
编辑故事后要同步照片元数据
故事里的年代、地点、人物并不只属于故事正文,也会影响地图、照片管理和重新生成逻辑。项目在保存故事后,会继续调用 updatePhotoMetadata 同步照片元数据。
dart
final updated = await widget.repository.update(story.id, draft);
await widget.repository.updatePhotoMetadata(
updated.photoId,
era: draft.era,
location: draft.location,
persons: draft.persons,
);
如果只更新故事,不更新照片,地图页可能仍然读到旧地点,照片列表也可能显示旧人物信息。详情页看起来保存成功了,其他模块却不同步,这就是跨模块数据边界没处理好。
什么时候提示重新生成
不是每次编辑都要重新生成故事。正文改了,就直接保存正文;年代、地点、日期、人物变了,旧正文可能不再匹配新的元数据,这时才需要询问是否重新生成。
dart
bool _shouldOfferRegenerate(Story story, StoryEditDraft draft) {
return story.era.trim() != draft.era.trim() ||
story.location.trim() != draft.location.trim() ||
!_sameDate(story.storyDate, draft.storyDate) ||
!_sameStringList(story.persons, draft.persons);
}
这个判断把"文本编辑"和"业务元数据变化"区分开。用户只是润色一句话时,不会被打断;用户改了地点和人物时,系统才提示重新生成更匹配的故事。
删除必须说清数据边界
删除故事不等于删除照片。项目里删除故事后会从故事列表移除,并清掉故事详情缓存,但原始照片仍然可以继续存在。这个边界要清楚,否则用户会担心删除故事把照片也删掉。
dart
Future<void> delete(String id) async {
await _api.deleteJson('/api/stories/$id');
final cached = _storyCache;
if (cached != null) {
_storyCache = cached.where((item) => item.id != id).toList();
}
_storyDetailCache.remove(id);
}
删除动作还需要二次确认。移动端误触概率高,尤其详情页按钮区域靠近底部时,确认弹层能减少不可逆操作。
详情页验证路径
详情页至少覆盖这些操作:
text
1. 从故事列表进入详情,先展示缓存内容,再刷新接口数据。
2. 修改正文并保存,返回列表后内容同步更新。
3. 修改地点或人物后出现重新生成提示。
4. 删除故事后返回列表,被删除项不再显示。
5. 正文变化后音频状态被重置,避免播放旧内容。
详情页涉及的数据最多,验证时要跨页面看结果。只在详情页看到保存成功不够,还要返回列表、地图、照片信息确认数据是否一致。
详情操作排查
底部弹层适合承载编辑表单
故事详情页没有跳转到单独编辑页,而是用 showModalBottomSheet 打开编辑表单。这样用户仍然停留在故事上下文里,修改完成后能立即看到结果。
dart
final draft = await showModalBottomSheet<StoryEditDraft>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
),
builder: (_) => _StoryEditSheet(story: story),
);
isScrollControlled: true 对移动端表单很重要。键盘弹起后,如果弹层高度不可控,输入框容易被遮挡。编辑正文、地点、人物这类字段时,弹层要能随键盘调整。
保存动作要防止重复提交
_saving 状态不只是显示 loading,还要防止用户连续点击保存。重复提交可能导致两次接口请求交叉返回,页面最后显示旧结果。
dart
setState(() => _saving = true);
try {
final updated = await widget.repository.update(story.id, draft);
...
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
项目代码里在成功和失败分支分别收回 _saving,效果一样。重点是请求发出后,按钮状态要进入不可重复提交状态。
重新生成会影响多个区域
重新生成故事后,正文、标题、摘要、语音状态都可能变化。页面不能只替换正文,还要重置与旧正文绑定的资源。
text
故事内容变化 -> 更新 Story 缓存
正文变化 -> 清理旧音频状态
照片元数据变化 -> 重新判断是否需要生成
如果正文变了但语音状态没重置,用户可能播放到旧音频。这个问题在内容类应用里很常见,文本和衍生资源要一起维护。
删除后的返回值
详情页删除成功后,需要把"数据已变化"传回上一页。上一页收到结果后刷新列表或使用缓存删除结果。
text
详情页删除故事
-> Repository 删除缓存
-> Navigator.pop(context, true)
-> 列表页刷新或移除对应项
这种返回值比让列表页盲目刷新更明确。页面之间不用共享太多状态,只传递"发生了变化"这个事实。
详情页问题定位表
| 现象 | 优先检查 | 对应文件 | 处理方向 |
|---|---|---|---|
| 保存后列表仍是旧标题 | 缓存是否同步更新 | stories_repository.dart |
更新 _storyCache 和 _storyDetailCache |
| 修改地点后地图不变 | 照片元数据是否同步 | story_detail_page.dart |
保存故事后调用 updatePhotoMetadata |
| 重新生成后还播放旧语音 | 音频状态是否清理 | story_detail_page.dart |
正文变化后执行 _resetAudioState() |
| 删除后返回列表仍显示 | 删除结果是否传回 | 详情页和列表页 | 删除缓存并通知上一页刷新 |
详情页牵动的数据很多。只看当前页面保存成功不够,还要确认列表、地图、音频这些相关模块是否跟着更新。
编辑草稿的字段边界
编辑弹层提交的不是完整 Story,而是 StoryEditDraft。这样能避免把只读字段、服务端生成字段一起提交回去。
text
可编辑:标题、正文、年代、地点、日期、人物
不可编辑:故事 id、照片 id、创建时间、视频状态、缓存路径
草稿对象越干净,接口越不容易收到无关字段。详情页只提交用户真正能修改的内容,照片和视频这些关联数据通过专门接口维护,边界更清楚。
|
详情页牵动的数据很多。只看当前页面保存成功不够,还要确认列表、地图、音频这些相关模块是否跟着更新。