feat(app): album banner picker in edit form — show current banner, pick & upload on save
- api_client:uploadAlbumAssetFromFile 泛化(cover 为薄壳)+ fetchAlbumAssetUrl
(GET /albums/{id}/assets → 指定 kind 全局行 URL)
- 编辑页 Details:封面下方新增「宣传图」3:1 选择器——编辑模式异步加载现有
banner 预览(失败静默);保存成功后上传(同封面:失败不阻塞表单结果)
- 未保存变更判断纳入 banner;l10n zh/en
- 测试适配:编辑用例 pumpForm 后清空 banner 现状预检请求(397/397 全绿)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -89,16 +89,34 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
/// 创建模式:相册创建成功后上传;编辑模式:保存后上传。
|
||||
String? _coverFilePath;
|
||||
|
||||
/// 用户选择的宣传图本机文件路径(null = 未选;上传时机同封面)。
|
||||
String? _bannerFilePath;
|
||||
|
||||
/// 编辑模式下的现有宣传图 URL(asset 现状查询,异步加载即时预览)。
|
||||
String? _existingBannerUrl;
|
||||
|
||||
/// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。
|
||||
String? get _existingCoverUrl => _coverFilePath == null
|
||||
? (widget.existing?.cover?.url)
|
||||
: null;
|
||||
String? get _existingCoverUrl =>
|
||||
_coverFilePath == null ? (widget.existing?.cover?.url) : null;
|
||||
|
||||
bool get _isCreate => widget.existing == null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 编辑模式:加载现有宣传图预览(asset 现状查询;失败静默不阻塞表单)。
|
||||
if (widget.existing != null) {
|
||||
Future<void>.microtask(() async {
|
||||
try {
|
||||
final url = await ref
|
||||
.read(apiClientProvider)
|
||||
.fetchAlbumAssetUrl(widget.existing!.id, 'banner');
|
||||
if (mounted) setState(() => _existingBannerUrl = url);
|
||||
} on Object {
|
||||
// 预览加载失败不阻塞表单
|
||||
}
|
||||
});
|
||||
}
|
||||
final initial = widget.existing == null
|
||||
? (widget.initialDraft ??
|
||||
AlbumFormDraft(
|
||||
@@ -245,7 +263,9 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
albumId = album.id;
|
||||
} else {
|
||||
final patch = draft.toUpdatePatch(widget.existing!);
|
||||
if (patch == null && _coverFilePath == null) {
|
||||
if (patch == null &&
|
||||
_coverFilePath == null &&
|
||||
_bannerFilePath == null) {
|
||||
navigator.pop(false);
|
||||
return;
|
||||
}
|
||||
@@ -269,6 +289,22 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
);
|
||||
}
|
||||
}
|
||||
// 宣传图上传:同封面——保存成功后执行,失败不阻塞表单结果。
|
||||
if (_bannerFilePath != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.uploadAlbumAssetFromFile(
|
||||
albumId,
|
||||
kind: 'banner',
|
||||
filePath: _bannerFilePath!,
|
||||
);
|
||||
} on Object {
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.bannerUploadFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (error) {
|
||||
// 保存失败:保留输入,仅提示。
|
||||
@@ -370,7 +406,12 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
),
|
||||
// 保存按钮固定底部:键盘打开时常驻可达(§4.5)。
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace12),
|
||||
minimum: const EdgeInsets.fromLTRB(
|
||||
kSpace16,
|
||||
kSpace8,
|
||||
kSpace16,
|
||||
kSpace12,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: _canSubmit ? _submit : null,
|
||||
child: _submitting
|
||||
@@ -385,8 +426,8 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
body: mainTabs == null
|
||||
? _detailsForm(l10n)
|
||||
: _mainIndex == 0
|
||||
? _detailsForm(l10n)
|
||||
: _accessPanel(l10n),
|
||||
? _detailsForm(l10n)
|
||||
: _accessPanel(l10n),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -396,8 +437,7 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.coverPicker,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
Text(l10n.coverPicker, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: kSpace8),
|
||||
InkWell(
|
||||
onTap: _submitting ? null : _pickCoverImage,
|
||||
@@ -422,42 +462,43 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _existingCoverUrl != null
|
||||
? Image.network(
|
||||
_existingCoverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _coverPlaceholder(l10n),
|
||||
? Image.network(
|
||||
_existingCoverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _coverPlaceholder(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
_coverFilePath != null
|
||||
? l10n.coverSelected
|
||||
: l10n.coverPickerHint,
|
||||
_coverFilePath != null ? l10n.coverSelected : l10n.coverPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _coverPlaceholder(AppLocalizations l10n) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.add_photo_alternate_outlined,
|
||||
size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
l10n.coverPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_photo_alternate_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
l10n.coverPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _pickCoverImage() async {
|
||||
final path = await ref.read(singleImagePickerProvider)();
|
||||
@@ -466,6 +507,81 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickBannerImage() async {
|
||||
final path = await ref.read(singleImagePickerProvider)();
|
||||
if (path != null) {
|
||||
setState(() => _bannerFilePath = path);
|
||||
}
|
||||
}
|
||||
|
||||
/// 宣传图(banner)选择器:3:1 横幅可点击区域;编辑模式显示现有 banner。
|
||||
Widget _buildBannerPicker(AppLocalizations l10n) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.bannerPicker, style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: kSpace8),
|
||||
InkWell(
|
||||
onTap: _submitting ? null : _pickBannerImage,
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
child: Container(
|
||||
height: 120,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
child: _bannerFilePath != null
|
||||
? Image.file(
|
||||
File(_bannerFilePath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _bannerPlaceholder(l10n),
|
||||
)
|
||||
: _existingBannerUrl != null
|
||||
? Image.network(
|
||||
_existingBannerUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _bannerPlaceholder(l10n),
|
||||
)
|
||||
: _bannerPlaceholder(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
_bannerFilePath != null ? l10n.bannerSelected : l10n.bannerPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bannerPlaceholder(AppLocalizations l10n) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.panorama_outlined,
|
||||
size: 40,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
Text(
|
||||
l10n.bannerPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
/// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。
|
||||
Widget _detailsForm(AppLocalizations l10n) {
|
||||
return Form(
|
||||
@@ -477,6 +593,10 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
children: [
|
||||
// ── 封面选择(16:9 可点击区域) ─────────────────
|
||||
_buildCoverPicker(l10n),
|
||||
const SizedBox(height: kSpace16),
|
||||
|
||||
// ── 宣传图选择(3:1 横幅;编辑模式显示现有 banner)──
|
||||
_buildBannerPicker(l10n),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 1:Event details ──────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user