f6929ae18f
Co-Authored-By: Claude Code <noreply@anthropic.com>
447 lines
15 KiB
Dart
447 lines
15 KiB
Dart
/// 照片大图查看器(UI-01 §4.3):已加载照片间左右切换、临近边缘加载
|
||
/// 更多、contain 完整展示;底部信息栏(上传者 / 相册时区时间 / 尺寸大
|
||
/// 小,photoId 放诊断区域);隐藏/恢复/删除单张操作(权限校验 + 删除
|
||
/// 确认「云端照片将被删除」);签名 URL 过期受控刷新一次。
|
||
///
|
||
/// 关闭后回到原网格位置(网格页状态与 ScrollController 在路由下保留)。
|
||
/// 恢复发布仅 owner/admin([PhotoGridController.isManager]);隐藏/删除
|
||
/// 为「管理者或自己上传」(服务端最终裁决,403 以 SnackBar 呈现)。
|
||
library;
|
||
|
||
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.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/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();
|
||
}
|
||
|
||
class _AlbumPhotoViewerPageState extends State<AlbumPhotoViewerPage> {
|
||
late final PageController _pageController;
|
||
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);
|
||
}
|
||
|
||
@override
|
||
void 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();
|
||
}
|
||
}
|
||
|
||
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));
|
||
|
||
Future<void> _hide(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)));
|
||
}
|
||
}
|
||
|
||
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);
|
||
// 删除确认:明确影响云端照片(与本机任务记录清理区分)。
|
||
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)));
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// 整个 Scaffold(含信息栏)随 controller 重建:隐藏/恢复/刷新合并
|
||
// 后操作行与状态需同步更新(仅重建 body 会留下过期的操作入口)。
|
||
return AnimatedBuilder(
|
||
animation: widget.controller,
|
||
builder: (context, _) {
|
||
final items = widget.controller.items;
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
body: items.isEmpty
|
||
? const _ViewerShell(child: SizedBox.shrink())
|
||
: _ViewerShell(
|
||
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 诊断区。
|
||
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 dims = (photo.width != null && photo.height != null)
|
||
? '${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.symmetric(
|
||
horizontal: kSpace16,
|
||
vertical: kSpace8,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
photo.uploader.displayName.isEmpty
|
||
? photo.uploader.account
|
||
: photo.uploader.displayName,
|
||
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),
|
||
// 诊断区域:照片 ID(不与主信息争夺层级)。
|
||
Text(
|
||
photo.photoId,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(
|
||
context,
|
||
).textTheme.labelSmall?.copyWith(color: Colors.white38),
|
||
),
|
||
Wrap(
|
||
spacing: kSpace16,
|
||
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,
|
||
),
|
||
),
|
||
if (_canDelete(photo))
|
||
TextButton.icon(
|
||
onPressed: () => _delete(photo),
|
||
icon: const Icon(Icons.delete_outline),
|
||
label: Text(l10n.delete),
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: Colors.white,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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';
|
||
}
|
||
}
|
||
|
||
/// 查看器壳层:关闭按钮(安全区避让)+ 内容。
|
||
class _ViewerShell extends StatelessWidget {
|
||
const _ViewerShell({required this.child});
|
||
|
||
final Widget child;
|
||
|
||
@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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单张大图: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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|