/// 照片大图查看器(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 createState() => _AlbumPhotoViewerPageState(); } class _AlbumPhotoViewerPageState extends State { 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 _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 _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 _delete(AdminPhoto photo) async { final l10n = AppLocalizations.of(context); // 删除确认:明确影响云端照片(与本机任务记录清理区分)。 final confirmed = await showDialog( 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 = [ 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), ), ], ), ); }, ); } }