Files
photo-live/apps/mobile/lib/pages/album_detail_page.dart
T
浅念 ac37794d43 fix(app): path-precise duplicate matching; shared file stat; union test
Review follow-ups on 0fa23e0 (Task 2), applied as one commit:

- Path-precise dupPaths (#2, user-visible): DuplicateMatch gains
  sourcePath (local match fills it from the matched pick; unified
  server branch from picks[hit.index].path). Both pages' duplicate
  dialogs derive dupPaths from duplicates' sourcePath instead of
  basename matching, so same-basename siblings (different dir/bytes)
  are no longer mis-flagged — "跳过重复" no longer drops the
  non-duplicate sibling. Stale basename-derivation comment removed.
- Single shared stat (#3): files are stat'd once in
  checkDuplicatesUnified (_statPicks) and the picks feed both the
  local half (extracted _matchLocalHistory; public checkDuplicates
  folded — it had no remaining callers) and the server request.
  Halves stat IO for large batches (was ~2x).
- Union test (#1): new test/features/uploads/enqueue_test.dart pins
  that a file hit by BOTH local and server history yields ONE
  DuplicateMatch with the local uploadedAt (server's echoed time does
  not override). Merge dedup is now keyed by sourcePath
  (putIfAbsent, local first).

Tests: +1 enqueue union test (real-async chain inside one runAsync
window; sync file/dir setup — fake-async body must not await real
IO), +1 album_detail same-basename sibling test (skip-duplicates
enqueues exactly the non-hit sibling, distinguished by bytes since
sourceFilePath becomes the preparer's staging copy).

Gates: flutter analyze clean; full flutter test 429/429 green.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-28 20:22:37 +08:00

1001 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」
/// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再
/// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口
/// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览
/// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航);
/// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时
/// 说明原因并禁用。
///
/// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your
/// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不
/// 当绝对空白,无伪造计数与示例照片。
///
/// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份——
/// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的
/// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。
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';
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_categories_page.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';
/// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。
const int _kRecentGridLimit = 9;
/// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。
enum AlbumAction { edit, members, categories, share, delete }
/// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。
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;
/// 最近照片网格(协作者为「自己上传」,服务端过滤)。
List<AdminPhoto> _recentPhotos = const <AdminPhoto>[];
bool _photosLoading = true;
/// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。
/// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。
bool _isManager = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_loading = true;
_errorCode = null;
});
try {
final album = await ref.read(apiClientProvider).getAlbum(widget.albumId);
if (!mounted) return;
setState(() {
_album = album;
_loading = false;
});
await _loadPhotoPreview();
} on AppException catch (e) {
if (mounted) {
setState(() {
_errorCode = e.code;
_loading = false;
});
}
} on Object {
if (mounted) {
setState(() {
_errorCode = ErrorCodes.internalError;
_loading = false;
});
}
}
}
/// 照片入口预览:先按 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: _kRecentGridLimit,
);
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: _kRecentGridLimit,
);
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;
if (album == null) return;
final changed = await Navigator.of(context).push<bool>(
MaterialPageRoute<bool>(builder: (_) => AlbumFormPage(existing: album)),
);
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(
MaterialPageRoute<void>(builder: (_) => AlbumMembersPage(album: album)),
);
// 成员变更不影响详情数据,回来无需刷新。
}
Future<void> _openPhotos() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => AlbumPhotosPage(album: album)),
);
// 管理页内可能发生隐藏/删除,回来后刷新预览网格。
await _loadPhotoPreview();
}
/// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情
/// 返回,供连拍目标等使用)。
Future<void> _openCategories() async {
final album = _album;
if (album == null) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
AlbumCategoriesPage(album: album, isManager: _isManager),
),
);
await _load();
}
void _shareQr() {
final album = _album;
if (album == null) return;
// UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。
Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => ShareQrPage(album: album)));
}
/// 「⋯」操作菜单(页面 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:
await _openCategories();
case AlbumAction.share:
_shareQr();
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;
}
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l10n.albumDeleted)));
// 整栈退回列表根(编辑页等推栈路径一并清理),并刷新列表数据源。
Navigator.of(context).popUntil((route) => route.isFirst);
ref.read(albumListProvider.notifier).refresh();
}
// ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ─────────
/// 当前登录账号(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,
albumTitle: albumDisplayTitle(
album,
AppLocalizations.of(context).localeName,
),
categories: album.categories,
),
),
),
);
}
/// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。
Future<void> _pickFromGallery() async {
final album = _album;
if (album == null) return;
// P0-8:相册上传开关预检——关闭时不入队(避免白做功后 403)。
if (!album.uploadEnabled) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).albumUploadDisabled),
),
);
return;
}
final l10n = AppLocalizations.of(context);
final account = await _currentAccountId();
if (account == null || !mounted) return;
final paths = await ref.read(multiImagePickerProvider)(context);
if (paths.isEmpty || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
// P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史
// ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。
final duplicates = await checkDuplicatesUnified(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
);
Set<String> skipPaths = <String>{};
Set<String> forcePaths = <String>{};
if (duplicates != null && duplicates.isNotEmpty && mounted) {
final choice = await _showDuplicateDialog(l10n, duplicates, paths);
if (choice == null) return; // 取消。
skipPaths = choice.skipPaths;
forcePaths = choice.forcePaths;
}
final result = await enqueueForUpload(
ref,
accountId: account,
albumId: album.id,
sourcePaths: paths,
skipPaths: skipPaths.toList(),
forcePaths: forcePaths.toList(),
);
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(
result.skipped > 0
? '${result.enqueued} 张已入队,${result.skipped} 张重复已跳过'
: l10n.uploadEnqueuedCount(result.enqueued),
),
),
);
}
/// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths=
/// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端
/// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。
Future<DuplicateDialogChoice?> _showDuplicateDialog(
AppLocalizations l10n,
List<DuplicateMatch> duplicates,
List<String> allPaths,
) async {
// 重复项按完整源路径精确派生(同 basename 不同目录/扩展名的兄弟
// 文件不误伤,Task 2 评审 #2)。
final dupPaths = duplicates.map((d) => d.sourcePath).toSet();
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;
if (choice == 'skip') {
return (skipPaths: dupPaths, forcePaths: <String>{});
}
// 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。
return (skipPaths: <String>{}, forcePaths: dupPaths);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final album = _album;
final title = album == null
? l10n.albumDetail
: albumDisplayTitle(album, l10n.localeName);
return Scaffold(
appBar: AppBar(
title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
IconButton(
tooltip: l10n.moreActions,
onPressed: _showActionMenu,
icon: const Icon(Icons.more_vert),
),
],
),
bottomNavigationBar: album == null
? null
: _UploadBar(
uploadEnabled: album.uploadEnabled,
onCapture: _openCapture,
onPick: _pickFromGallery,
),
body: _loading
? const Center(child: CircularProgressIndicator())
: album == null
? _ErrorRetryView(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _load,
)
: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 封面:全宽 16:9(无封面同尺寸中性占位)。
PhotoThumbnail(
url: album.cover?.url,
aspectRatio: 16 / 9,
maxCacheWidth: 600,
),
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,
),
const SizedBox(height: kSpace4),
Text(
_metaLine(album, l10n),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: kSpace12),
_StatusSection(album: album),
],
),
),
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),
],
),
),
);
}
/// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。
static String _metaLine(Album album, AppLocalizations l10n) {
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);
return venue == null
? '$start ~ $end (${album.timezone})'
: '$start ~ $end (${album.timezone}) · $venue';
}
}
/// 操作菜单(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),
),
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),
),
const Divider(indent: kSpace16, endIndent: kSpace16),
ListTile(
title: Center(
child: Text(
l10n.cancel,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
onTap: () => Navigator.of(context).pop(),
),
],
),
),
);
}
}
/// 状态区:公开 / 上传分别显示(关闭不等于不公开)。
class _StatusSection extends StatelessWidget {
const _StatusSection({required this.album});
final Album album;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Wrap(
spacing: kSpace8,
runSpacing: kSpace8,
children: [
StateBadge(
variant: album.publicEnabled
? BadgeVariant.info
: BadgeVariant.neutral,
label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff,
),
StateBadge(
variant: album.uploadEnabled
? BadgeVariant.success
: BadgeVariant.neutral,
label: album.uploadEnabled
? l10n.albumUploadsOn
: l10n.albumUploadsOff,
),
if (album.status == AlbumStatus.offline)
StateBadge(
variant: BadgeVariant.error,
label: l10n.albumStatusOffline,
),
],
);
}
}
/// 次级四入口(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),
),
child: Icon(icon, size: 22, color: theme.colorScheme.primary),
),
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({
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(
title: l10n.recentPhotos,
trailing: TextButton(
onPressed: onOpenPhotos,
child: Text(l10n.seeAllPhotos),
),
),
const SizedBox(height: kSpace8),
if (loading)
const SizedBox(
height: 120,
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
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,
),
const SizedBox(width: kSpace8),
Text(
l10n.processingPhotos,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
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,
maxCacheWidth: 400,
),
),
] else
Padding(
padding: const EdgeInsets.symmetric(vertical: kSpace24),
child: Column(
children: [
Icon(
Icons.photo_camera_outlined,
size: 48,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
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,
),
),
],
),
),
],
);
}
/// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。
bool get _hasProcessing =>
photos.any((p) => p.processingState == PhotoProcessingState.processing);
}
/// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传
/// 关闭时给出原因说明并禁用两个入口。
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),
),
),
],
),
),
],
),
);
}
}
/// 首屏错误 + 重试。
class _ErrorRetryView extends StatelessWidget {
const _ErrorRetryView({required this.message, required this.onRetry});
final String message;
final Future<void> Function() onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
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),
),
],
),
);
}
}