/// 相册详情页(UI-03 §4 页面 06-08 重设计):顶栏返回 + 标题 +「⋯」 /// 操作菜单(BottomSheet:编辑/成员/分类/分享,按角色过滤,先关面板再 /// 导航)→ 全宽 16:9 封面 → 标题/时间地点/公开与上传状态 → 次级四入口 /// (Photos/Members/Categories/Share,≥44dp)→ Recent photos 三列预览 /// (≤9 张)+ See all;分类自页面 12 起提取为独立管理页(本页仅导航); /// 底部固定「拍摄上传(主)/从相册选择(次)」,避让安全区,上传关闭时 /// 说明原因并禁用。 /// /// 空相册(页面 08):保留真实封面与资料,中心安静占位「Ready for your /// first photo」;协作者显示只读说明;有处理中照片时显示「处理中」,不 /// 当绝对空白,无伪造计数与示例照片。 /// /// 数据范围:照片预览按 scope 自适应(all 403 → own)推导管理者身份—— /// 创建者/管理员见管理入口;协作者仅见被授权内容(照片入口为自己的 /// 上传)。编辑与分类变更后刷新本地详情并同步全局相册列表。 library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart'; import '../core/app_exception.dart'; import '../core/error_codes.dart'; import '../core/error_l10n.dart'; import '../features/albums/album_format.dart'; import '../features/albums/models.dart'; import '../features/albums/providers.dart'; import '../features/photos/models.dart'; import '../features/uploads/enqueue.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/photo_thumbnail.dart'; import '../ui/components/section_header.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; import 'album_categories_page.dart'; import 'album_form_page.dart'; import 'album_members_page.dart'; import 'album_photos_page.dart'; import 'capture_page.dart'; import 'share_qr_dialog.dart'; /// 详情页最近照片三列网格取数上限(方案 §4.2「最多 6-9 张」)。 const int _kRecentGridLimit = 9; /// 操作菜单(页面 07)的动作标识:面板 pop 返回,页面侧统一分发导航。 enum AlbumAction { edit, members, categories, share, delete } /// 相册详情([albumId] 路由参数;进入后 GET /albums/{id} + 照片预览)。 class AlbumDetailPage extends ConsumerStatefulWidget { const AlbumDetailPage({required this.albumId, super.key}); final String albumId; @override ConsumerState createState() => _AlbumDetailPageState(); } class _AlbumDetailPageState extends ConsumerState { Album? _album; bool _loading = true; String? _errorCode; /// 最近照片网格(协作者为「自己上传」,服务端过滤)。 List _recentPhotos = const []; bool _photosLoading = true; /// 管理者(owner/admin):scope=all 照片请求成功推导;403 → 协作者。 /// 预览请求非 403 失败时保守按管理者展示(服务端仍逐请求裁决)。 bool _isManager = true; @override void initState() { super.initState(); _load(); } Future _load() async { setState(() { _loading = true; _errorCode = null; }); try { final album = await ref.read(apiClientProvider).getAlbum(widget.albumId); if (!mounted) return; setState(() { _album = album; _loading = false; }); await _loadPhotoPreview(); } on AppException catch (e) { if (mounted) { setState(() { _errorCode = e.code; _loading = false; }); } } on Object { if (mounted) { setState(() { _errorCode = ErrorCodes.internalError; _loading = false; }); } } } /// 照片入口预览:先按 all(owner/admin);403 回退 own(协作者仅自己 /// 上传)。非 403 失败不阻塞详情页(入口退化为「查看全部」按钮)。 Future _loadPhotoPreview() async { final album = _album; if (album == null) return; final api = ref.read(apiClientProvider); try { final page = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.all, limit: _kRecentGridLimit, ); if (!mounted) return; setState(() { _recentPhotos = page.items; _photosLoading = false; _isManager = true; }); } on AppException catch (e) { if (!mounted) return; if (e.code != ErrorCodes.authForbidden) { setState(() { _recentPhotos = const []; _photosLoading = false; }); return; } try { final ownPage = await api.listAlbumPhotos( album.id, scope: AlbumPhotoScope.own, limit: _kRecentGridLimit, ); if (!mounted) return; setState(() { _recentPhotos = ownPage.items; _photosLoading = false; _isManager = false; }); } on AppException { if (!mounted) return; setState(() { _recentPhotos = const []; _photosLoading = false; _isManager = false; }); } } on Object { if (mounted) { setState(() { _recentPhotos = const []; _photosLoading = false; }); } } } // ── 导航入口(四入口与操作菜单共享,不另起一套实现) ─────────────── /// 编辑成功后:详情重载 + 全局列表同步。 Future _openEdit() async { final album = _album; if (album == null) return; final changed = await Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumFormPage(existing: album)), ); if (changed == true) { await _load(); ref.read(albumListProvider.notifier).refresh(); } } Future _openMembers() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumMembersPage(album: album)), ); // 成员变更不影响详情数据,回来无需刷新。 } Future _openPhotos() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute(builder: (_) => AlbumPhotosPage(album: album)), ); // 管理页内可能发生隐藏/删除,回来后刷新预览网格。 await _loadPhotoPreview(); } /// 分类管理(页面 12):进入独立分类页;返回后重载详情(分类随详情 /// 返回,供连拍目标等使用)。 Future _openCategories() async { final album = _album; if (album == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => AlbumCategoriesPage(album: album, isManager: _isManager), ), ); await _load(); } void _shareQr() { final album = _album; if (album == null) return; // UI-03 页面 19:分享从对话框升级为全屏分享页(真实封面 + 二维码)。 Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => ShareQrPage(album: album))); } /// 「⋯」操作菜单(页面 07):BottomSheet 按角色过滤动作;先关面板再 /// 导航(await pop 后统一分发),避免叠层路由错乱。 Future _showActionMenu() async { final action = await showModalBottomSheet( context: context, showDragHandle: true, builder: (_) => AlbumActionSheet(isManager: _isManager), ); if (!mounted || action == null) return; switch (action) { case AlbumAction.edit: await _openEdit(); case AlbumAction.members: await _openMembers(); case AlbumAction.categories: await _openCategories(); case AlbumAction.share: _shareQr(); case AlbumAction.delete: await _deleteAlbum(); } } /// 软删除相册(admin/owner;契约 deleteAlbum):二次确认 → DELETE → /// 成功提示并整栈退回相册列表(详情/编辑等路由一并出栈),列表刷新。 Future _deleteAlbum() async { final album = _album; if (album == null) return; final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l10n.deleteAlbumConfirmTitle), content: Text(l10n.deleteAlbumConfirmBody), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: Text(l10n.cancel), ), FilledButton( style: FilledButton.styleFrom( backgroundColor: theme.colorScheme.error, ), onPressed: () => Navigator.of(ctx).pop(true), child: Text(l10n.deleteAlbum), ), ], ), ); if (confirmed != true || !mounted) return; final messenger = ScaffoldMessenger.of(context); try { await ref.read(apiClientProvider).deleteAlbum(album.id); } on AppException catch (error) { // 删除失败:留在详情页,本地化错误码提示。 messenger.showSnackBar( SnackBar(content: Text(l10n.messageForError(error))), ); return; } on Object { messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); return; } if (!mounted) return; messenger.showSnackBar(SnackBar(content: Text(l10n.albumDeleted))); // 整栈退回列表根(编辑页等推栈路径一并清理),并刷新列表数据源。 Navigator.of(context).popUntil((route) => route.isFirst); ref.read(albumListProvider.notifier).refresh(); } // ── 上传入口(底部固定栏:拍摄上传主按钮 / 从相册选择次按钮) ───────── /// 当前登录账号(TaskStore 隔离键;详情页处于登录壳内,null 视为异常)。 Future _currentAccountId() async { final session = await ref.read(sessionStoreProvider).read(); return session?.account; } /// 拍照上传:进入连拍页(目标相册 + 分类选定一次)。 Future _openCapture() async { final album = _album; if (album == null) return; final account = await _currentAccountId(); if (!mounted) return; if (account == null) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => CapturePage( target: CaptureTarget( accountId: account, albumId: album.id, albumTitle: albumDisplayTitle( album, AppLocalizations.of(context).localeName, ), categories: album.categories, ), ), ), ); } /// 从相册选择:多选 → 逐张入队(默认分类)→ 入队数提示。 Future _pickFromGallery() async { final album = _album; if (album == null) return; // P0-8:相册上传开关预检——关闭时不入队(避免白做功后 403)。 if (!album.uploadEnabled) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).albumUploadDisabled), ), ); return; } final l10n = AppLocalizations.of(context); final account = await _currentAccountId(); if (account == null || !mounted) return; // 方案 A:accountId/albumId 传入选图器(点选拦截的判定范围)。 final paths = await ref.read(multiImagePickerProvider)( context, accountId: account, albumId: album.id, ); if (paths.isEmpty || !mounted) return; final messenger = ScaffoldMessenger.of(context); // P0-1 层 1+2(Task 2 统一入口):入队前查重——本机 TaskStore 历史 // ∪ 服务器历史(bytes 命中;服务器失败静默降级本机-only)。 final duplicates = await checkDuplicatesUnified( ref, accountId: account, albumId: album.id, sourcePaths: paths, ); Set skipPaths = {}; Set forcePaths = {}; if (duplicates != null && duplicates.isNotEmpty && mounted) { final choice = await _showDuplicateDialog(l10n, duplicates, paths); if (choice == null) return; // 取消。 skipPaths = choice.skipPaths; forcePaths = choice.forcePaths; } final result = await enqueueForUpload( ref, accountId: account, albumId: album.id, sourcePaths: paths, skipPaths: skipPaths.toList(), forcePaths: forcePaths.toList(), ); if (!mounted) return; messenger.showSnackBar( SnackBar( content: Text( result.skipped > 0 ? '${result.enqueued} 张已入队,${result.skipped} 张重复已跳过' : l10n.uploadEnqueuedCount(result.enqueued), ), ), ); } /// 批量重复确认弹窗(Task 8):null=取消;「跳过重复」→ skipPaths= /// 重复项;「全部上传」→ forcePaths=重复项(用户确认强传,服务端 /// force 查重跳过仅 owner/admin 生效——协作者仍会被 409 拒绝)。 Future _showDuplicateDialog( AppLocalizations l10n, List duplicates, List allPaths, ) async { // 重复项按完整源路径精确派生(同 basename 不同目录/扩展名的兄弟 // 文件不误伤,Task 2 评审 #2)。 final dupPaths = duplicates.map((d) => d.sourcePath).toSet(); final nonDupCount = allPaths.length - dupPaths.length; final choice = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('可能已上传过'), content: Text( '${duplicates.length} 张照片可能已上传到该相册' '${nonDupCount > 0 ? '(另 $nonDupCount 张不重复)' : ''}\n\n' '${duplicates.take(3).map((d) => d.filename).join('、')}' '${duplicates.length > 3 ? ' …' : ''}', ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop('cancel'), child: const Text('取消'), ), if (nonDupCount > 0) TextButton( onPressed: () => Navigator.of(ctx).pop('skip'), child: Text('跳过重复,上传 $nonDupCount 张'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop('all'), child: Text('全部上传 ${allPaths.length} 张'), ), ], ), ); if (choice == 'cancel' || choice == null) return null; if (choice == 'skip') { return (skipPaths: dupPaths, forcePaths: {}); } // 'all' = 用户确认强传:重复项 force 入队(不跳过任何)。 return (skipPaths: {}, forcePaths: dupPaths); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final album = _album; final title = album == null ? l10n.albumDetail : albumDisplayTitle(album, l10n.localeName); return Scaffold( appBar: AppBar( title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ IconButton( tooltip: l10n.moreActions, onPressed: _showActionMenu, icon: const Icon(Icons.more_vert), ), ], ), bottomNavigationBar: album == null ? null : _UploadBar( uploadEnabled: album.uploadEnabled, onCapture: _openCapture, onPick: _pickFromGallery, ), body: _loading ? const Center(child: CircularProgressIndicator()) : album == null ? _ErrorRetryView( message: l10n.messageForErrorCode(_errorCode!), onRetry: _load, ) : SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // 封面:全宽 16:9(无封面同尺寸中性占位)。 PhotoThumbnail( url: album.cover?.url, aspectRatio: 16 / 9, maxCacheWidth: 600, ), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace16, kSpace16, 0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( albumDisplayTitle(album, l10n.localeName), style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: kSpace4), Text( _metaLine(album, l10n), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: Theme.of( context, ).colorScheme.onSurfaceVariant, ), ), const SizedBox(height: kSpace12), _StatusSection(album: album), ], ), ), const SizedBox(height: kSpace16), // 次级四入口(Photos/Members/Categories/Share)。 Padding( padding: const EdgeInsets.symmetric(horizontal: kSpace16), child: _ActionTiles( isManager: _isManager, onPhotos: _openPhotos, onMembers: _openMembers, onCategories: _openCategories, onShare: _shareQr, ), ), const SizedBox(height: kSpace24), // Recent photos:三列网格(≤9)/ 空相册安静占位 / // 处理中指示。 Padding( padding: const EdgeInsets.symmetric(horizontal: kSpace16), child: _RecentPhotosSection( photos: _recentPhotos, loading: _photosLoading, isManager: _isManager, onOpenPhotos: _openPhotos, ), ), const SizedBox(height: kSpace32), ], ), ), ); } /// 单行时间/场地(相册时区墙钟 + 场地语言回退;结束未定显示「未定」)。 static String _metaLine(Album album, AppLocalizations l10n) { final venue = albumDisplayVenue(album, l10n.localeName); final start = formatAlbumDateTime( album.startAt, album.timezone, l10n.localeName, ); final end = album.endAt == null ? l10n.notSet : formatAlbumDateTime(album.endAt!, album.timezone, l10n.localeName); return venue == null ? '$start ~ $end (${album.timezone})' : '$start ~ $end (${album.timezone}) · $venue'; } } /// 操作菜单(UI-03 §4 页面 07):Edit album / Manage members / /// Categories / Share album + Cancel,按角色过滤(管理类动作仅 /// owner/admin;分享对全体成员开放)。点击 pop 对应 [AlbumAction], /// 由详情页在面板关闭后统一导航。 class AlbumActionSheet extends StatelessWidget { const AlbumActionSheet({required this.isManager, super.key}); /// 是否管理者(owner/admin):决定管理类动作是否显示。 final bool isManager; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); // 大字体/小屏下面板内容可滚动,不溢出。 return SafeArea( top: false, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isManager) ...[ ListTile( leading: const Icon(Icons.edit_outlined), title: Text(l10n.editAlbum), onTap: () => Navigator.of(context).pop(AlbumAction.edit), ), ListTile( leading: const Icon(Icons.group_outlined), title: Text(l10n.manageMembers), onTap: () => Navigator.of(context).pop(AlbumAction.members), ), ListTile( leading: const Icon(Icons.category_outlined), title: Text(l10n.actionCategories), onTap: () => Navigator.of(context).pop(AlbumAction.categories), ), ], ListTile( leading: const Icon(Icons.share_outlined), title: Text(l10n.shareAlbum), onTap: () => Navigator.of(context).pop(AlbumAction.share), ), if (isManager) ListTile( leading: Icon( Icons.delete_outline, color: theme.colorScheme.error, ), title: Text( l10n.deleteAlbum, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.error, ), ), onTap: () => Navigator.of(context).pop(AlbumAction.delete), ), const Divider(indent: kSpace16, endIndent: kSpace16), ListTile( title: Center( child: Text( l10n.cancel, style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), onTap: () => Navigator.of(context).pop(), ), ], ), ), ); } } /// 状态区:公开 / 上传分别显示(关闭不等于不公开)。 class _StatusSection extends StatelessWidget { const _StatusSection({required this.album}); final Album album; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Wrap( spacing: kSpace8, runSpacing: kSpace8, children: [ StateBadge( variant: album.publicEnabled ? BadgeVariant.info : BadgeVariant.neutral, label: album.publicEnabled ? l10n.tagPublic : l10n.albumPublicOff, ), StateBadge( variant: album.uploadEnabled ? BadgeVariant.success : BadgeVariant.neutral, label: album.uploadEnabled ? l10n.albumUploadsOn : l10n.albumUploadsOff, ), if (album.status == AlbumStatus.offline) StateBadge( variant: BadgeVariant.error, label: l10n.albumStatusOffline, ), ], ); } } /// 次级四入口(UI-03 §4 页面 06):Photos / Members / Categories / Share /// 等宽平铺,每格有效触控区 ≥44dp(实际高 72)。管理类入口(成员/分类) /// 仅管理者可见;Photos 与 Share 对全体成员开放。 class _ActionTiles extends StatelessWidget { const _ActionTiles({ required this.isManager, required this.onPhotos, required this.onMembers, required this.onCategories, required this.onShare, }); final bool isManager; final VoidCallback onPhotos; final VoidCallback onMembers; final VoidCallback onCategories; final VoidCallback onShare; @override Widget build(BuildContext context) { return Card( margin: EdgeInsets.zero, clipBehavior: Clip.antiAlias, child: Row( children: [ Expanded( child: _ActionTile( icon: Icons.photo_library_outlined, label: AppLocalizations.of(context).actionPhotos, onTap: onPhotos, ), ), if (isManager) ...[ const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.group_outlined, label: AppLocalizations.of(context).actionMembers, onTap: onMembers, ), ), const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.category_outlined, label: AppLocalizations.of(context).actionCategories, onTap: onCategories, ), ), ], const VerticalDivider(width: 1), Expanded( child: _ActionTile( icon: Icons.share_outlined, label: AppLocalizations.of(context).actionShare, onTap: onShare, ), ), ], ), ); } } /// 单个入口格:圆角容器图标 + 标签纵排(≥44dp 触控要求)。 class _ActionTile extends StatelessWidget { const _ActionTile({ required this.icon, required this.label, required this.onTap, }); final IconData icon; final String label; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); return InkWell( onTap: onTap, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 72), child: Padding( padding: const EdgeInsets.symmetric(vertical: kSpace12), child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(kRadiusInput), ), child: Icon(icon, size: 22, color: theme.colorScheme.primary), ), const SizedBox(height: kSpace8), Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, ), ), ], ), ), ), ); } } /// Recent photos(UI-03 §4 页面 06/08): /// - 加载中 → 占位高度居中转圈; /// - 有照片 → 三列网格(≤9 张,真实缩略图;URL 失败为中性占位), /// 其中存在服务端处理中的照片时在网格上方提示「Processing...」; /// - 无照片 → 安静空态(页面 08):图标 + 「Ready for your first photo」, /// 协作者显示只读说明;不显示伪造计数与示例照片。 class _RecentPhotosSection extends StatelessWidget { const _RecentPhotosSection({ required this.photos, required this.loading, required this.isManager, required this.onOpenPhotos, }); final List photos; final bool loading; final bool isManager; final VoidCallback onOpenPhotos; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SectionHeader( title: l10n.recentPhotos, trailing: TextButton( onPressed: onOpenPhotos, child: Text(l10n.seeAllPhotos), ), ), const SizedBox(height: kSpace8), if (loading) const SizedBox( height: 120, child: Center( child: SizedBox( height: 24, width: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ), ) else if (photos.isNotEmpty) ...[ if (_hasProcessing) Padding( padding: const EdgeInsets.only(bottom: kSpace8), child: Row( children: [ Icon( Icons.hourglass_top, size: 16, color: Theme.of(context).colorScheme.secondary, ), const SizedBox(width: kSpace8), Text( l10n.processingPhotos, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: EdgeInsets.zero, gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: kSpace8, crossAxisSpacing: kSpace8, ), itemCount: photos.length, itemBuilder: (context, index) => PhotoThumbnail( url: photos[index].preview?.thumb.url, aspectRatio: 1, maxCacheWidth: 400, ), ), ] else Padding( padding: const EdgeInsets.symmetric(vertical: kSpace24), child: Column( children: [ Icon( Icons.photo_camera_outlined, size: 48, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(height: kSpace12), Text( l10n.emptyAlbumTitle, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: kSpace4), Text( isManager ? l10n.emptyAlbumSubtitle : l10n.readonlyMember, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ), ], ); } /// 是否存在服务端仍在处理的照片(处理中提示;不当绝对空白)。 bool get _hasProcessing => photos.any((p) => p.processingState == PhotoProcessingState.processing); } /// 底部固定上传栏:拍摄上传(主)+ 从相册选择(次),避让安全区;上传 /// 关闭时给出原因说明并禁用两个入口。 class _UploadBar extends StatelessWidget { const _UploadBar({ required this.uploadEnabled, required this.onCapture, required this.onPick, }); final bool uploadEnabled; final VoidCallback onCapture; final VoidCallback onPick; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (!uploadEnabled) Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, 0, ), child: Row( children: [ Icon( Icons.info_outline, size: 16, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: kSpace4), Expanded( child: Text( l10n.albumUploadDisabledHint, style: theme.textTheme.bodySmall, ), ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB( kSpace16, kSpace8, kSpace16, kSpace12, ), child: Row( children: [ Expanded( child: FilledButton.icon( onPressed: uploadEnabled ? onCapture : null, icon: const Icon(Icons.photo_camera_outlined), label: Text(l10n.uploadActionCapture), ), ), const SizedBox(width: kSpace12), Expanded( child: OutlinedButton.icon( onPressed: uploadEnabled ? onPick : null, icon: const Icon(Icons.photo_library_outlined), label: Text(l10n.uploadActionPick), ), ), ], ), ), ], ), ); } } /// 首屏错误 + 重试。 class _ErrorRetryView extends StatelessWidget { const _ErrorRetryView({required this.message, required this.onRetry}); final String message; final Future Function() onRetry; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.cloud_off, size: 48), const SizedBox(height: 12), Text(message, textAlign: TextAlign.center), const SizedBox(height: 12), FilledButton.icon( onPressed: onRetry, icon: const Icon(Icons.refresh), label: Text(l10n.retry), ), ], ), ); } }