feat(mobile): UI-01 album cards, photo grid and full viewer
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
/// 相册详情页(P3 计划 Task 4):信息区(标题/时间/时区/令牌/开关标签)
|
||||
/// + 动作行(编辑、成员管理、分类管理=滚动定位、分享二维码、照片管理
|
||||
/// 占位)+ 分类管理区(列表 + 新增/编辑对话框 + 删除确认)。
|
||||
/// 编辑与分类变更后刷新本地详情并同步全局相册列表。
|
||||
/// 相册详情页(UI-01 §4.2 重构):紧凑封面与活动信息 → 公开/上传状态 →
|
||||
/// 照片入口(最近上传预览条 / 拍摄第一张照片引导)→ 次级管理入口(编辑/
|
||||
/// 成员/分类/分享,ListTile 次强调)+ 分类管理区;底部固定「拍摄上传
|
||||
/// (主)/ 从相册选择(次)」,避让安全区,上传关闭时说明原因并禁用。
|
||||
///
|
||||
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
|
||||
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
|
||||
/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -14,15 +18,23 @@ import '../core/error_l10n.dart';
|
||||
import '../features/albums/album_format.dart';
|
||||
import '../features/albums/models.dart';
|
||||
import '../features/albums/providers.dart';
|
||||
import '../features/photos/models.dart';
|
||||
import '../features/uploads/enqueue.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../ui/components/photo_thumbnail.dart';
|
||||
import '../ui/components/section_header.dart';
|
||||
import '../ui/components/state_badge.dart';
|
||||
import '../ui/tokens.dart';
|
||||
import 'album_form_page.dart';
|
||||
import 'album_members_page.dart';
|
||||
import 'album_photos_page.dart';
|
||||
import 'capture_page.dart';
|
||||
import 'share_qr_dialog.dart';
|
||||
|
||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id})。
|
||||
/// 详情页照片预览条取数上限(方案 §4.2「3-5 张缩略图」)。
|
||||
const int _kPreviewStripLimit = 5;
|
||||
|
||||
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
|
||||
class AlbumDetailPage extends ConsumerStatefulWidget {
|
||||
const AlbumDetailPage({required this.albumId, super.key});
|
||||
|
||||
@@ -40,6 +52,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
bool _loading = true;
|
||||
String? _errorCode;
|
||||
|
||||
/// 最近上传预览条(协作者为「自己上传」,服务端过滤)。
|
||||
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
|
||||
bool _photosLoading = true;
|
||||
|
||||
/// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。
|
||||
/// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。
|
||||
bool _isManager = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -61,12 +81,12 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
final album = await ref.read(apiClientProvider).getAlbum(
|
||||
widget.albumId,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_album = album;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_album = album;
|
||||
_loading = false;
|
||||
});
|
||||
await _loadPhotoPreview();
|
||||
} on AppException catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -84,6 +104,63 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己
|
||||
/// 上传)。非 403 失败不阻塞详情页(入口退化为「管理照片」按钮)。
|
||||
Future<void> _loadPhotoPreview() async {
|
||||
final album = _album;
|
||||
if (album == null) return;
|
||||
final api = ref.read(apiClientProvider);
|
||||
try {
|
||||
final page = await api.listAlbumPhotos(
|
||||
album.id,
|
||||
scope: AlbumPhotoScope.all,
|
||||
limit: _kPreviewStripLimit,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_recentPhotos = page.items;
|
||||
_photosLoading = false;
|
||||
_isManager = true;
|
||||
});
|
||||
} on AppException catch (e) {
|
||||
if (!mounted) return;
|
||||
if (e.code != ErrorCodes.authForbidden) {
|
||||
setState(() {
|
||||
_recentPhotos = const <AdminPhoto>[];
|
||||
_photosLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final ownPage = await api.listAlbumPhotos(
|
||||
album.id,
|
||||
scope: AlbumPhotoScope.own,
|
||||
limit: _kPreviewStripLimit,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_recentPhotos = ownPage.items;
|
||||
_photosLoading = false;
|
||||
_isManager = false;
|
||||
});
|
||||
} on AppException {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_recentPhotos = const <AdminPhoto>[];
|
||||
_photosLoading = false;
|
||||
_isManager = false;
|
||||
});
|
||||
}
|
||||
} on Object {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recentPhotos = const <AdminPhoto>[];
|
||||
_photosLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 编辑成功后:详情重载 + 全局列表同步。
|
||||
Future<void> _openEdit() async {
|
||||
final album = _album;
|
||||
@@ -118,6 +195,8 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
builder: (_) => AlbumPhotosPage(album: album),
|
||||
),
|
||||
);
|
||||
// 管理页内可能发生隐藏/删除,回来后刷新预览条。
|
||||
await _loadPhotoPreview();
|
||||
}
|
||||
|
||||
void _shareQr() {
|
||||
@@ -129,7 +208,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 上传入口(Task 9 FAB:拍照上传 / 从相册选择) ──────────────────────
|
||||
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
|
||||
|
||||
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
||||
Future<String?> _currentAccountId() async {
|
||||
@@ -137,36 +216,6 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
return session?.account;
|
||||
}
|
||||
|
||||
Future<void> _openUploadSheet() {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined),
|
||||
title: Text(l10n.uploadActionCapture),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_openCapture();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: Text(l10n.uploadActionPick),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_pickFromGallery();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
|
||||
Future<void> _openCapture() async {
|
||||
final album = _album;
|
||||
@@ -180,7 +229,7 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
target: CaptureTarget(
|
||||
accountId: account,
|
||||
albumId: album.id,
|
||||
albumTitle: _titleOf(album),
|
||||
albumTitle: albumDisplayTitle(album, AppLocalizations.of(context).localeName),
|
||||
categories: album.categories,
|
||||
),
|
||||
),
|
||||
@@ -272,17 +321,14 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final album = _album;
|
||||
final title = album == null ? l10n.albumDetail : _titleOf(album);
|
||||
final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
// 上传入口(Task 9):拍照上传(连拍页)/ 从相册选择(多选入队)。
|
||||
floatingActionButton: album == null
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: _openUploadSheet,
|
||||
icon: const Icon(Icons.photo_camera_outlined),
|
||||
label: Text(l10n.uploadAction),
|
||||
),
|
||||
bottomNavigationBar: album == null ? null : _UploadBar(
|
||||
uploadEnabled: album.uploadEnabled,
|
||||
onCapture: _openCapture,
|
||||
onPick: _pickFromGallery,
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: album == null
|
||||
@@ -292,21 +338,32 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_InfoCard(album: album),
|
||||
const SizedBox(height: 16),
|
||||
_ActionsRow(
|
||||
onEdit: _openEdit,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _scrollToCategories,
|
||||
onShareQr: _shareQr,
|
||||
onPhotos: _openPhotos,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
padding: const EdgeInsets.all(kSpace16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_CompactHeader(album: album),
|
||||
const SizedBox(height: kSpace16),
|
||||
_StatusSection(album: album),
|
||||
const SizedBox(height: kSpace16),
|
||||
_PhotoEntrySection(
|
||||
album: album,
|
||||
photos: _recentPhotos,
|
||||
loading: _photosLoading,
|
||||
isManager: _isManager,
|
||||
onOpenPhotos: _openPhotos,
|
||||
onTakeFirst: _openCapture,
|
||||
),
|
||||
const SizedBox(height: kSpace16),
|
||||
_SecondaryActions(
|
||||
showManage: _isManager,
|
||||
onEdit: _openEdit,
|
||||
onMembers: _openMembers,
|
||||
onCategories: _scrollToCategories,
|
||||
onShareQr: _shareQr,
|
||||
),
|
||||
if (_isManager) ...[
|
||||
const SizedBox(height: kSpace16),
|
||||
_CategoriesSection(
|
||||
key: _categoriesKey,
|
||||
album: album,
|
||||
@@ -314,20 +371,328 @@ class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
||||
onEdit: (c) => _openCategoryDialog(c),
|
||||
onDelete: _deleteCategory,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: kSpace32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _titleOf(Album album) {
|
||||
for (final t in album.translations) {
|
||||
if (t.locale == album.defaultLocale) return t.title;
|
||||
}
|
||||
if (album.translations.isNotEmpty) return album.translations.first.title;
|
||||
return album.id;
|
||||
/// 紧凑头部:封面缩略图(16:9,最高 200)+ 标题 + 时间/场地单行。
|
||||
class _CompactHeader extends StatelessWidget {
|
||||
const _CompactHeader({required this.album});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final venue = albumDisplayVenue(album, l10n.localeName);
|
||||
final start =
|
||||
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
||||
final end = album.endAt == null
|
||||
? l10n.notSet
|
||||
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
||||
final meta = venue == null
|
||||
? '$start ~ $end (${album.timezone})'
|
||||
: '$start ~ $end (${album.timezone}) · $venue';
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// 16:9 封面,最高 200(超出按高度封顶,宽度同步收窄)。
|
||||
final height = constraints.maxWidth * 9 / 16;
|
||||
final capped = height > 200.0;
|
||||
final h = capped ? 200.0 : height;
|
||||
final w = capped ? h * 16 / 9 : constraints.maxWidth;
|
||||
return Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(kRadiusCard),
|
||||
child: SizedBox(
|
||||
width: w,
|
||||
height: h,
|
||||
child: PhotoThumbnail(
|
||||
url: album.cover?.url,
|
||||
aspectRatio: w / h,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: kSpace12),
|
||||
Text(
|
||||
albumDisplayTitle(album, l10n.localeName),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: kSpace4),
|
||||
Text(
|
||||
meta,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
|
||||
class _StatusSection extends StatelessWidget {
|
||||
const _StatusSection({required this.album});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Row(
|
||||
children: [
|
||||
StateBadge(
|
||||
variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral,
|
||||
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
StateBadge(
|
||||
variant:
|
||||
album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral,
|
||||
label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff,
|
||||
),
|
||||
if (album.status == AlbumStatus.offline) ...[
|
||||
const SizedBox(width: kSpace8),
|
||||
StateBadge(variant: BadgeVariant.error, label: l10n.albumStatusOffline),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 照片入口:有照片 → 预览条(≤5 张)+「管理照片」;无照片 →「拍摄第
|
||||
/// 一张照片」引导(上传关闭时禁用,由底部栏说明原因)。
|
||||
class _PhotoEntrySection extends StatelessWidget {
|
||||
const _PhotoEntrySection({
|
||||
required this.album,
|
||||
required this.photos,
|
||||
required this.loading,
|
||||
required this.isManager,
|
||||
required this.onOpenPhotos,
|
||||
required this.onTakeFirst,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
final List<AdminPhoto> photos;
|
||||
final bool loading;
|
||||
final bool isManager;
|
||||
final VoidCallback onOpenPhotos;
|
||||
final VoidCallback onTakeFirst;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SectionHeader(
|
||||
title: l10n.albumPhotos,
|
||||
trailing: TextButton(
|
||||
onPressed: onOpenPhotos,
|
||||
child: Text(l10n.albumManagePhotos),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: kSpace8),
|
||||
if (loading)
|
||||
const SizedBox(
|
||||
height: 96,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (photos.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: photos.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: kSpace8),
|
||||
itemBuilder: (context, index) {
|
||||
final photo = photos[index];
|
||||
return SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: PhotoThumbnail(
|
||||
url: photo.preview?.thumb.url,
|
||||
aspectRatio: 1,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_camera_outlined,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.albumPhotosEmpty,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace8),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: album.uploadEnabled ? onTakeFirst : null,
|
||||
icon: const Icon(Icons.photo_camera_outlined),
|
||||
label: Text(l10n.albumTakeFirstPhoto),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 次级管理入口(ListTile 次强调,不再与主操作同级):编辑 / 成员 / 分类
|
||||
/// (滚动定位)/ 分享二维码。管理类入口仅创建者/管理员可见;分享对全体
|
||||
/// 成员开放(publicToken 本身对成员可见)。
|
||||
class _SecondaryActions extends StatelessWidget {
|
||||
const _SecondaryActions({
|
||||
required this.showManage,
|
||||
required this.onEdit,
|
||||
required this.onMembers,
|
||||
required this.onCategories,
|
||||
required this.onShareQr,
|
||||
});
|
||||
|
||||
final bool showManage;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onMembers;
|
||||
final VoidCallback onCategories;
|
||||
final VoidCallback onShareQr;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
children: [
|
||||
if (showManage) ...[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: Text(AppLocalizations.of(context).actionEdit),
|
||||
onTap: onEdit,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.group_outlined),
|
||||
title: Text(AppLocalizations.of(context).albumMembers),
|
||||
onTap: onMembers,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.category_outlined),
|
||||
title: Text(AppLocalizations.of(context).albumCategories),
|
||||
onTap: onCategories,
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
leading: const Icon(Icons.qr_code_2),
|
||||
title: Text(AppLocalizations.of(context).shareQrTitle),
|
||||
onTap: onShareQr,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
|
||||
/// 关闭时给出原因说明并禁用两个入口。
|
||||
class _UploadBar extends StatelessWidget {
|
||||
const _UploadBar({
|
||||
required this.uploadEnabled,
|
||||
required this.onCapture,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
final bool uploadEnabled;
|
||||
final VoidCallback onCapture;
|
||||
final VoidCallback onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!uploadEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16,
|
||||
kSpace8,
|
||||
kSpace16,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: kSpace4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.albumUploadDisabledHint,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
kSpace16,
|
||||
kSpace8,
|
||||
kSpace16,
|
||||
kSpace12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: uploadEnabled ? onCapture : null,
|
||||
icon: const Icon(Icons.photo_camera_outlined),
|
||||
label: Text(l10n.uploadActionCapture),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: kSpace12),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: uploadEnabled ? onPick : null,
|
||||
icon: const Icon(Icons.photo_library_outlined),
|
||||
label: Text(l10n.uploadActionPick),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,191 +725,6 @@ class _ErrorRetryView extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 信息卡:标题 + 状态、默认语言内容(描述/场地/地址)、时区时间区间、
|
||||
/// 公开令牌、四开关标签。
|
||||
class _InfoCard extends StatelessWidget {
|
||||
const _InfoCard({required this.album});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final translation = album.translations
|
||||
.where((t) => t.locale == album.defaultLocale)
|
||||
.firstOrNull;
|
||||
final start =
|
||||
formatAlbumDateTime(album.startAt, album.timezone, l10n.localeName);
|
||||
final end = album.endAt == null
|
||||
? l10n.notSet
|
||||
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
translation?.title ?? album.id,
|
||||
style: theme.textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_AlbumStatusBadge(status: album.status),
|
||||
],
|
||||
),
|
||||
if ((translation?.description ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(translation!.description!),
|
||||
],
|
||||
if ((translation?.venue ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.place_outlined, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(translation!.venue!)),
|
||||
],
|
||||
),
|
||||
],
|
||||
if ((translation?.address ?? '').trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.signpost_outlined, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(translation!.address!)),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.schedule, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text('$start ~ $end (${album.timezone})')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.link, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
album.publicToken,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_switchTag(context, l10n.tagUpload, album.uploadEnabled),
|
||||
_switchTag(context, l10n.tagPublic, album.publicEnabled),
|
||||
_switchTag(
|
||||
context,
|
||||
l10n.tagDownload,
|
||||
album.downloadEnabled,
|
||||
),
|
||||
_switchTag(
|
||||
context,
|
||||
l10n.tagOriginal,
|
||||
album.originalDownloadEnabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _switchTag(BuildContext context, String label, bool on) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: on
|
||||
? theme.colorScheme.primaryContainer
|
||||
: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: on
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 动作行:编辑 / 成员管理 / 分类管理(滚动定位) / 分享二维码 / 照片管理。
|
||||
class _ActionsRow extends StatelessWidget {
|
||||
const _ActionsRow({
|
||||
required this.onEdit,
|
||||
required this.onMembers,
|
||||
required this.onCategories,
|
||||
required this.onShareQr,
|
||||
required this.onPhotos,
|
||||
});
|
||||
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onMembers;
|
||||
final VoidCallback onCategories;
|
||||
final VoidCallback onShareQr;
|
||||
final VoidCallback onPhotos;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: Text(l10n.actionEdit),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onMembers,
|
||||
icon: const Icon(Icons.group_outlined),
|
||||
label: Text(l10n.albumMembers),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCategories,
|
||||
icon: const Icon(Icons.category_outlined),
|
||||
label: Text(l10n.albumCategories),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onShareQr,
|
||||
icon: const Icon(Icons.qr_code_2),
|
||||
label: Text(l10n.shareQrTitle),
|
||||
),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onPhotos,
|
||||
icon: const Icon(Icons.photo_outlined),
|
||||
label: Text(l10n.albumPhotos),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类管理区:列表(点击编辑、尾随删除)+ 新增入口。
|
||||
class _CategoriesSection extends StatelessWidget {
|
||||
const _CategoriesSection({
|
||||
@@ -594,6 +774,7 @@ class _CategoriesSection extends StatelessWidget {
|
||||
)
|
||||
else
|
||||
Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
for (final (i, category) in album.categories.indexed) ...[
|
||||
@@ -633,37 +814,6 @@ class _CategoriesSection extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态徽标(详情信息卡用)。
|
||||
class _AlbumStatusBadge extends StatelessWidget {
|
||||
const _AlbumStatusBadge({required this.status});
|
||||
|
||||
final AlbumStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final active = status == AlbumStatus.active;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? theme.colorScheme.secondaryContainer
|
||||
: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
active ? l10n.albumStatusActive : l10n.albumStatusOffline,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: active
|
||||
? theme.colorScheme.onSecondaryContainer
|
||||
: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类新增/编辑对话框:排序序号 + 中文名 + 英文名(至少一个必填)。
|
||||
///
|
||||
/// 成功 pop(true);校验失败就地提示;API 失败 SnackBar(对话框保持)。
|
||||
|
||||
Reference in New Issue
Block a user