9892b65a19
CI / flutter-gates (push) Successful in 3m9s
- 新增 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>
564 lines
19 KiB
Dart
564 lines
19 KiB
Dart
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]),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|