2026-09-22 00:57:59 +08:00
|
|
|
|
/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」
|
|
|
|
|
|
/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再
|
|
|
|
|
|
/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口
|
|
|
|
|
|
/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航);
|
|
|
|
|
|
/// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时
|
|
|
|
|
|
/// 说明原因并禁用。
|
2026-09-22 00:57:59 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your
|
|
|
|
|
|
/// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不
|
|
|
|
|
|
/// 当绝对空白,无伪造计数与示例照片。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
///
|
|
|
|
|
|
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
|
|
|
|
|
|
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
|
|
|
|
|
|
/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
|
|
|
|
|
|
import '../core/api_client.dart';
|
|
|
|
|
|
import '../core/app_exception.dart';
|
|
|
|
|
|
import '../core/error_codes.dart';
|
|
|
|
|
|
import '../core/error_l10n.dart';
|
|
|
|
|
|
import '../features/albums/album_format.dart';
|
|
|
|
|
|
import '../features/albums/models.dart';
|
|
|
|
|
|
import '../features/albums/providers.dart';
|
2026-09-21 15:55:44 +08:00
|
|
|
|
import '../features/photos/models.dart';
|
2026-09-19 04:47:31 +08:00
|
|
|
|
import '../features/uploads/enqueue.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
import '../l10n/app_localizations.dart';
|
2026-09-21 15:55:44 +08:00
|
|
|
|
import '../ui/components/photo_thumbnail.dart';
|
|
|
|
|
|
import '../ui/components/section_header.dart';
|
|
|
|
|
|
import '../ui/components/state_badge.dart';
|
|
|
|
|
|
import '../ui/tokens.dart';
|
2026-09-22 01:23:02 +08:00
|
|
|
|
import 'album_categories_page.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
import 'album_form_page.dart';
|
|
|
|
|
|
import 'album_members_page.dart';
|
|
|
|
|
|
import 'album_photos_page.dart';
|
2026-09-19 04:47:31 +08:00
|
|
|
|
import 'capture_page.dart';
|
2026-09-19 01:56:38 +08:00
|
|
|
|
import 'share_qr_dialog.dart';
|
|
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。
|
|
|
|
|
|
const int _kRecentGridLimit = 9;
|
|
|
|
|
|
|
|
|
|
|
|
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
|
2026-09-23 10:13:08 +08:00
|
|
|
|
enum AlbumAction { edit, members, categories, share, delete }
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
|
|
|
|
|
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
|
2026-09-19 01:56:38 +08:00
|
|
|
|
class AlbumDetailPage extends ConsumerStatefulWidget {
|
|
|
|
|
|
const AlbumDetailPage({required this.albumId, super.key});
|
|
|
|
|
|
|
|
|
|
|
|
final String albumId;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
ConsumerState<AlbumDetailPage> createState() => _AlbumDetailPageState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _AlbumDetailPageState extends ConsumerState<AlbumDetailPage> {
|
|
|
|
|
|
Album? _album;
|
|
|
|
|
|
bool _loading = true;
|
|
|
|
|
|
String? _errorCode;
|
|
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 最近照片网格(协作者为「自己上传」,服务端过滤)。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
|
|
|
|
|
|
bool _photosLoading = true;
|
|
|
|
|
|
|
|
|
|
|
|
/// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。
|
|
|
|
|
|
/// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。
|
|
|
|
|
|
bool _isManager = true;
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
|
|
|
|
|
_load();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _load() async {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_loading = true;
|
|
|
|
|
|
_errorCode = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
try {
|
2026-09-23 10:13:08 +08:00
|
|
|
|
final album = await ref.read(apiClientProvider).getAlbum(widget.albumId);
|
2026-09-21 15:55:44 +08:00
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_album = album;
|
|
|
|
|
|
_loading = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
await _loadPhotoPreview();
|
|
|
|
|
|
} on AppException catch (e) {
|
2026-09-19 01:56:38 +08:00
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
2026-09-21 15:55:44 +08:00
|
|
|
|
_errorCode = e.code;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
_loading = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-21 15:55:44 +08:00
|
|
|
|
} on Object {
|
2026-09-19 01:56:38 +08:00
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
2026-09-21 15:55:44 +08:00
|
|
|
|
_errorCode = ErrorCodes.internalError;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
_loading = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-21 15:55:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 上传)。非 403 失败不阻塞详情页(入口退化为「查看全部」按钮)。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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,
|
2026-09-22 00:57:59 +08:00
|
|
|
|
limit: _kRecentGridLimit,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
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,
|
2026-09-22 00:57:59 +08:00
|
|
|
|
limit: _kRecentGridLimit,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
);
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_recentPhotos = ownPage.items;
|
|
|
|
|
|
_photosLoading = false;
|
|
|
|
|
|
_isManager = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
} on AppException {
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_recentPhotos = const <AdminPhoto>[];
|
|
|
|
|
|
_photosLoading = false;
|
|
|
|
|
|
_isManager = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-09-19 01:56:38 +08:00
|
|
|
|
} on Object {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
2026-09-21 15:55:44 +08:00
|
|
|
|
_recentPhotos = const <AdminPhoto>[];
|
|
|
|
|
|
_photosLoading = false;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
// ── 导航入口(四入口与操作菜单共享,不另起一套实现) ───────────────
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
/// 编辑成功后:详情重载 + 全局列表同步。
|
|
|
|
|
|
Future<void> _openEdit() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
|
final changed = await Navigator.of(context).push<bool>(
|
2026-09-23 10:13:08 +08:00
|
|
|
|
MaterialPageRoute<bool>(builder: (_) => AlbumFormPage(existing: album)),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
if (changed == true) {
|
|
|
|
|
|
await _load();
|
|
|
|
|
|
ref.read(albumListProvider.notifier).refresh();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _openMembers() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
|
await Navigator.of(context).push(
|
2026-09-23 10:13:08 +08:00
|
|
|
|
MaterialPageRoute<void>(builder: (_) => AlbumMembersPage(album: album)),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
// 成员变更不影响详情数据,回来无需刷新。
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _openPhotos() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
|
await Navigator.of(context).push(
|
2026-09-23 10:13:08 +08:00
|
|
|
|
MaterialPageRoute<void>(builder: (_) => AlbumPhotosPage(album: album)),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
2026-09-22 00:57:59 +08:00
|
|
|
|
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
await _loadPhotoPreview();
|
2026-09-19 01:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 01:23:02 +08:00
|
|
|
|
/// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情
|
|
|
|
|
|
/// 返回,供连拍目标等使用)。
|
|
|
|
|
|
Future<void> _openCategories() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
|
await Navigator.of(context).push(
|
|
|
|
|
|
MaterialPageRoute<void>(
|
2026-09-23 10:13:08 +08:00
|
|
|
|
builder: (_) =>
|
|
|
|
|
|
AlbumCategoriesPage(album: album, isManager: _isManager),
|
2026-09-22 01:23:02 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
await _load();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
void _shareQr() {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
2026-09-22 02:45:34 +08:00
|
|
|
|
// UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。
|
2026-09-23 10:13:08 +08:00
|
|
|
|
Navigator.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).push(MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)));
|
2026-09-19 01:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再
|
|
|
|
|
|
/// 导航(await pop 后统一分发),避免叠层路由错乱。
|
|
|
|
|
|
Future<void> _showActionMenu() async {
|
|
|
|
|
|
final action = await showModalBottomSheet<AlbumAction>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
showDragHandle: true,
|
|
|
|
|
|
builder: (_) => AlbumActionSheet(isManager: _isManager),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!mounted || action == null) return;
|
|
|
|
|
|
switch (action) {
|
|
|
|
|
|
case AlbumAction.edit:
|
|
|
|
|
|
await _openEdit();
|
|
|
|
|
|
case AlbumAction.members:
|
|
|
|
|
|
await _openMembers();
|
|
|
|
|
|
case AlbumAction.categories:
|
2026-09-22 01:23:02 +08:00
|
|
|
|
await _openCategories();
|
2026-09-22 00:57:59 +08:00
|
|
|
|
case AlbumAction.share:
|
|
|
|
|
|
_shareQr();
|
2026-09-23 10:13:08 +08:00
|
|
|
|
case AlbumAction.delete:
|
|
|
|
|
|
await _deleteAlbum();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 软删除相册(admin/owner;契约 deleteAlbum):二次确认 → DELETE →
|
|
|
|
|
|
/// 成功提示并整栈退回相册列表(详情/编辑等路由一并出栈),列表刷新。
|
|
|
|
|
|
Future<void> _deleteAlbum() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
|
final confirmed = await showDialog<bool>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
|
|
title: Text(l10n.deleteAlbumConfirmTitle),
|
|
|
|
|
|
content: Text(l10n.deleteAlbumConfirmBody),
|
|
|
|
|
|
actions: [
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.of(ctx).pop(false),
|
|
|
|
|
|
child: Text(l10n.cancel),
|
|
|
|
|
|
),
|
|
|
|
|
|
FilledButton(
|
|
|
|
|
|
style: FilledButton.styleFrom(
|
|
|
|
|
|
backgroundColor: theme.colorScheme.error,
|
|
|
|
|
|
),
|
|
|
|
|
|
onPressed: () => Navigator.of(ctx).pop(true),
|
|
|
|
|
|
child: Text(l10n.deleteAlbum),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (confirmed != true || !mounted) return;
|
|
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await ref.read(apiClientProvider).deleteAlbum(album.id);
|
|
|
|
|
|
} on AppException catch (error) {
|
|
|
|
|
|
// 删除失败:留在详情页,本地化错误码提示。
|
|
|
|
|
|
messenger.showSnackBar(
|
|
|
|
|
|
SnackBar(content: Text(l10n.messageForError(error))),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
} on Object {
|
|
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
|
|
|
|
|
return;
|
2026-09-22 00:57:59 +08:00
|
|
|
|
}
|
2026-09-23 10:13:08 +08:00
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.albumDeleted)));
|
|
|
|
|
|
// 整栈退回列表根(编辑页等推栈路径一并清理),并刷新列表数据源。
|
|
|
|
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
|
|
|
|
ref.read(albumListProvider.notifier).refresh();
|
2026-09-22 00:57:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
|
2026-09-19 04:47:31 +08:00
|
|
|
|
|
|
|
|
|
|
/// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。
|
|
|
|
|
|
Future<String?> _currentAccountId() async {
|
|
|
|
|
|
final session = await ref.read(sessionStoreProvider).read();
|
|
|
|
|
|
return session?.account;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。
|
|
|
|
|
|
Future<void> _openCapture() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
|
|
|
|
|
final account = await _currentAccountId();
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
if (account == null) return;
|
|
|
|
|
|
await Navigator.of(context).push(
|
|
|
|
|
|
MaterialPageRoute<void>(
|
|
|
|
|
|
builder: (_) => CapturePage(
|
|
|
|
|
|
target: CaptureTarget(
|
|
|
|
|
|
accountId: account,
|
|
|
|
|
|
albumId: album.id,
|
2026-09-23 10:13:08 +08:00
|
|
|
|
albumTitle: albumDisplayTitle(
|
|
|
|
|
|
album,
|
|
|
|
|
|
AppLocalizations.of(context).localeName,
|
|
|
|
|
|
),
|
2026-09-19 04:47:31 +08:00
|
|
|
|
categories: album.categories,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
|
|
|
|
|
|
Future<void> _pickFromGallery() async {
|
|
|
|
|
|
final album = _album;
|
|
|
|
|
|
if (album == null) return;
|
2026-09-28 11:06:40 +08:00
|
|
|
|
// P0-8:相册上传开关预检——关闭时不入队(避免白做功后 403)。
|
|
|
|
|
|
if (!album.uploadEnabled) {
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text(AppLocalizations.of(context).albumUploadDisabled),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-19 04:47:31 +08:00
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final account = await _currentAccountId();
|
2026-09-28 14:43:59 +08:00
|
|
|
|
if (account == null || !mounted) return;
|
2026-09-29 10:10:49 +08:00
|
|
|
|
// 方案 A:accountId/albumId 传入选图器(点选拦截的判定范围)。
|
|
|
|
|
|
final paths = await ref.read(multiImagePickerProvider)(
|
|
|
|
|
|
context,
|
|
|
|
|
|
accountId: account,
|
|
|
|
|
|
albumId: album.id,
|
|
|
|
|
|
);
|
2026-09-19 04:47:31 +08:00
|
|
|
|
if (paths.isEmpty || !mounted) return;
|
|
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
2026-09-28 11:06:40 +08:00
|
|
|
|
|
2026-09-28 19:17:35 +08:00
|
|
|
|
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
|
|
|
|
|
|
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
|
|
|
|
|
|
final duplicates = await checkDuplicatesUnified(
|
2026-09-28 11:06:40 +08:00
|
|
|
|
ref,
|
|
|
|
|
|
accountId: account,
|
|
|
|
|
|
albumId: album.id,
|
|
|
|
|
|
sourcePaths: paths,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
Set<String> skipPaths = <String>{};
|
2026-09-28 17:06:19 +08:00
|
|
|
|
Set<String> forcePaths = <String>{};
|
2026-09-28 11:06:40 +08:00
|
|
|
|
if (duplicates != null && duplicates.isNotEmpty && mounted) {
|
2026-09-28 17:06:19 +08:00
|
|
|
|
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
|
|
|
|
|
|
if (choice == null) return; // 取消。
|
|
|
|
|
|
skipPaths = choice.skipPaths;
|
|
|
|
|
|
forcePaths = choice.forcePaths;
|
2026-09-28 11:06:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final result = await enqueueForUpload(
|
2026-09-19 04:47:31 +08:00
|
|
|
|
ref,
|
|
|
|
|
|
accountId: account,
|
|
|
|
|
|
albumId: album.id,
|
|
|
|
|
|
sourcePaths: paths,
|
2026-09-28 11:06:40 +08:00
|
|
|
|
skipPaths: skipPaths.toList(),
|
2026-09-28 17:06:19 +08:00
|
|
|
|
forcePaths: forcePaths.toList(),
|
2026-09-19 04:47:31 +08:00
|
|
|
|
);
|
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
|
messenger.showSnackBar(
|
2026-09-28 11:06:40 +08:00
|
|
|
|
SnackBar(
|
|
|
|
|
|
content: Text(
|
|
|
|
|
|
result.skipped > 0
|
|
|
|
|
|
? '${result.enqueued} 张已入队,${result.skipped} 张重复已跳过'
|
|
|
|
|
|
: l10n.uploadEnqueuedCount(result.enqueued),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-19 04:47:31 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-28 17:06:19 +08:00
|
|
|
|
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
|
|
|
|
|
|
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
|
|
|
|
|
|
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
|
|
|
|
|
|
Future<DuplicateDialogChoice?> _showDuplicateDialog(
|
2026-09-28 11:06:40 +08:00
|
|
|
|
AppLocalizations l10n,
|
|
|
|
|
|
List<DuplicateMatch> duplicates,
|
|
|
|
|
|
List<String> allPaths,
|
|
|
|
|
|
) async {
|
2026-09-28 20:22:37 +08:00
|
|
|
|
// 重复项按完整源路径精确派生(同 basename 不同目录/扩展名的兄弟
|
|
|
|
|
|
// 文件不误伤,Task 2 评审 #2)。
|
|
|
|
|
|
final dupPaths = duplicates.map((d) => d.sourcePath).toSet();
|
2026-09-28 11:06:40 +08:00
|
|
|
|
final nonDupCount = allPaths.length - dupPaths.length;
|
|
|
|
|
|
|
|
|
|
|
|
final choice = await showDialog<String>(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
builder: (ctx) => AlertDialog(
|
|
|
|
|
|
title: const Text('可能已上传过'),
|
|
|
|
|
|
content: Text(
|
|
|
|
|
|
'${duplicates.length} 张照片可能已上传到该相册'
|
|
|
|
|
|
'${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n'
|
|
|
|
|
|
'${duplicates.take(3).map((d) => d.filename).join('、')}'
|
|
|
|
|
|
'${duplicates.length > 3 ? ' …' : ''}',
|
|
|
|
|
|
),
|
|
|
|
|
|
actions: [
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.of(ctx).pop('cancel'),
|
|
|
|
|
|
child: const Text('取消'),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (nonDupCount > 0)
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => Navigator.of(ctx).pop('skip'),
|
|
|
|
|
|
child: Text('跳过重复,上传 $nonDupCount 张'),
|
|
|
|
|
|
),
|
|
|
|
|
|
FilledButton(
|
|
|
|
|
|
onPressed: () => Navigator.of(ctx).pop('all'),
|
|
|
|
|
|
child: Text('全部上传 ${allPaths.length} 张'),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (choice == 'cancel' || choice == null) return null;
|
2026-09-28 17:06:19 +08:00
|
|
|
|
if (choice == 'skip') {
|
|
|
|
|
|
return (skipPaths: dupPaths, forcePaths: <String>{});
|
|
|
|
|
|
}
|
|
|
|
|
|
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
|
|
|
|
|
|
return (skipPaths: <String>{}, forcePaths: dupPaths);
|
2026-09-28 11:06:40 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 01:56:38 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final album = _album;
|
2026-09-23 10:13:08 +08:00
|
|
|
|
final title = album == null
|
|
|
|
|
|
? l10n.albumDetail
|
|
|
|
|
|
: albumDisplayTitle(album, l10n.localeName);
|
2026-09-19 01:56:38 +08:00
|
|
|
|
return Scaffold(
|
2026-09-22 00:57:59 +08:00
|
|
|
|
appBar: AppBar(
|
|
|
|
|
|
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
|
|
|
|
actions: [
|
|
|
|
|
|
IconButton(
|
|
|
|
|
|
tooltip: l10n.moreActions,
|
|
|
|
|
|
onPressed: _showActionMenu,
|
|
|
|
|
|
icon: const Icon(Icons.more_vert),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
bottomNavigationBar: album == null
|
|
|
|
|
|
? null
|
|
|
|
|
|
: _UploadBar(
|
|
|
|
|
|
uploadEnabled: album.uploadEnabled,
|
|
|
|
|
|
onCapture: _openCapture,
|
|
|
|
|
|
onPick: _pickFromGallery,
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
body: _loading
|
|
|
|
|
|
? const Center(child: CircularProgressIndicator())
|
|
|
|
|
|
: album == null
|
2026-09-23 10:13:08 +08:00
|
|
|
|
? _ErrorRetryView(
|
|
|
|
|
|
message: l10n.messageForErrorCode(_errorCode!),
|
|
|
|
|
|
onRetry: _load,
|
|
|
|
|
|
)
|
|
|
|
|
|
: SingleChildScrollView(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// 封面:全宽 16:9(无封面同尺寸中性占位)。
|
2026-09-28 10:35:14 +08:00
|
|
|
|
PhotoThumbnail(
|
|
|
|
|
|
url: album.cover?.url,
|
|
|
|
|
|
aspectRatio: 16 / 9,
|
|
|
|
|
|
maxCacheWidth: 600,
|
|
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
0,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(
|
|
|
|
|
|
albumDisplayTitle(album, l10n.localeName),
|
|
|
|
|
|
style: Theme.of(context).textTheme.titleLarge,
|
2026-09-22 00:57:59 +08:00
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
const SizedBox(height: kSpace4),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
_metaLine(album, l10n),
|
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall
|
|
|
|
|
|
?.copyWith(
|
|
|
|
|
|
color: Theme.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).colorScheme.onSurfaceVariant,
|
|
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
const SizedBox(height: kSpace12),
|
|
|
|
|
|
_StatusSection(album: album),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
const SizedBox(height: kSpace16),
|
|
|
|
|
|
// 次级四入口(Photos/Members/Categories/Share)。
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
|
|
|
|
|
|
child: _ActionTiles(
|
|
|
|
|
|
isManager: _isManager,
|
|
|
|
|
|
onPhotos: _openPhotos,
|
|
|
|
|
|
onMembers: _openMembers,
|
|
|
|
|
|
onCategories: _openCategories,
|
|
|
|
|
|
onShare: _shareQr,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace24),
|
|
|
|
|
|
// Recent photos:三列网格(≤9)/ 空相册安静占位 /
|
|
|
|
|
|
// 处理中指示。
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: kSpace16),
|
|
|
|
|
|
child: _RecentPhotosSection(
|
|
|
|
|
|
photos: _recentPhotos,
|
|
|
|
|
|
loading: _photosLoading,
|
|
|
|
|
|
isManager: _isManager,
|
|
|
|
|
|
onOpenPhotos: _openPhotos,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace32),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。
|
|
|
|
|
|
static String _metaLine(Album album, AppLocalizations l10n) {
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final venue = albumDisplayVenue(album, l10n.localeName);
|
2026-09-23 10:13:08 +08:00
|
|
|
|
final start = formatAlbumDateTime(
|
|
|
|
|
|
album.startAt,
|
|
|
|
|
|
album.timezone,
|
|
|
|
|
|
l10n.localeName,
|
|
|
|
|
|
);
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final end = album.endAt == null
|
|
|
|
|
|
? l10n.notSet
|
|
|
|
|
|
: formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName);
|
2026-09-22 00:57:59 +08:00
|
|
|
|
return venue == null
|
2026-09-21 15:55:44 +08:00
|
|
|
|
? '$start ~ $end (${album.timezone})'
|
|
|
|
|
|
: '$start ~ $end (${album.timezone}) · $venue';
|
2026-09-22 00:57:59 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 操作菜单(UI-03 §4 页面 07):Edit album / Manage members /
|
|
|
|
|
|
/// Categories / Share album + Cancel,按角色过滤(管理类动作仅
|
|
|
|
|
|
/// owner/admin;分享对全体成员开放)。点击 pop 对应 [AlbumAction],
|
|
|
|
|
|
/// 由详情页在面板关闭后统一导航。
|
|
|
|
|
|
class AlbumActionSheet extends StatelessWidget {
|
|
|
|
|
|
const AlbumActionSheet({required this.isManager, super.key});
|
|
|
|
|
|
|
|
|
|
|
|
/// 是否管理者(owner/admin):决定管理类动作是否显示。
|
|
|
|
|
|
final bool isManager;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
|
// 大字体/小屏下面板内容可滚动,不溢出。
|
|
|
|
|
|
return SafeArea(
|
|
|
|
|
|
top: false,
|
|
|
|
|
|
child: SingleChildScrollView(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
if (isManager) ...[
|
|
|
|
|
|
ListTile(
|
|
|
|
|
|
leading: const Icon(Icons.edit_outlined),
|
|
|
|
|
|
title: Text(l10n.editAlbum),
|
|
|
|
|
|
onTap: () => Navigator.of(context).pop(AlbumAction.edit),
|
|
|
|
|
|
),
|
|
|
|
|
|
ListTile(
|
|
|
|
|
|
leading: const Icon(Icons.group_outlined),
|
|
|
|
|
|
title: Text(l10n.manageMembers),
|
|
|
|
|
|
onTap: () => Navigator.of(context).pop(AlbumAction.members),
|
|
|
|
|
|
),
|
|
|
|
|
|
ListTile(
|
|
|
|
|
|
leading: const Icon(Icons.category_outlined),
|
|
|
|
|
|
title: Text(l10n.actionCategories),
|
|
|
|
|
|
onTap: () => Navigator.of(context).pop(AlbumAction.categories),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
ListTile(
|
|
|
|
|
|
leading: const Icon(Icons.share_outlined),
|
|
|
|
|
|
title: Text(l10n.shareAlbum),
|
|
|
|
|
|
onTap: () => Navigator.of(context).pop(AlbumAction.share),
|
|
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
if (isManager)
|
|
|
|
|
|
ListTile(
|
|
|
|
|
|
leading: Icon(
|
|
|
|
|
|
Icons.delete_outline,
|
|
|
|
|
|
color: theme.colorScheme.error,
|
|
|
|
|
|
),
|
|
|
|
|
|
title: Text(
|
|
|
|
|
|
l10n.deleteAlbum,
|
|
|
|
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
|
|
|
|
color: theme.colorScheme.error,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
onTap: () => Navigator.of(context).pop(AlbumAction.delete),
|
|
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
const Divider(indent: kSpace16, endIndent: kSpace16),
|
|
|
|
|
|
ListTile(
|
|
|
|
|
|
title: Center(
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
l10n.cancel,
|
|
|
|
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
|
|
|
|
color: theme.colorScheme.onSurfaceVariant,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
onTap: () => Navigator.of(context).pop(),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
);
|
2026-09-19 01:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
|
|
|
|
|
|
class _StatusSection extends StatelessWidget {
|
|
|
|
|
|
const _StatusSection({required this.album});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final Album album;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
2026-09-22 00:57:59 +08:00
|
|
|
|
return Wrap(
|
|
|
|
|
|
spacing: kSpace8,
|
|
|
|
|
|
runSpacing: kSpace8,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
children: [
|
|
|
|
|
|
StateBadge(
|
2026-09-23 10:13:08 +08:00
|
|
|
|
variant: album.publicEnabled
|
|
|
|
|
|
? BadgeVariant.info
|
|
|
|
|
|
: BadgeVariant.neutral,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
|
|
|
|
|
|
),
|
|
|
|
|
|
StateBadge(
|
2026-09-23 10:13:08 +08:00
|
|
|
|
variant: album.uploadEnabled
|
|
|
|
|
|
? BadgeVariant.success
|
|
|
|
|
|
: BadgeVariant.neutral,
|
|
|
|
|
|
label: album.uploadEnabled
|
|
|
|
|
|
? l10n.albumUploadsOn
|
|
|
|
|
|
: l10n.albumUploadsOff,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
if (album.status == AlbumStatus.offline)
|
2026-09-23 10:13:08 +08:00
|
|
|
|
StateBadge(
|
|
|
|
|
|
variant: BadgeVariant.error,
|
|
|
|
|
|
label: l10n.albumStatusOffline,
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 次级四入口(UI-03 §4 页面 06):Photos / Members / Categories / Share
|
|
|
|
|
|
/// 等宽平铺,每格有效触控区 ≥44dp(实际高 72)。管理类入口(成员/分类)
|
|
|
|
|
|
/// 仅管理者可见;Photos 与 Share 对全体成员开放。
|
|
|
|
|
|
class _ActionTiles extends StatelessWidget {
|
|
|
|
|
|
const _ActionTiles({
|
|
|
|
|
|
required this.isManager,
|
|
|
|
|
|
required this.onPhotos,
|
|
|
|
|
|
required this.onMembers,
|
|
|
|
|
|
required this.onCategories,
|
|
|
|
|
|
required this.onShare,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final bool isManager;
|
|
|
|
|
|
final VoidCallback onPhotos;
|
|
|
|
|
|
final VoidCallback onMembers;
|
|
|
|
|
|
final VoidCallback onCategories;
|
|
|
|
|
|
final VoidCallback onShare;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return Card(
|
|
|
|
|
|
margin: EdgeInsets.zero,
|
|
|
|
|
|
clipBehavior: Clip.antiAlias,
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: _ActionTile(
|
|
|
|
|
|
icon: Icons.photo_library_outlined,
|
|
|
|
|
|
label: AppLocalizations.of(context).actionPhotos,
|
|
|
|
|
|
onTap: onPhotos,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (isManager) ...[
|
|
|
|
|
|
const VerticalDivider(width: 1),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: _ActionTile(
|
|
|
|
|
|
icon: Icons.group_outlined,
|
|
|
|
|
|
label: AppLocalizations.of(context).actionMembers,
|
|
|
|
|
|
onTap: onMembers,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const VerticalDivider(width: 1),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: _ActionTile(
|
|
|
|
|
|
icon: Icons.category_outlined,
|
|
|
|
|
|
label: AppLocalizations.of(context).actionCategories,
|
|
|
|
|
|
onTap: onCategories,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
const VerticalDivider(width: 1),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: _ActionTile(
|
|
|
|
|
|
icon: Icons.share_outlined,
|
|
|
|
|
|
label: AppLocalizations.of(context).actionShare,
|
|
|
|
|
|
onTap: onShare,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 单个入口格:圆角容器图标 + 标签纵排(≥44dp 触控要求)。
|
|
|
|
|
|
class _ActionTile extends StatelessWidget {
|
|
|
|
|
|
const _ActionTile({
|
|
|
|
|
|
required this.icon,
|
|
|
|
|
|
required this.label,
|
|
|
|
|
|
required this.onTap,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final IconData icon;
|
|
|
|
|
|
final String label;
|
|
|
|
|
|
final VoidCallback onTap;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
|
return InkWell(
|
|
|
|
|
|
onTap: onTap,
|
|
|
|
|
|
child: ConstrainedBox(
|
|
|
|
|
|
constraints: const BoxConstraints(minHeight: 72),
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: kSpace12),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Container(
|
|
|
|
|
|
width: 44,
|
|
|
|
|
|
height: 44,
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: theme.colorScheme.primaryContainer,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(kRadiusInput),
|
|
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
child: Icon(icon, size: 22, color: theme.colorScheme.primary),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
label,
|
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Recent photos(UI-03 §4 页面 06/08):
|
|
|
|
|
|
/// - 加载中 → 占位高度居中转圈;
|
|
|
|
|
|
/// - 有照片 → 三列网格(≤9 张,真实缩略图;URL 失败为中性占位),
|
|
|
|
|
|
/// 其中存在服务端处理中的照片时在网格上方提示「Processing...」;
|
|
|
|
|
|
/// - 无照片 → 安静空态(页面 08):图标 + 「Ready for your first photo」,
|
|
|
|
|
|
/// 协作者显示只读说明;不显示伪造计数与示例照片。
|
|
|
|
|
|
class _RecentPhotosSection extends StatelessWidget {
|
|
|
|
|
|
const _RecentPhotosSection({
|
2026-09-21 15:55:44 +08:00
|
|
|
|
required this.photos,
|
|
|
|
|
|
required this.loading,
|
|
|
|
|
|
required this.isManager,
|
|
|
|
|
|
required this.onOpenPhotos,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
final List<AdminPhoto> photos;
|
|
|
|
|
|
final bool loading;
|
|
|
|
|
|
final bool isManager;
|
|
|
|
|
|
final VoidCallback onOpenPhotos;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
SectionHeader(
|
2026-09-22 00:57:59 +08:00
|
|
|
|
title: l10n.recentPhotos,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
trailing: TextButton(
|
|
|
|
|
|
onPressed: onOpenPhotos,
|
2026-09-22 00:57:59 +08:00
|
|
|
|
child: Text(l10n.seeAllPhotos),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace8),
|
|
|
|
|
|
if (loading)
|
|
|
|
|
|
const SizedBox(
|
2026-09-22 00:57:59 +08:00
|
|
|
|
height: 120,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
child: Center(
|
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
|
height: 24,
|
|
|
|
|
|
width: 24,
|
|
|
|
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-22 00:57:59 +08:00
|
|
|
|
else if (photos.isNotEmpty) ...[
|
|
|
|
|
|
if (_hasProcessing)
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(bottom: kSpace8),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Icon(
|
|
|
|
|
|
Icons.hourglass_top,
|
|
|
|
|
|
size: 16,
|
|
|
|
|
|
color: Theme.of(context).colorScheme.secondary,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
const SizedBox(width: kSpace8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
l10n.processingPhotos,
|
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
GridView.builder(
|
|
|
|
|
|
shrinkWrap: true,
|
|
|
|
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
|
|
|
|
padding: EdgeInsets.zero,
|
|
|
|
|
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
|
|
|
|
crossAxisCount: 3,
|
|
|
|
|
|
mainAxisSpacing: kSpace8,
|
|
|
|
|
|
crossAxisSpacing: kSpace8,
|
|
|
|
|
|
),
|
|
|
|
|
|
itemCount: photos.length,
|
|
|
|
|
|
itemBuilder: (context, index) => PhotoThumbnail(
|
|
|
|
|
|
url: photos[index].preview?.thumb.url,
|
|
|
|
|
|
aspectRatio: 1,
|
2026-09-28 10:35:14 +08:00
|
|
|
|
maxCacheWidth: 400,
|
2026-09-22 00:57:59 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-23 10:13:08 +08:00
|
|
|
|
] else
|
2026-09-22 00:57:59 +08:00
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: kSpace24),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Icon(
|
|
|
|
|
|
Icons.photo_camera_outlined,
|
|
|
|
|
|
size: 48,
|
|
|
|
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
2026-09-22 00:57:59 +08:00
|
|
|
|
const SizedBox(height: kSpace12),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
l10n.emptyAlbumTitle,
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace4),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
isManager ? l10n.emptyAlbumSubtitle : l10n.readonlyMember,
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
|
|
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:57:59 +08:00
|
|
|
|
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
|
2026-09-23 10:13:08 +08:00
|
|
|
|
bool get _hasProcessing =>
|
|
|
|
|
|
photos.any((p) => p.processingState == PhotoProcessingState.processing);
|
2026-09-19 01:56:38 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
|
|
|
|
|
|
/// 关闭时给出原因说明并禁用两个入口。
|
|
|
|
|
|
class _UploadBar extends StatelessWidget {
|
|
|
|
|
|
const _UploadBar({
|
|
|
|
|
|
required this.uploadEnabled,
|
|
|
|
|
|
required this.onCapture,
|
|
|
|
|
|
required this.onPick,
|
|
|
|
|
|
});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final bool uploadEnabled;
|
|
|
|
|
|
final VoidCallback onCapture;
|
|
|
|
|
|
final VoidCallback onPick;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final theme = Theme.of(context);
|
2026-09-21 15:55:44 +08:00
|
|
|
|
return SafeArea(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
if (!uploadEnabled)
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace8,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
0,
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
child: Row(
|
2026-09-19 01:56:38 +08:00
|
|
|
|
children: [
|
2026-09-21 15:55:44 +08:00
|
|
|
|
Icon(
|
|
|
|
|
|
Icons.info_outline,
|
|
|
|
|
|
size: 16,
|
|
|
|
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: kSpace4),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
l10n.albumUploadDisabledHint,
|
|
|
|
|
|
style: theme.textTheme.bodySmall,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace8,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace12,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Row(
|
2026-09-19 01:56:38 +08:00
|
|
|
|
children: [
|
|
|
|
|
|
Expanded(
|
2026-09-21 15:55:44 +08:00
|
|
|
|
child: FilledButton.icon(
|
|
|
|
|
|
onPressed: uploadEnabled ? onCapture : null,
|
|
|
|
|
|
icon: const Icon(Icons.photo_camera_outlined),
|
|
|
|
|
|
label: Text(l10n.uploadActionCapture),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
const SizedBox(width: kSpace12),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: OutlinedButton.icon(
|
|
|
|
|
|
onPressed: uploadEnabled ? onPick : null,
|
|
|
|
|
|
icon: const Icon(Icons.photo_library_outlined),
|
|
|
|
|
|
label: Text(l10n.uploadActionPick),
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-09-19 01:56:38 +08:00
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
/// 首屏错误 + 重试。
|
|
|
|
|
|
class _ErrorRetryView extends StatelessWidget {
|
|
|
|
|
|
const _ErrorRetryView({required this.message, required this.onRetry});
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final String message;
|
|
|
|
|
|
final Future<void> Function() onRetry;
|
2026-09-19 01:56:38 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
2026-09-21 15:55:44 +08:00
|
|
|
|
return Center(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Icon(Icons.cloud_off, size: 48),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
Text(message, textAlign: TextAlign.center),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
FilledButton.icon(
|
|
|
|
|
|
onPressed: onRetry,
|
|
|
|
|
|
icon: const Icon(Icons.refresh),
|
|
|
|
|
|
label: Text(l10n.retry),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-09-19 01:56:38 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|