Files
photo-live/apps/mobile/lib/pages/album_photo_viewer_page.dart
T
2026-09-22 02:45:34 +08:00

737 lines
25 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-01 §4.3;UI-03 §4 页面 18 重设计):摄影深色背景
/// (恒深色 #10161E,不随系统主题)+ contain 完整展示 + 左右滑动/方向键
/// 切换 + 临近边缘加载更多;顶栏关闭 × 与「⋯」更多菜单;底部资料面板
/// (上传者 / 相册时区时间 · 尺寸 · 大小 / 真实可见性状态 / photoId 诊断
/// 区)+ 操作行 Hide photo|Publish again(权限裁决)与 Delete(红色,
/// 独立确认说明软删除语义)。
///
/// 工作人员管理页:不加入公众点赞/分享/下载工具栏。隐藏经轻量确认
/// BottomSheet(UI-03 §4 页面 20,[HidePhotoConfirmSheet])——确认时捕获
/// photoId,防切图错删;成功后关闭面板与查看器,回网格(滚动位置由网格
/// 页状态保留)。恢复发布仅 owner/admin([PhotoGridController.isManager]);
/// 隐藏/删除为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。
library;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../core/app_exception.dart';
import '../core/error_l10n.dart';
import '../features/albums/album_format.dart';
import '../features/albums/models.dart';
import '../features/photos/models.dart';
import '../l10n/app_localizations.dart';
import '../ui/components/photo_thumbnail.dart';
import '../ui/components/state_badge.dart';
import '../ui/tokens.dart';
import 'album_photos_page.dart';
/// 大图查看器(网格页 push 进入,共享 [controller] 的已加载条目)。
class AlbumPhotoViewerPage extends StatefulWidget {
const AlbumPhotoViewerPage({
required this.controller,
required this.album,
required this.initialIndex,
super.key,
});
final PhotoGridController controller;
final Album album;
/// 初始照片(controller.items 内的下标)。
final int initialIndex;
@override
State<AlbumPhotoViewerPage> createState() => _AlbumPhotoViewerPageState();
}
/// 「⋯」更多菜单动作(面板 pop 返回,页面侧分发执行)。
enum _ViewerMenuAction { hide, publish, delete }
class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
late final PageController _pageController;
late final FocusNode _focusNode;
late int _index;
@override
void initState() {
super.initState();
_index = widget.initialIndex.clamp(
0,
math.max(widget.controller.items.length - 1, 0),
);
_pageController = PageController(initialPage: _index);
_focusNode = FocusNode();
}
@override
void dispose() {
_focusNode.dispose();
_pageController.dispose();
super.dispose();
}
void _onPageChanged(int page) {
setState(() => _index = page);
final controller = widget.controller;
// 临近已加载末尾 → 预取下一页(loadMore 自带并发/末页守卫)。
if (page >= controller.items.length - 3 &&
controller.hasMore &&
!controller.loadingMore &&
!controller.loading) {
controller.loadMore();
}
}
/// 方向键切换(键盘/遥控可达性;与滑动同一条 PageView 序列)。
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored;
final itemCount = widget.controller.items.length;
final target = switch (event.logicalKey) {
LogicalKeyboardKey.arrowLeft => _index - 1,
LogicalKeyboardKey.arrowRight => _index + 1,
_ => _index,
};
if (target == _index || target < 0 || target >= itemCount) {
return KeyEventResult.ignored;
}
_pageController.animateToPage(
target,
duration: motion(context, carousel: true),
curve: Curves.easeOutCubic,
);
return KeyEventResult.handled;
}
bool _isOwnUpload(AdminPhoto photo) =>
widget.controller.selfAccount != null &&
photo.uploader.account == widget.controller.selfAccount;
/// 隐藏(published→hidden):管理者或自己上传。
bool _canHide(AdminPhoto photo) =>
photo.visibility == PhotoVisibility.published &&
(widget.controller.isManager || _isOwnUpload(photo));
/// 恢复显示(hidden→published):仅管理者(服务端同口径)。
bool _canUnhide(AdminPhoto photo) =>
photo.visibility == PhotoVisibility.hidden && widget.controller.isManager;
/// 删除:管理者或自己上传(未删除态)。
bool _canDelete(AdminPhoto photo) =>
photo.visibility != PhotoVisibility.deleted &&
(widget.controller.isManager || _isOwnUpload(photo));
/// 隐藏确认(页面 20):轻量 BottomSheet;API 调用在面板内执行(提交中
/// 防重复、失败面板内提示可重试),photoId 打开时捕获。
Future<void> _confirmAndHide(AdminPhoto photo) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
final hidden = await showModalBottomSheet<bool>(
context: context,
showDragHandle: true,
builder: (_) => HidePhotoConfirmSheet(
photo: photo,
onConfirm: widget.controller.hidePhoto,
),
);
if (hidden != true || !mounted) return;
// 成功:回网格(条目状态已合并,瓦片角标翻转为 Hidden)。
Navigator.of(context).pop();
messenger.showSnackBar(SnackBar(content: Text(l10n.photoHiddenToast)));
}
Future<void> _unhide(AdminPhoto photo) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.controller.unhidePhoto(photo);
messenger.showSnackBar(SnackBar(content: Text(l10n.photoUnhiddenToast)));
} on AppException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e))));
} on Object {
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
}
}
Future<void> _delete(AdminPhoto photo) async {
final l10n = AppLocalizations.of(context);
// 删除确认(与隐藏确认分开):说明当前实际软删除语义——标记 deleted
// 公开端点即刻不可见,物理删除走独立运维流程(openapi deletePhoto)。
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: Text(l10n.photoDeleteConfirmTitle),
content: Text(l10n.photoDeleteConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(l10n.delete),
),
],
),
);
if (confirmed != true || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await widget.controller.deletePhoto(photo);
widget.controller.removeById(photo.photoId);
if (!mounted) return;
messenger.showSnackBar(SnackBar(content: Text(l10n.photoDeletedToast)));
// 当前照片已删除且列表为空 → 回网格;否则夹取到有效页。
if (widget.controller.items.isEmpty) {
Navigator.of(context).pop();
} else {
setState(() {
_index = _index.clamp(0, widget.controller.items.length - 1);
});
}
} on AppException catch (e) {
messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e))));
} on Object {
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
}
}
/// 「⋯」更多菜单:与底部操作行同一套权限裁决(先关面板再执行)。
Future<void> _showMoreMenu(AdminPhoto photo) async {
final l10n = AppLocalizations.of(context);
final action = await showModalBottomSheet<_ViewerMenuAction>(
context: context,
showDragHandle: true,
builder: (_) => SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_canHide(photo))
ListTile(
leading: const Icon(Icons.visibility_off_outlined),
title: Text(l10n.photoHideAction),
onTap: () => Navigator.of(context).pop(_ViewerMenuAction.hide),
),
if (_canUnhide(photo))
ListTile(
leading: const Icon(Icons.visibility_outlined),
title: Text(l10n.photoPublishAction),
onTap: () =>
Navigator.of(context).pop(_ViewerMenuAction.publish),
),
if (_canDelete(photo))
ListTile(
leading: Icon(
Icons.delete_outline,
color: Theme.of(context).colorScheme.error,
),
title: Text(l10n.delete),
onTap: () =>
Navigator.of(context).pop(_ViewerMenuAction.delete),
),
],
),
),
);
if (!mounted || action == null) return;
switch (action) {
case _ViewerMenuAction.hide:
await _confirmAndHide(photo);
case _ViewerMenuAction.publish:
await _unhide(photo);
case _ViewerMenuAction.delete:
await _delete(photo);
}
}
@override
Widget build(BuildContext context) {
// 整个 Scaffold(含信息栏)随 controller 重建:隐藏/恢复/刷新合并
// 后操作行与状态需同步更新(仅重建 body 会留下过期的操作入口)。
return AnimatedBuilder(
animation: widget.controller,
builder: (context, _) {
final items = widget.controller.items;
return Scaffold(
// 摄影深色背景(恒深色,不随系统主题;spec §3)。
backgroundColor: kColorPhotoBackdrop,
body: items.isEmpty
? const _ViewerShell(child: SizedBox.shrink())
: _ViewerShell(
onMore: () =>
_showMoreMenu(items[_index.clamp(0, items.length - 1)]),
onKey: _onKey,
focusNode: _focusNode,
child: PageView.builder(
controller: _pageController,
itemCount: items.length,
onPageChanged: _onPageChanged,
itemBuilder: (context, page) => _ViewerPhotoCell(
photo: items[page],
album: widget.album,
controller: widget.controller,
),
),
),
bottomNavigationBar: _infoBar(context),
);
},
);
}
/// 底部资料 + 操作栏(安全区避让):上传者 / 时区时间 · 尺寸 · 大小 /
/// 可见性状态 / photoId 诊断区 / Hide photo(Publish again)+ Delete。
Widget _infoBar(BuildContext context) {
final l10n = AppLocalizations.of(context);
final items = widget.controller.items;
if (items.isEmpty) return const SizedBox.shrink();
final photo = items[_index.clamp(0, items.length - 1)];
final uploader = photo.uploader.displayName.isEmpty
? photo.uploader.account
: photo.uploader.displayName;
final dims = (photo.width != null && photo.height != null)
? l10n.photoDimensions(photo.width!, photo.height!)
: null;
final size = _formatBytes(photo.bytes);
final meta = <String>[
formatAlbumDateTime(
photo.uploadedAt,
widget.album.timezone,
l10n.localeName,
),
?dims,
?size,
].join(' · ');
return SafeArea(
top: false,
child: Container(
color: Colors.black.withValues(alpha: 0.72),
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace12,
kSpace16,
kSpace8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.photoUploadedBy(uploader),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(color: Colors.white),
),
const SizedBox(height: kSpace4),
Text(meta, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: kSpace4),
Text(
'${l10n.photoStatus}: ${_visibilityLabel(l10n, photo.visibility)}',
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: kSpace4),
// 诊断区域:照片 ID(不与主信息争夺层级)。
Text(
photo.photoId,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: Colors.white38),
),
const SizedBox(height: kSpace4),
Row(
children: [
if (_canHide(photo))
Expanded(
child: OutlinedButton.icon(
onPressed: () => _confirmAndHide(photo),
icon: const Icon(Icons.visibility_off_outlined, size: 18),
label: Text(l10n.photoHideAction),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
),
),
)
else if (_canUnhide(photo))
Expanded(
child: OutlinedButton.icon(
onPressed: () => _unhide(photo),
icon: const Icon(Icons.visibility_outlined, size: 18),
label: Text(l10n.photoPublishAction),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white38),
),
),
),
if (_canDelete(photo) && (_canHide(photo) || _canUnhide(photo)))
const SizedBox(width: kSpace12),
if (_canDelete(photo))
Expanded(
child: OutlinedButton.icon(
onPressed: () => _delete(photo),
icon: const Icon(Icons.delete_outline, size: 18),
label: Text(l10n.delete),
style: OutlinedButton.styleFrom(
// 深色表面用提亮错误变体保证对比度。
foregroundColor: kColorErrorDark,
side: const BorderSide(color: kColorErrorDark),
),
),
),
],
),
],
),
),
);
}
static String _visibilityLabel(
AppLocalizations l10n,
PhotoVisibility visibility,
) => switch (visibility) {
PhotoVisibility.published => l10n.visibilityPublished,
PhotoVisibility.unpublished => l10n.visibilityUnpublished,
PhotoVisibility.hidden => l10n.visibilityHidden,
PhotoVisibility.deleted => l10n.visibilityDeleted,
};
static String? _formatBytes(int bytes) {
if (bytes <= 0) return null;
if (bytes < 1024 * 1024) return '${(bytes / 1024).round()} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
/// 查看器壳层:顶栏(关闭 × + 更多 ⋯,白图标 + 半透明圆底,≥44dp 触控)
/// + 方向键焦点 + 内容。
class _ViewerShell extends StatelessWidget {
const _ViewerShell({
required this.child,
this.onMore,
this.onKey,
this.focusNode,
});
final Widget child;
final VoidCallback? onMore;
final FocusOnKeyEventCallback? onKey;
final FocusNode? focusNode;
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(
child: Focus(
autofocus: true,
focusNode: focusNode,
onKeyEvent: onKey,
child: child,
),
),
// 顶栏显式 Positioned:Stack 尺寸不随内容收缩(Row 会 shrink-wrap,
// 旧实现的 Align 自扩张——换成 Row 后必须定位,否则 PageView 被压
// 到顶栏高度)。
Positioned(
top: 0,
left: 0,
right: 0,
child: SafeArea(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
onPressed: () => Navigator.of(context).pop(),
// 44×44 有效触控区(图标本身 24)。
icon: const Icon(Icons.close, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
),
),
if (onMore != null)
IconButton(
tooltip: MaterialLocalizations.of(
context,
).moreButtonTooltip,
onPressed: onMore,
icon: const Icon(Icons.more_vert, color: Colors.white),
style: IconButton.styleFrom(
backgroundColor: Colors.black45,
foregroundColor: Colors.white,
),
),
],
),
),
),
],
);
}
}
/// 单张大图:contain 展示;签名 URL 失败 → 一次受控刷新;无预览(处理
/// 中/已删除/无权)显示状态说明。
class _ViewerPhotoCell extends StatelessWidget {
const _ViewerPhotoCell({
required this.photo,
required this.album,
required this.controller,
});
final AdminPhoto photo;
final Album album;
final PhotoGridController controller;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final url = photo.preview?.browse.url;
if (url == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.image_not_supported_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: kSpace12),
StateBadge(
variant: _stateBadgeVariant(photo),
label: _stateBadgeLabel(l10n, photo),
),
const SizedBox(height: kSpace8),
Text(
l10n.photoPreviewUnavailable,
style: const TextStyle(color: Colors.white54),
),
],
),
);
}
return _ViewerImage(
imageUrl: url,
photoId: photo.photoId,
controller: controller,
);
}
static BadgeVariant _stateBadgeVariant(AdminPhoto photo) {
if (photo.visibility == PhotoVisibility.deleted ||
photo.processingState == PhotoProcessingState.processingFailed) {
return BadgeVariant.error;
}
return BadgeVariant.warning;
}
static String _stateBadgeLabel(AppLocalizations l10n, AdminPhoto photo) {
if (photo.visibility == PhotoVisibility.deleted) {
return l10n.visibilityDeleted;
}
if (photo.processingState == PhotoProcessingState.processingFailed) {
return l10n.procProcessingFailed;
}
return l10n.procProcessing;
}
}
/// 大图 Image.network:失败时自动按原页参数刷新一次签名 URL;仍失败显示
/// 占位(不再重试,见 [PhotoGridController.refreshPreview])。
class _ViewerImage extends StatefulWidget {
const _ViewerImage({
required this.imageUrl,
required this.photoId,
required this.controller,
});
final String imageUrl;
final String photoId;
final PhotoGridController controller;
@override
State<_ViewerImage> createState() => _ViewerImageState();
}
class _ViewerImageState extends State<_ViewerImage> {
bool _autoRefreshScheduled = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Image.network(
widget.imageUrl,
fit: BoxFit.contain,
gaplessPlayback: true,
loadingBuilder: (context, child, progress) => progress == null
? child
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
errorBuilder: (_, _, _) {
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
if (!_autoRefreshScheduled &&
widget.controller.canRefresh(widget.photoId)) {
_autoRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.controller.refreshPreview(widget.photoId);
});
}
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: kSpace8),
Text(
l10n.photoPreviewUnavailable,
style: const TextStyle(color: Colors.white54),
),
],
),
);
},
);
}
}
/// 隐藏确认 BottomSheet(UI-03 §4 页面 20):标题 + 当前图缩略图(有可见
/// 预览才显示)+ 影响说明 + Hide photo(主操作)/ Cancel。
///
/// [photo] 在打开时捕获——确认请求始终作用于该 photoId,不受查看器后续
/// 切图/加载影响([onConfirm] 收到的即该对象)。提交中防重复;失败保留
/// 面板并给出可重试提示;取消不请求接口。pop(true) = 已隐藏,pop()/false
/// = 取消。
class HidePhotoConfirmSheet extends StatefulWidget {
const HidePhotoConfirmSheet({
required this.photo,
required this.onConfirm,
super.key,
});
final AdminPhoto photo;
/// 执行隐藏(如 controller.hidePhoto);抛异常 = 失败(面板保留)。
final Future<void> Function(AdminPhoto photo) onConfirm;
@override
State<HidePhotoConfirmSheet> createState() => _HidePhotoConfirmSheetState();
}
class _HidePhotoConfirmSheetState extends State<HidePhotoConfirmSheet> {
bool _submitting = false;
String? _error;
Future<void> _submit(AppLocalizations l10n) async {
if (_submitting) return;
setState(() {
_submitting = true;
_error = null;
});
try {
await widget.onConfirm(widget.photo);
if (!mounted) return;
Navigator.of(context).pop(true);
} on AppException catch (e) {
setState(() {
_submitting = false;
_error = l10n.messageForError(e);
});
} on Object {
setState(() {
_submitting = false;
_error = l10n.errorGeneric;
});
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final thumbUrl = widget.photo.preview?.thumb.url;
return SafeArea(
top: false,
child: SingleChildScrollView(
// 小屏/横屏高度不足时面板可滚动,避免压缩溢出。
child: Padding(
padding: const EdgeInsets.fromLTRB(kSpace16, 0, kSpace16, kSpace16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
l10n.hidePhotoTitle,
textAlign: TextAlign.center,
style: theme.textTheme.titleMedium,
),
if (thumbUrl != null) ...[
const SizedBox(height: kSpace12),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusBadge),
child: SizedBox(
height: 96,
width: 96,
child: PhotoThumbnail(url: thumbUrl, aspectRatio: 1),
),
),
),
],
const SizedBox(height: kSpace12),
Text(
l10n.hidePhotoBody,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
if (_error != null) ...[
const SizedBox(height: kSpace8),
Text(
_error!,
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
const SizedBox(height: kSpace16),
FilledButton(
onPressed: _submitting ? null : () => _submit(l10n),
child: _submitting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(l10n.photoHideAction),
),
TextButton(
onPressed: _submitting
? null
: () => Navigator.of(context).pop(false),
child: Text(l10n.cancel),
),
],
),
),
),
);
}
}