fix(viewer): replace text arrow share icon with SVG share glyph

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 13:55:37 +08:00
parent 900ff2a74d
commit b353a3de06
54 changed files with 2130 additions and 14 deletions
+76
View File
@@ -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(
+4
View File
@@ -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 => '描述与语言';
+4
View File
@@ -80,6 +80,10 @@
"formTitleRequiredWithFields": "填写了描述/场地/地址时,标题不能为空",
"formEndAtNotAfterStartAt": "结束时间必须晚于开始时间",
"formEventDetails": "活动信息",
"coverPicker": "相册封面",
"coverPickerHint": "点击选择封面图片(可选)",
"coverSelected": "已选择封面,保存后上传",
"coverUploadFailed": "封面上传失败,可稍后在相册详情中重新上传",
"formDescriptionLanguages": "描述与语言",
"formDefaultLanguage": "默认语言",
"formDetailsTab": "详情",
+117 -6
View File
@@ -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),