feat(mobile): UI-03 photos and share pages (17-20)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 02:45:34 +08:00
parent ab51616c70
commit 8ee69598bf
14 changed files with 1338 additions and 379 deletions
@@ -1,16 +1,21 @@
/// 照片大图查看器(UI-01 §4.3):已加载照片间左右切换、临近边缘加载
/// 更多、contain 完整展示;底部信息栏(上传者 / 相册时区时间 / 尺寸大
/// 小,photoId 放诊断区域);隐藏/恢复/删除单张操作(权限校验 + 删除
/// 确认「云端照片将被删除」);签名 URL 过期受控刷新一次。
/// 照片大图查看器(UI-01 §4.3;UI-03 §4 页面 18 重设计):摄影深色背景
/// (恒深色 #10161E,不随系统主题)+ contain 完整展示 + 左右滑动/方向键
/// 切换 + 临近边缘加载更多;顶栏关闭 × 与「⋯」更多菜单;底部资料面板
/// (上传者 / 相册时区时间 · 尺寸 · 大小 / 真实可见性状态 / photoId 诊断
/// 区)+ 操作行 Hide photo|Publish again(权限裁决)与 Delete(红色,
/// 独立确认说明软删除语义)。
///
/// 关闭后回到原网格位置(网格页状态与 ScrollController 在路由下保留)。
/// 恢复发布仅 owner/admin([PhotoGridController.isManager]);隐藏/删除
/// 为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。
/// 工作人员管理页:不加入公众点赞/分享/下载工具栏。隐藏经轻量确认
/// 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';
@@ -18,6 +23,7 @@ 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';
@@ -41,19 +47,28 @@ class AlbumPhotoViewerPage extends StatefulWidget {
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));
_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();
}
@@ -70,6 +85,26 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
}
}
/// 方向键切换(键盘/遥控可达性;与滑动同一条 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;
@@ -88,19 +123,23 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
photo.visibility != PhotoVisibility.deleted &&
(widget.controller.isManager || _isOwnUpload(photo));
Future<void> _hide(AdminPhoto photo) async {
/// 隐藏确认(页面 20):轻量 BottomSheet;API 调用在面板内执行(提交中
/// 防重复、失败面板内提示可重试),photoId 打开时捕获。
Future<void> _confirmAndHide(AdminPhoto photo) async {
final l10n = AppLocalizations.of(context);
final messenger = ScaffoldMessenger.of(context);
try {
await widget.controller.hidePhoto(photo);
messenger.showSnackBar(SnackBar(content: Text(l10n.photoHiddenToast)));
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
} on Object {
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
}
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 {
@@ -110,9 +149,7 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
await widget.controller.unhidePhoto(photo);
messenger.showSnackBar(SnackBar(content: Text(l10n.photoUnhiddenToast)));
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(e))),
);
messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e))));
} on Object {
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
}
@@ -120,7 +157,8 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
Future<void> _delete(AdminPhoto photo) async {
final l10n = AppLocalizations.of(context);
// 删除确认:明确影响云端照片(与本机任务记录清理区分)。
// 删除确认(与隐藏确认分开):说明当前实际软删除语义——标记 deleted
// 公开端点即刻不可见,物理删除走独立运维流程(openapi deletePhoto)。
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
@@ -154,14 +192,61 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
});
}
} on AppException catch (e) {
messenger.showSnackBar(
SnackBar(content: Text(l10n.messageForError(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 重建:隐藏/恢复/刷新合并
@@ -171,10 +256,15 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
builder: (context, _) {
final items = widget.controller.items;
return Scaffold(
backgroundColor: Colors.black,
// 摄影深色背景(恒深色,不随系统主题;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,
@@ -192,14 +282,18 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
);
}
/// 底部信息栏:上传者 / 时区时间 / 尺寸大小 + 操作行 + photoId 诊断区。
/// 底部资料 + 操作栏(安全区避让):上传者 / 时区时间 · 尺寸 · 大小 /
/// 可见性状态 / 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)
? '${photo.width}×${photo.height}'
? l10n.photoDimensions(photo.width!, photo.height!)
: null;
final size = _formatBytes(photo.bytes);
final meta = <String>[
@@ -215,27 +309,32 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
top: false,
child: Container(
color: Colors.black.withValues(alpha: 0.72),
padding: const EdgeInsets.symmetric(
horizontal: kSpace16,
vertical: kSpace8,
padding: const EdgeInsets.fromLTRB(
kSpace16,
kSpace12,
kSpace16,
kSpace8,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
photo.uploader.displayName.isEmpty
? photo.uploader.account
: photo.uploader.displayName,
l10n.photoUploadedBy(uploader),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.white,
),
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,
@@ -245,34 +344,46 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
context,
).textTheme.labelSmall?.copyWith(color: Colors.white38),
),
Wrap(
spacing: kSpace16,
const SizedBox(height: kSpace4),
Row(
children: [
if (_canHide(photo))
TextButton.icon(
onPressed: () => _hide(photo),
icon: const Icon(Icons.visibility_off_outlined),
label: Text(l10n.photoHide),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
),
),
if (_canUnhide(photo))
TextButton.icon(
onPressed: () => _unhide(photo),
icon: const Icon(Icons.visibility_outlined),
label: Text(l10n.photoUnhide),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
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))
TextButton.icon(
onPressed: () => _delete(photo),
icon: const Icon(Icons.delete_outline),
label: Text(l10n.delete),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
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),
),
),
),
],
@@ -283,6 +394,16 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
);
}
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';
@@ -290,29 +411,68 @@ class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
}
}
/// 查看器壳层:关闭按钮(安全区避让)+ 内容。
/// 查看器壳层:顶栏(关闭 × + 更多 ⋯,白图标 + 半透明圆底,≥44dp 触控)
/// + 方向键焦点 + 内容。
class _ViewerShell extends StatelessWidget {
const _ViewerShell({required this.child});
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: child),
SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: 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,
),
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,
),
),
],
),
),
),
@@ -343,7 +503,11 @@ class _ViewerPhotoCell extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.image_not_supported_outlined, size: 48, color: Colors.white38),
const Icon(
Icons.image_not_supported_outlined,
size: 48,
color: Colors.white38,
),
const SizedBox(height: kSpace12),
StateBadge(
variant: _stateBadgeVariant(photo),
@@ -411,13 +575,13 @@ class _ViewerImageState extends State<_ViewerImage> {
widget.imageUrl,
fit: BoxFit.contain,
gaplessPlayback: true,
loadingBuilder: (context, child, progress) =>
progress == null ? child : const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
loadingBuilder: (context, child, progress) => progress == null
? child
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
errorBuilder: (_, _, _) {
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
if (!_autoRefreshScheduled && widget.controller.canRefresh(widget.photoId)) {
if (!_autoRefreshScheduled &&
widget.controller.canRefresh(widget.photoId)) {
_autoRefreshScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.controller.refreshPreview(widget.photoId);
@@ -444,3 +608,129 @@ class _ViewerImageState extends State<_ViewerImage> {
);
}
}
/// 隐藏确认 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),
),
],
),
),
),
);
}
}