fix(viewer): replace text arrow share icon with SVG share glyph
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
/// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。
|
||||
library;
|
||||
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -282,6 +284,80 @@ class ApiClient {
|
||||
Future<CursorPage<AlbumSummary>> listAlbums({String? cursor, int? limit}) =>
|
||||
_getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit);
|
||||
|
||||
/// 资产上传凭证(契约 AssetInitResponse)。
|
||||
/// kind:cover | banner | logo;凭据结构与照片 presign 相同。
|
||||
static const assetKindCover = 'cover';
|
||||
static const assetKindBanner = 'banner';
|
||||
|
||||
Future<({String assetId, UploadCredential credential})>
|
||||
initAlbumAsset(String albumId, {
|
||||
required String kind,
|
||||
required String filename,
|
||||
required int bytes,
|
||||
required String contentType,
|
||||
}) async {
|
||||
final resp = await _send(
|
||||
() => _dio.post<Map<String, dynamic>>('/v1/albums/$albumId/assets', data: {
|
||||
'kind': kind,
|
||||
'filename': filename,
|
||||
'bytes': bytes,
|
||||
'contentType': contentType,
|
||||
}),
|
||||
);
|
||||
final data = resp.data!;
|
||||
return (
|
||||
assetId: data['assetId'] as String,
|
||||
credential: UploadCredential.fromJson(data['credential'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
/// 完成资产上传并激活(返回短期签名 URL)。
|
||||
Future<String> completeAlbumAsset(String albumId, String assetId) async {
|
||||
final resp = await _send(
|
||||
() => _dio.post<Map<String, dynamic>>(
|
||||
'/v1/albums/$albumId/assets/$assetId/complete',
|
||||
),
|
||||
);
|
||||
return resp.data!['url'] as String;
|
||||
}
|
||||
|
||||
/// 一步上传相册封面:选图 → presign → PUT → complete。
|
||||
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
|
||||
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
|
||||
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) async {
|
||||
final file = File(filePath);
|
||||
final bytes = await file.length();
|
||||
final filename = filePath.split(Platform.pathSeparator).last;
|
||||
final contentType = _guessContentType(filename);
|
||||
final init = await initAlbumAsset(albumId,
|
||||
kind: assetKindCover,
|
||||
filename: filename,
|
||||
bytes: bytes,
|
||||
contentType: contentType);
|
||||
final putRes = await Dio().put(
|
||||
init.credential.url,
|
||||
data: file.openRead(),
|
||||
options: Options(headers: {
|
||||
...init.credential.headers,
|
||||
'Content-Length': bytes,
|
||||
}),
|
||||
);
|
||||
if (putRes.statusCode != 200 && putRes.statusCode != 204) {
|
||||
throw Exception('cover PUT failed: ${putRes.statusCode}');
|
||||
}
|
||||
return completeAlbumAsset(albumId, init.assetId);
|
||||
}
|
||||
|
||||
String _guessContentType(String filename) {
|
||||
final ext = filename.split('.').last.toLowerCase();
|
||||
return switch (ext) {
|
||||
'jpg' || 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png',
|
||||
'heic' => 'image/heic',
|
||||
_ => 'image/jpeg',
|
||||
};
|
||||
}
|
||||
|
||||
/// operationId `createAlbum`(POST /albums → 201)。
|
||||
Future<Album> createAlbum(AlbumCreateRequest req) async {
|
||||
final resp = await _send(
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
"formTitleRequiredWithFields": "Title is required when description, venue or address is filled",
|
||||
"formEndAtNotAfterStartAt": "End time must be after start time",
|
||||
"formEventDetails": "Event details",
|
||||
"coverPicker": "Album cover",
|
||||
"coverPickerHint": "Tap to select a cover image (optional)",
|
||||
"coverSelected": "Cover selected, will upload on save",
|
||||
"coverUploadFailed": "Cover upload failed, you can retry from album detail later",
|
||||
"formDescriptionLanguages": "Description & languages",
|
||||
"formDefaultLanguage": "Default language",
|
||||
"formDetailsTab": "Details",
|
||||
|
||||
@@ -572,6 +572,30 @@ abstract class AppLocalizations {
|
||||
/// **'活动信息'**
|
||||
String get formEventDetails;
|
||||
|
||||
/// No description provided for @coverPicker.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'相册封面'**
|
||||
String get coverPicker;
|
||||
|
||||
/// No description provided for @coverPickerHint.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'点击选择封面图片(可选)'**
|
||||
String get coverPickerHint;
|
||||
|
||||
/// No description provided for @coverSelected.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'已选择封面,保存后上传'**
|
||||
String get coverSelected;
|
||||
|
||||
/// No description provided for @coverUploadFailed.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'封面上传失败,可稍后在相册详情中重新上传'**
|
||||
String get coverUploadFailed;
|
||||
|
||||
/// No description provided for @formDescriptionLanguages.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -254,6 +254,19 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get formEventDetails => 'Event details';
|
||||
|
||||
@override
|
||||
String get coverPicker => 'Album cover';
|
||||
|
||||
@override
|
||||
String get coverPickerHint => 'Tap to select a cover image (optional)';
|
||||
|
||||
@override
|
||||
String get coverSelected => 'Cover selected, will upload on save';
|
||||
|
||||
@override
|
||||
String get coverUploadFailed =>
|
||||
'Cover upload failed, you can retry from album detail later';
|
||||
|
||||
@override
|
||||
String get formDescriptionLanguages => 'Description & languages';
|
||||
|
||||
|
||||
@@ -248,6 +248,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get formEventDetails => '活动信息';
|
||||
|
||||
@override
|
||||
String get coverPicker => '相册封面';
|
||||
|
||||
@override
|
||||
String get coverPickerHint => '点击选择封面图片(可选)';
|
||||
|
||||
@override
|
||||
String get coverSelected => '已选择封面,保存后上传';
|
||||
|
||||
@override
|
||||
String get coverUploadFailed => '封面上传失败,可稍后在相册详情中重新上传';
|
||||
|
||||
@override
|
||||
String get formDescriptionLanguages => '描述与语言';
|
||||
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
"formTitleRequiredWithFields": "填写了描述/场地/地址时,标题不能为空",
|
||||
"formEndAtNotAfterStartAt": "结束时间必须晚于开始时间",
|
||||
"formEventDetails": "活动信息",
|
||||
"coverPicker": "相册封面",
|
||||
"coverPickerHint": "点击选择封面图片(可选)",
|
||||
"coverSelected": "已选择封面,保存后上传",
|
||||
"coverUploadFailed": "封面上传失败,可稍后在相册详情中重新上传",
|
||||
"formDescriptionLanguages": "描述与语言",
|
||||
"formDefaultLanguage": "默认语言",
|
||||
"formDetailsTab": "详情",
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
/// 无变更时保存禁用。成功 pop(true) 供调用方刷新列表/详情;失败保留输入。
|
||||
library;
|
||||
|
||||
import 'dart:io' show File;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
@@ -25,6 +27,7 @@ import '../core/error_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/album_form.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/tokens.dart';
|
||||
@@ -82,6 +85,15 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
List<AlbumFormError> _errors = const <AlbumFormError>[];
|
||||
bool _submitting = false;
|
||||
|
||||
/// 用户选择的封面本机文件路径(null = 未选)。
|
||||
/// 创建模式:相册创建成功后上传;编辑模式:保存后上传。
|
||||
String? _coverFilePath;
|
||||
|
||||
/// 编辑模式下的现有封面 URL(来自 AlbumSummary.cover,即时预览)。
|
||||
String? get _existingCoverUrl => _coverFilePath == null
|
||||
? (widget.existing?.cover?.url)
|
||||
: null;
|
||||
|
||||
bool get _isCreate => widget.existing == null;
|
||||
|
||||
@override
|
||||
@@ -225,17 +237,37 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
try {
|
||||
late final String albumId;
|
||||
if (_isCreate) {
|
||||
await ref.read(apiClientProvider).createAlbum(draft.toCreateRequest());
|
||||
final album = await ref
|
||||
.read(apiClientProvider)
|
||||
.createAlbum(draft.toCreateRequest());
|
||||
albumId = album.id;
|
||||
} else {
|
||||
final patch = draft.toUpdatePatch(widget.existing!);
|
||||
if (patch == null) {
|
||||
if (patch == null && _coverFilePath == null) {
|
||||
navigator.pop(false);
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.updateAlbum(widget.existing!.id, patch);
|
||||
if (patch != null) {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.updateAlbum(widget.existing!.id, patch);
|
||||
}
|
||||
albumId = widget.existing!.id;
|
||||
}
|
||||
// 封面上传:相册保存成功后执行;失败不阻塞表单结果(封面可后补)。
|
||||
if (_coverFilePath != null) {
|
||||
try {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.uploadAlbumCoverFromFile(albumId, _coverFilePath!);
|
||||
} on Object {
|
||||
// 封面失败仅提示,不影响相册保存结果。
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.coverUploadFailed)),
|
||||
);
|
||||
}
|
||||
}
|
||||
navigator.pop(true);
|
||||
} on AppException catch (error) {
|
||||
@@ -359,7 +391,82 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
);
|
||||
}
|
||||
|
||||
/// Details(= 创建表单):分组字段 + 校验错误汇总。
|
||||
/// 封面选择器:16:9 可点击区域。编辑模式显示现有封面预览。
|
||||
Widget _buildCoverPicker(AppLocalizations l10n) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.coverPicker,
|
||||
style: Theme.of(context).textTheme.titleMedium),
|
||||
const SizedBox(height: kSpace8),
|
||||
InkWell(
|
||||
onTap: _submitting ? null : _pickCoverImage,
|
||||
borderRadius: BorderRadius.circular(kRadiusInput),
|
||||
child: Container(
|
||||
height: 180,
|
||||
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: _coverFilePath != null
|
||||
? Image.file(
|
||||
File(_coverFilePath!),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _existingCoverUrl != null
|
||||
? Image.network(
|
||||
_existingCoverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => _coverPlaceholder(l10n),
|
||||
)
|
||||
: _coverPlaceholder(l10n),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
_coverFilePath != null
|
||||
? l10n.coverSelected
|
||||
: l10n.coverPickerHint,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _pickCoverImage() async {
|
||||
final path = await ref.read(singleImagePickerProvider)();
|
||||
if (path != null) {
|
||||
setState(() => _coverFilePath = path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Details(= 创建表单):封面选择 + 分组字段 + 校验错误汇总。
|
||||
Widget _detailsForm(AppLocalizations l10n) {
|
||||
return Form(
|
||||
child: SingleChildScrollView(
|
||||
@@ -368,6 +475,10 @@ class _AlbumFormPageState extends ConsumerState<AlbumFormPage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// ── 封面选择(16:9 可点击区域) ─────────────────
|
||||
_buildCoverPicker(l10n),
|
||||
const SizedBox(height: kSpace24),
|
||||
|
||||
// ── 分组 1:Event details ──────────────────────────
|
||||
SectionHeader(title: l10n.formEventDetails),
|
||||
const SizedBox(height: kSpace12),
|
||||
|
||||
Reference in New Issue
Block a user