/// 照片大图查看器(UI-01 §4.3;UI-03 §4 页面 18 重设计):摄影深色背景 /// (恒深色 #10161E,不随系统主题)+ contain 完整展示 + 左右滑动/方向键 /// 切换 + 临近边缘加载更多;顶栏关闭 × 与「⋯」更多菜单;底部资料面板 /// (上传者 / 相册时区时间 · 尺寸 · 大小 / 真实可见性状态 / photoId 诊断 /// 区)+ 操作行 Hide photo|Publish again(权限裁决)与 Delete(红色, /// 独立确认说明软删除语义)。 /// /// 工作人员管理页:不加入公众点赞/分享/下载工具栏。隐藏经轻量确认 /// 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'; 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'; /// 大图查看器(网格页 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(); } /// 「⋯」更多菜单动作(面板 pop 返回,页面侧分发执行)。 enum _ViewerMenuAction { hide, publish, delete } class _AlbumPhotoViewerPageState extends State { 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), ); _pageController = PageController(initialPage: _index); _focusNode = FocusNode(); } @override void dispose() { _focusNode.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(); } } /// 方向键切换(键盘/遥控可达性;与滑动同一条 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; /// 隐藏(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)); /// 隐藏确认(页面 20):轻量 BottomSheet;API 调用在面板内执行(提交中 /// 防重复、失败面板内提示可重试),photoId 打开时捕获。 Future _confirmAndHide(AdminPhoto photo) async { final l10n = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); final hidden = await showModalBottomSheet( 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 _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); // 删除确认(与隐藏确认分开):说明当前实际软删除语义——标记 deleted // 公开端点即刻不可见,物理删除走独立运维流程(openapi deletePhoto)。 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))); } } /// 「⋯」更多菜单:与底部操作行同一套权限裁决(先关面板再执行)。 Future _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 重建:隐藏/恢复/刷新合并 // 后操作行与状态需同步更新(仅重建 body 会留下过期的操作入口)。 return AnimatedBuilder( animation: widget.controller, builder: (context, _) { final items = widget.controller.items; return Scaffold( // 摄影深色背景(恒深色,不随系统主题;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, onPageChanged: _onPageChanged, itemBuilder: (context, page) => _ViewerPhotoCell( photo: items[page], album: widget.album, controller: widget.controller, ), ), ), bottomNavigationBar: _infoBar(context), ); }, ); } /// 底部资料 + 操作栏(安全区避让):上传者 / 时区时间 · 尺寸 · 大小 / /// 可见性状态 / 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) ? l10n.photoDimensions(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.fromLTRB( kSpace16, kSpace12, kSpace16, kSpace8, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( l10n.photoUploadedBy(uploader), 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), Text( '${l10n.photoStatus}: ${_visibilityLabel(l10n, photo.visibility)}', 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), ), const SizedBox(height: kSpace4), Row( children: [ if (_canHide(photo)) 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)) 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), ), ), ), ], ), ], ), ), ); } 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'; return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; } } /// 查看器壳层:顶栏(关闭 × + 更多 ⋯,白图标 + 半透明圆底,≥44dp 触控) /// + 方向键焦点 + 内容。 class _ViewerShell extends StatelessWidget { 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: 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, ), ), ], ), ), ), ], ); } } /// 单张大图: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), ), ], ), ); }, ); } } /// 隐藏确认 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 Function(AdminPhoto photo) onConfirm; @override State createState() => _HidePhotoConfirmSheetState(); } class _HidePhotoConfirmSheetState extends State { bool _submitting = false; String? _error; Future _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), ), ], ), ), ), ); } }