Files
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

564 lines
19 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:patbond_flutter/core/theme/app_theme.dart';
import 'package:patbond_flutter/data/demo_data.dart';
import 'package:patbond_flutter/models/models.dart';
import 'package:patbond_flutter/state/app_state.dart';
import 'package:patbond_flutter/widgets/common.dart';
enum CreationMode { image, video }
/// 创作 Tab。
///
/// **AI 生成模拟(700/650/500ms 假延时、风格/模型/分辨率设置、结果卡)
/// 属 M4 范围,T3-17 原样保留**;社区发布半边自 T3-17 起改由真实发布页
/// `PostComposePage`push 全屏)承担——本页顶部「发布动态」入口即其
/// 入口(entryPoint=create_tab),`AppState.publishPost` demo 发布流退役。
class CreatePage extends StatefulWidget {
const CreatePage({
required this.appState,
required this.onOpenCompose,
super.key,
});
final AppState appState;
/// 真实发布页入口(主壳 push,发布成功后回首页 Feed 刷新)。
final VoidCallback onOpenCompose;
@override
State<CreatePage> createState() => _CreatePageState();
}
class _CreatePageState extends State<CreatePage> {
final titleController = TextEditingController();
final contentController = TextEditingController();
CreationMode mode = CreationMode.image;
CreationStyle selectedStyle = creationStyles.first;
String selectedModel = 'Patbond-V1';
String duration = '5 秒';
String resolution = '1080P';
bool upscaling = true;
bool uploaded = false;
bool uploading = false;
bool generating = false;
int generationStep = 0;
String? resultUrl;
List<String> tags = ['可爱修勾', 'AI宠物'];
@override
void dispose() {
titleController.dispose();
contentController.dispose();
super.dispose();
}
Future<void> simulateUpload() async {
setState(() => uploading = true);
await Future<void>.delayed(const Duration(milliseconds: 700));
if (!mounted) return;
setState(() {
uploading = false;
uploaded = true;
resultUrl = null;
});
}
Future<void> generate() async {
if (!uploaded) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先选择一张宠物照片')));
return;
}
setState(() {
generating = true;
generationStep = 1;
});
for (var step = 2; step <= 4; step++) {
await Future<void>.delayed(const Duration(milliseconds: 650));
if (!mounted) return;
setState(() => generationStep = step);
}
await Future<void>.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
setState(() {
generating = false;
resultUrl = selectedStyle.image;
titleController.text = mode == CreationMode.image
? '豆豆的${selectedStyle.title}冒险'
: '豆豆的 AI 萌宠短片';
contentController.text = '刚刚用 Patbond 创作了新作品,快来看看豆豆的新造型吧!✨';
});
}
Future<void> addTag() async {
final controller = TextEditingController();
final value = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('添加话题'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(hintText: '输入话题名称'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(context, controller.text.trim()),
child: const Text('添加'),
),
],
),
);
controller.dispose();
if (value == null || value.isEmpty || tags.contains(value)) return;
setState(() => tags = [...tags, value]);
}
/// AI 作品的社区发布留待 M4:AI 结果是生成图(无本地文件、无 media
/// asset),走不了两步上传,故不接真实发布链路;demo 发布流(写
/// `AppState.posts`)随 T3-17 退役,此处只余占位提示。
void publish() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('AI 作品发布随 AI 创作能力上线(M4);发布普通动态请用上方「发布动态」')),
);
}
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_ComposeEntryCard(onTap: widget.onOpenCompose),
const SizedBox(height: 18),
SegmentedButton<CreationMode>(
segments: const [
ButtonSegment(
value: CreationMode.image,
icon: Icon(Icons.image_outlined),
label: Text('AI 图片'),
),
ButtonSegment(
value: CreationMode.video,
icon: Icon(Icons.movie_creation_outlined),
label: Text('AI 视频'),
),
],
selected: {mode},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() {
mode = value.first;
resultUrl = null;
}),
),
const SizedBox(height: 16),
_UploadCard(
uploaded: uploaded,
uploading: uploading,
imageUrl: widget.appState.pet.avatarUrl,
onTap: simulateUpload,
onRemove: () => setState(() {
uploaded = false;
resultUrl = null;
}),
),
const SizedBox(height: 16),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('生成设置', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 14),
DropdownButtonFormField<String>(
initialValue: selectedModel,
decoration: const InputDecoration(labelText: '创作模型'),
items: ['Patbond-V1', 'Pet-Art Pro', 'Cute Motion']
.map(
(model) =>
DropdownMenuItem(value: model, child: Text(model)),
)
.toList(),
onChanged: (value) {
if (value != null) setState(() => selectedModel = value);
},
),
if (mode == CreationMode.video) ...[
const SizedBox(height: 12),
_ChoiceRow(
title: '视频时长',
values: const ['5 秒', '10 秒', '15 秒'],
selected: duration,
onChanged: (value) => setState(() => duration = value),
),
],
const SizedBox(height: 12),
_ChoiceRow(
title: '分辨率',
values: const ['720P', '1080P', '2K'],
selected: resolution,
onChanged: (value) => setState(() => resolution = value),
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('高清增强'),
subtitle: const Text('提升毛发与眼睛细节'),
value: upscaling,
onChanged: (value) => setState(() => upscaling = value),
),
],
),
),
const SizedBox(height: 18),
Text('热门风格', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
SizedBox(
height: 150,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: creationStyles.length,
separatorBuilder: (_, _) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final style = creationStyles[index];
final selected = style.id == selectedStyle.id;
return InkWell(
onTap: () => setState(() {
selectedStyle = style;
resultUrl = null;
}),
borderRadius: BorderRadius.circular(20),
child: Container(
width: 125,
decoration: BoxDecoration(
border: Border.all(
color: selected ? AppColors.primary : AppColors.border,
width: selected ? 2 : 1,
),
borderRadius: BorderRadius.circular(20),
),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: style.image,
borderRadius: BorderRadius.circular(18),
),
DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
AppColors.ink.withAlpha(204),
],
),
),
),
Positioned(
left: 12,
right: 12,
bottom: 10,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
style.title,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
Text(
style.subtitle,
style: const TextStyle(
color: Colors.white70,
fontSize: 10,
),
),
],
),
),
],
),
),
);
},
),
),
const SizedBox(height: 18),
if (generating) _GenerationProgress(step: generationStep),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(52),
backgroundColor: AppColors.ink,
),
onPressed: generating ? null : generate,
icon: generating
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.auto_awesome),
label: Text(generating ? '正在生成…' : '开始生成'),
),
if (resultUrl != null) ...[
const SizedBox(height: 18),
SectionCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.check_circle, color: AppColors.success),
const SizedBox(width: 8),
Text(
'生成完成',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 14),
AspectRatio(
aspectRatio: 16 / 10,
child: RemoteImage(
url: resultUrl!,
borderRadius: BorderRadius.circular(18),
),
),
const SizedBox(height: 14),
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: '发布标题(必填)'),
),
const SizedBox(height: 12),
TextField(
controller: contentController,
maxLines: 3,
decoration: const InputDecoration(labelText: '分享正文'),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...tags.map(
(tag) => InputChip(
label: Text('#$tag'),
onDeleted: () => setState(
() =>
tags = tags.where((item) => item != tag).toList(),
),
),
),
ActionChip(
avatar: const Icon(Icons.add, size: 16),
label: const Text('话题'),
onPressed: addTag,
),
],
),
const SizedBox(height: 14),
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.location_on_outlined),
title: Text('北京市 · 朝阳区'),
trailing: Icon(Icons.chevron_right),
),
FilledButton.icon(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
),
onPressed: publish,
icon: const Icon(Icons.send_rounded),
label: const Text('发布到社区'),
),
],
),
),
],
],
);
}
}
/// 真实发布入口(T3-17):本页其余部分是 AI 创作模拟(M4),社区发帖
/// 走这里 push 的发布页。
class _ComposeEntryCard extends StatelessWidget {
const _ComposeEntryCard({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SectionCard(
padding: EdgeInsets.zero,
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
leading: const CircleAvatar(
backgroundColor: AppColors.surfaceTint,
child: Icon(Icons.edit_outlined, color: AppColors.primary),
),
title: const Text(
'发布动态',
style: TextStyle(fontWeight: FontWeight.w800),
),
subtitle: const Text('写点文字、配上照片,分享给宠友'),
trailing: const Icon(Icons.chevron_right),
onTap: onTap,
),
);
}
}
class _UploadCard extends StatelessWidget {
const _UploadCard({
required this.uploaded,
required this.uploading,
required this.imageUrl,
required this.onTap,
required this.onRemove,
});
final bool uploaded;
final bool uploading;
final String imageUrl;
final VoidCallback onTap;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
return SectionCard(
child: SizedBox(
height: 190,
width: double.infinity,
child: uploading
? const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('正在读取宠物照片…'),
],
)
: uploaded
? Stack(
fit: StackFit.expand,
children: [
RemoteImage(
url: imageUrl,
borderRadius: BorderRadius.circular(18),
),
Positioned(
right: 8,
top: 8,
child: IconButton.filled(
tooltip: '移除照片',
onPressed: onRemove,
icon: const Icon(Icons.close),
),
),
],
)
: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 28,
child: Icon(Icons.add_photo_alternate_outlined),
),
SizedBox(height: 12),
Text(
'选择宠物照片',
style: TextStyle(fontWeight: FontWeight.w800),
),
SizedBox(height: 4),
Text('演示模式会读取豆豆的档案头像'),
],
),
),
),
);
}
}
class _ChoiceRow extends StatelessWidget {
const _ChoiceRow({
required this.title,
required this.values,
required this.selected,
required this.onChanged,
});
final String title;
final List<String> values;
final String selected;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 7),
Wrap(
spacing: 8,
children: values
.map(
(value) => ChoiceChip(
label: Text(value),
selected: value == selected,
onSelected: (_) => onChanged(value),
),
)
.toList(),
),
],
);
}
}
class _GenerationProgress extends StatelessWidget {
const _GenerationProgress({required this.step});
final int step;
@override
Widget build(BuildContext context) {
const labels = ['分析宠物特征', '加载风格模型', '生成画面细节', '高清增强与合成'];
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: SectionCard(
child: Column(
children: [
LinearProgressIndicator(value: step / labels.length),
const SizedBox(height: 12),
for (var index = 0; index < labels.length; index++)
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
index < step ? Icons.check_circle : Icons.circle_outlined,
color: index < step ? AppColors.success : AppColors.muted,
size: 20,
),
title: Text(labels[index]),
),
],
),
),
);
}
}