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