2026-09-22 02:45:34 +08:00
|
|
|
|
/// 照片大图查看器(UI-01 §4.3;UI-03 §4 页面 18 重设计):摄影深色背景
|
|
|
|
|
|
/// (恒深色 #10161E,不随系统主题)+ contain 完整展示 + 左右滑动/方向键
|
|
|
|
|
|
/// 切换 + 临近边缘加载更多;顶栏关闭 × 与「⋯」更多菜单;底部资料面板
|
|
|
|
|
|
/// (上传者 / 相册时区时间 · 尺寸 · 大小 / 真实可见性状态 / photoId 诊断
|
|
|
|
|
|
/// 区)+ 操作行 Hide photo|Publish again(权限裁决)与 Delete(红色,
|
|
|
|
|
|
/// 独立确认说明软删除语义)。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
///
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 工作人员管理页:不加入公众点赞/分享/下载工具栏。隐藏经轻量确认
|
|
|
|
|
|
/// BottomSheet(UI-03 §4 页面 20,[HidePhotoConfirmSheet])——确认时捕获
|
|
|
|
|
|
/// photoId,防切图错删;成功后关闭面板与查看器,回网格(滚动位置由网格
|
|
|
|
|
|
/// 页状态保留)。恢复发布仅 owner/admin([PhotoGridController.isManager]);
|
|
|
|
|
|
/// 隐藏/删除为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
library;
|
|
|
|
|
|
|
|
|
|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart';
|
2026-09-22 02:45:34 +08:00
|
|
|
|
import 'package:flutter/services.dart';
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
|
|
|
|
|
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';
|
2026-09-22 02:45:34 +08:00
|
|
|
|
import '../ui/components/photo_thumbnail.dart';
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 「⋯」更多菜单动作(面板 pop 返回,页面侧分发执行)。
|
|
|
|
|
|
enum _ViewerMenuAction { hide, publish, delete }
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
|
|
|
|
|
late final PageController _pageController;
|
2026-09-22 02:45:34 +08:00
|
|
|
|
late final FocusNode _focusNode;
|
2026-09-21 15:55:44 +08:00
|
|
|
|
late int _index;
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void initState() {
|
|
|
|
|
|
super.initState();
|
2026-09-22 02:45:34 +08:00
|
|
|
|
_index = widget.initialIndex.clamp(
|
|
|
|
|
|
0,
|
|
|
|
|
|
math.max(widget.controller.items.length - 1, 0),
|
|
|
|
|
|
);
|
2026-09-21 15:55:44 +08:00
|
|
|
|
_pageController = PageController(initialPage: _index);
|
2026-09-22 02:45:34 +08:00
|
|
|
|
_focusNode = FocusNode();
|
2026-09-21 15:55:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
2026-09-22 02:45:34 +08:00
|
|
|
|
_focusNode.dispose();
|
2026-09-21 15:55:44 +08:00
|
|
|
|
_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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 方向键切换(键盘/遥控可达性;与滑动同一条 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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));
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 隐藏确认(页面 20):轻量 BottomSheet;API 调用在面板内执行(提交中
|
|
|
|
|
|
/// 防重复、失败面板内提示可重试),photoId 打开时捕获。
|
|
|
|
|
|
Future<void> _confirmAndHide(AdminPhoto photo) async {
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
2026-09-22 02:45:34 +08:00
|
|
|
|
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)));
|
2026-09-21 15:55:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-09-22 02:45:34 +08:00
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e))));
|
2026-09-21 15:55:44 +08:00
|
|
|
|
} on Object {
|
|
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _delete(AdminPhoto photo) async {
|
|
|
|
|
|
final l10n = AppLocalizations.of(context);
|
2026-09-22 02:45:34 +08:00
|
|
|
|
// 删除确认(与隐藏确认分开):说明当前实际软删除语义——标记 deleted
|
|
|
|
|
|
// 公开端点即刻不可见,物理删除走独立运维流程(openapi deletePhoto)。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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) {
|
2026-09-22 02:45:34 +08:00
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.messageForError(e))));
|
2026-09-21 15:55:44 +08:00
|
|
|
|
} on Object {
|
|
|
|
|
|
messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric)));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 「⋯」更多菜单:与底部操作行同一套权限裁决(先关面板再执行)。
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
// 整个 Scaffold(含信息栏)随 controller 重建:隐藏/恢复/刷新合并
|
|
|
|
|
|
// 后操作行与状态需同步更新(仅重建 body 会留下过期的操作入口)。
|
|
|
|
|
|
return AnimatedBuilder(
|
|
|
|
|
|
animation: widget.controller,
|
|
|
|
|
|
builder: (context, _) {
|
|
|
|
|
|
final items = widget.controller.items;
|
|
|
|
|
|
return Scaffold(
|
2026-09-22 02:45:34 +08:00
|
|
|
|
// 摄影深色背景(恒深色,不随系统主题;spec §3)。
|
|
|
|
|
|
backgroundColor: kColorPhotoBackdrop,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
body: items.isEmpty
|
|
|
|
|
|
? const _ViewerShell(child: SizedBox.shrink())
|
|
|
|
|
|
: _ViewerShell(
|
2026-09-22 02:45:34 +08:00
|
|
|
|
onMore: () =>
|
|
|
|
|
|
_showMoreMenu(items[_index.clamp(0, items.length - 1)]),
|
|
|
|
|
|
onKey: _onKey,
|
|
|
|
|
|
focusNode: _focusNode,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 底部资料 + 操作栏(安全区避让):上传者 / 时区时间 · 尺寸 · 大小 /
|
|
|
|
|
|
/// 可见性状态 / photoId 诊断区 / Hide photo(Publish again)+ Delete。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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)];
|
2026-09-22 02:45:34 +08:00
|
|
|
|
final uploader = photo.uploader.displayName.isEmpty
|
|
|
|
|
|
? photo.uploader.account
|
|
|
|
|
|
: photo.uploader.displayName;
|
2026-09-21 15:55:44 +08:00
|
|
|
|
final dims = (photo.width != null && photo.height != null)
|
2026-09-22 02:45:34 +08:00
|
|
|
|
? l10n.photoDimensions(photo.width!, photo.height!)
|
2026-09-21 15:55:44 +08:00
|
|
|
|
: 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),
|
2026-09-22 02:45:34 +08:00
|
|
|
|
padding: const EdgeInsets.fromLTRB(
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace12,
|
|
|
|
|
|
kSpace16,
|
|
|
|
|
|
kSpace8,
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(
|
2026-09-22 02:45:34 +08:00
|
|
|
|
l10n.photoUploadedBy(uploader),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
2026-09-22 02:45:34 +08:00
|
|
|
|
style: Theme.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).textTheme.titleSmall?.copyWith(color: Colors.white),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace4),
|
|
|
|
|
|
Text(meta, style: const TextStyle(color: Colors.white70)),
|
|
|
|
|
|
const SizedBox(height: kSpace4),
|
2026-09-22 02:45:34 +08:00
|
|
|
|
Text(
|
|
|
|
|
|
'${l10n.photoStatus}: ${_visibilityLabel(l10n, photo.visibility)}',
|
|
|
|
|
|
style: const TextStyle(color: Colors.white70),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: kSpace4),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
// 诊断区域:照片 ID(不与主信息争夺层级)。
|
|
|
|
|
|
Text(
|
|
|
|
|
|
photo.photoId,
|
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
style: Theme.of(
|
|
|
|
|
|
context,
|
|
|
|
|
|
).textTheme.labelSmall?.copyWith(color: Colors.white38),
|
|
|
|
|
|
),
|
2026-09-22 02:45:34 +08:00
|
|
|
|
const SizedBox(height: kSpace4),
|
|
|
|
|
|
Row(
|
2026-09-21 15:55:44 +08:00
|
|
|
|
children: [
|
|
|
|
|
|
if (_canHide(photo))
|
2026-09-22 02:45:34 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
2026-09-22 02:45:34 +08:00
|
|
|
|
)
|
|
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-09-22 02:45:34 +08:00
|
|
|
|
if (_canDelete(photo) && (_canHide(photo) || _canUnhide(photo)))
|
|
|
|
|
|
const SizedBox(width: kSpace12),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
if (_canDelete(photo))
|
2026-09-22 02:45:34 +08:00
|
|
|
|
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),
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
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,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 02:45:34 +08:00
|
|
|
|
/// 查看器壳层:顶栏(关闭 × + 更多 ⋯,白图标 + 半透明圆底,≥44dp 触控)
|
|
|
|
|
|
/// + 方向键焦点 + 内容。
|
2026-09-21 15:55:44 +08:00
|
|
|
|
class _ViewerShell extends StatelessWidget {
|
2026-09-22 02:45:34 +08:00
|
|
|
|
const _ViewerShell({
|
|
|
|
|
|
required this.child,
|
|
|
|
|
|
this.onMore,
|
|
|
|
|
|
this.onKey,
|
|
|
|
|
|
this.focusNode,
|
|
|
|
|
|
});
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
|
|
|
|
|
final Widget child;
|
2026-09-22 02:45:34 +08:00
|
|
|
|
final VoidCallback? onMore;
|
|
|
|
|
|
final FocusOnKeyEventCallback? onKey;
|
|
|
|
|
|
final FocusNode? focusNode;
|
2026-09-21 15:55:44 +08:00
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
return Stack(
|
|
|
|
|
|
children: [
|
2026-09-22 02:45:34 +08:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-09-21 15:55:44 +08:00
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 单张大图: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: [
|
2026-09-22 02:45:34 +08:00
|
|
|
|
const Icon(
|
|
|
|
|
|
Icons.image_not_supported_outlined,
|
|
|
|
|
|
size: 48,
|
|
|
|
|
|
color: Colors.white38,
|
|
|
|
|
|
),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
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,
|
2026-09-22 02:45:34 +08:00
|
|
|
|
loadingBuilder: (context, child, progress) => progress == null
|
|
|
|
|
|
? child
|
|
|
|
|
|
: const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
2026-09-21 15:55:44 +08:00
|
|
|
|
errorBuilder: (_, _, _) {
|
|
|
|
|
|
// 失败 → 一次受控刷新(新签名 URL 触发重建重载)。
|
2026-09-22 02:45:34 +08:00
|
|
|
|
if (!_autoRefreshScheduled &&
|
|
|
|
|
|
widget.controller.canRefresh(widget.photoId)) {
|
2026-09-21 15:55:44 +08:00
|
|
|
|
_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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-22 02:45:34 +08:00
|
|
|
|
|
|
|
|
|
|
/// 隐藏确认 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),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|