Files
photo-live/apps/mobile/lib/pages/album_photo_viewer_page.dart
T
2026-09-21 15:55:44 +08:00

447 lines
15 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):已加载照片间左右切换、临近边缘加载
/// 更多、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),
),
],
),
);
},
);
}
}