/// 上传中心(UI-03 页面 14/15:上传中心 + 异常任务视图)。 /// /// 页头「Uploads」(24sp)+ 右上「History」入口(→ 服务端记录页,与 /// 本机队列数据源区分)。摘要卡(范围标注「相册·时间·本设备」+ 四组 /// 任务数:已完成(放大突出)/上传中/等待中/需关注——按任务数统计, /// 单任务百分比才走字节进度;**不随状态筛选变口径**,异常视图下摘要 /// 仍显示全量计数)。主筛选 = 大号三段(All / Active / Needs attention); /// 次级筛选(相册 × 时间 × 已完成)收进筛选图标菜单(BottomSheet)。 /// 任务行 = 缩略图 + 文件名 + 相册 + 状态徽章(StateBadge 图标+文案+ /// 颜色三重表达)+ 状态专属文案/操作(§4.4 表格:确认中不显示已公开、 /// 准备失败走重新选择而非普通重试、终态走「移除记录」并明确不删云端 /// 照片)+ 传输进度(进度总线行级订阅,不整页刷新)。存在可重试失败时 /// 底部「重试可恢复项」条(仅作用于可重试任务,文案说明范围)。 /// /// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。 library; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../features/albums/album_format.dart'; import '../features/albums/providers.dart'; import '../features/uploads/engine_scope.dart'; import '../features/uploads/enqueue.dart'; import '../features/uploads/providers.dart'; import '../features/uploads/queue_controller.dart'; import '../features/uploads/queue_l10n.dart'; import '../features/uploads/task_state.dart'; import '../features/uploads/task_store.dart'; import '../ui/components/segment_tabs.dart'; import '../l10n/app_localizations.dart'; import '../ui/components/empty_state.dart'; import '../ui/components/error_state.dart'; import '../ui/components/state_badge.dart'; import '../ui/tokens.dart'; /// 队列 Tab(主壳 IndexedStack 内常驻)。 class QueueTab extends ConsumerWidget { const QueueTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final counts = ref.watch(taskCountsProvider); // 批量重试仅针对可重试失败(等待网络自动恢复,不混入;准备失败需 // 重新选图,不能普通重试冒充恢复——§4.4)。 final retryable = counts[UploadTaskState.retryableFailed] ?? 0; return Scaffold( appBar: AppBar( // 24sp 主标题(theme titleLarge)+ 右上「History」链接(§4 页面14)。 title: Text(l10n.queue), actions: [ Padding( padding: const EdgeInsets.only(right: kSpace8), child: TextButton.icon( onPressed: () => Navigator.of(context).pushNamed('/server-history'), icon: const Icon(Icons.history_outlined, size: 18), label: Text(l10n.uploadsHistory), ), ), ], ), body: const Column( children: [ _SummaryCard(), _PrimaryFilterBar(), _NeedsAttentionNote(), Expanded(child: _TaskListView()), ], ), bottomNavigationBar: retryable > 0 ? const _BatchRetryBar() : null, ); } } // ── 摘要卡 ──────────────────────────────────────────────────────────────── /// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径;**不受 /// 状态筛选影响**——页面 15 异常视图下仍显示全量计数,配说明行)。 class _SummaryCard extends ConsumerWidget { const _SummaryCard(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final filter = ref.watch(queueFilterProvider); final albums = ref.watch(albumListProvider).items; final counts = ref.watch(queueSummaryProvider); // 范围标注:[相册名·]时间范围·本设备(本机任务库只含本设备任务, // 跨设备记录在服务端历史页——两者不合并统计)。 final album = filter.albumId == null ? null : albums.where((a) => a.id == filter.albumId).firstOrNull; final scope = [ if (album != null) albumDisplayTitle(album, l10n.localeName), _queueTimeLabel(l10n, filter.time), l10n.uploadsThisDevice, ].join(' · '); final light = theme.brightness == Brightness.light; // UI06:摘要直接排页背景(无白卡包裹);范围行 13px、主数字 32px、 // 副行 13px 中性(app-preview 示例 14)。 return Padding( padding: const EdgeInsets.fromLTRB(kSpace16, kSpace16, kSpace16, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( scope, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall, ), const SizedBox(height: kSpace8), // 已完成突出(§4 页面 14:摘要突出已完成数)。 _SummaryCount( value: completedSummaryOf(counts), label: l10n.uploadsCompleted, color: light ? kColorSuccess : kColorSuccessDark, emphasized: true, ), const SizedBox(height: kSpace4), Wrap( spacing: kSpace16, runSpacing: kSpace4, children: [ _SummaryCount( value: uploadingSummaryOf(counts), label: l10n.uploadsUploading, color: theme.colorScheme.primary, ), _SummaryCount( value: waitingSummaryOf(counts), label: l10n.uploadsWaiting, color: light ? kColorWaiting : kColorWaitingDark, ), _SummaryCount( value: failedCountOf(counts), label: l10n.queueSummaryNeedAttention, color: light ? kColorError : kColorErrorDark, ), ], ), ], ), ); } } /// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色);[emphasized] /// 档用于突出的「已完成」(titleLarge)。 class _SummaryCount extends StatelessWidget { const _SummaryCount({ required this.value, required this.label, required this.color, this.emphasized = false, }); final int value; final String label; final Color color; final bool emphasized; @override Widget build(BuildContext context) { final theme = Theme.of(context); return Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( '$value', style: (emphasized // UI06:主数字 32/40(app-preview 示例 14 .metric) ? theme.textTheme.titleLarge?.copyWith( fontSize: 32, height: 40 / 32, fontWeight: FontWeight.w700, letterSpacing: -0.6, ) : theme.textTheme.titleMedium) ?.copyWith( fontFeatures: const [FontFeature.tabularFigures()], ), ), const SizedBox(width: kSpace4), Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)), ], ); } } // ── 筛选栏 ──────────────────────────────────────────────────────────────── /// 时间快捷标签(「今天」与摘要口径共用一词,§4 页面 14 摘要「Today」)。 String _queueTimeLabel(AppLocalizations l10n, QueueTimeFilter time) => switch (time) { QueueTimeFilter.all => l10n.queueTimeAll, QueueTimeFilter.today => l10n.uploadsToday, QueueTimeFilter.last7Days => l10n.queueTimeLast7Days, QueueTimeFilter.last30Days => l10n.queueTimeLast30Days, }; /// 主筛选(§4 页面 14):大号三段 All / Active / Needs attention;次级 /// 筛选(相册 × 时间 × 已完成)收进筛选图标的 BottomSheet。 class _PrimaryFilterBar extends ConsumerWidget { const _PrimaryFilterBar(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final filter = ref.watch(queueFilterProvider); return Material( color: theme.colorScheme.surfaceContainerLow, child: Padding( padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace8, kSpace8), child: Row( children: [ Expanded( // UI06:轻量分段(app-preview .tabs——#EDF1F6 轨道/选中实蓝, // 替换 M3 SegmentedButton 黑边胶囊)。 // 已完成经筛选菜单选择时三段不亮(属次级筛选,非主段)。 child: SegmentTabs( value: filter.state == QueueStateFilter.completed ? null : filter.state, segments: [ (QueueStateFilter.all, l10n.uploadsAll), (QueueStateFilter.inProgress, l10n.uploadsActive), (QueueStateFilter.needsAttention, l10n.uploadsNeedsAttention), ], onChanged: (value) => ref.read(queueFilterProvider.notifier).setStateGroup(value), ), ), IconButton( tooltip: l10n.uploadsFilters, icon: const Icon(Icons.tune), onPressed: () => _openFilterSheet(context), ), ], ), ), ); } Future _openFilterSheet(BuildContext context) { return showModalBottomSheet( context: context, showDragHandle: true, builder: (_) => const _FilterSheet(), ); } } /// 次级筛选面板(相册 × 时间快捷 × 已完成):单选即选即生效并收起。 class _FilterSheet extends ConsumerWidget { const _FilterSheet(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final filter = ref.watch(queueFilterProvider); final albums = ref.watch(albumListProvider).items; void close() => Navigator.of(context).pop(); // 相册组值:'' = 全部相册(选中相册不在列表时回退全部)。 final albumValue = albums.any((a) => a.id == filter.albumId) ? (filter.albumId ?? '') : ''; return SafeArea( child: ListView( shrinkWrap: true, padding: const EdgeInsets.only(bottom: kSpace16), children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: kSpace16), child: Text( l10n.uploadsFilters, style: theme.textTheme.titleMedium, ), ), // 相册(单选即选即生效并收起)。 RadioGroup( groupValue: albumValue, onChanged: (albumId) { ref .read(queueFilterProvider.notifier) .setAlbum(albumId == '' ? null : albumId); close(); }, child: Column( children: [ RadioListTile( value: '', title: Text(l10n.queueAlbumFilterAll), ), for (final album in albums) RadioListTile( value: album.id, title: Text( albumDisplayTitle(album, l10n.localeName), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), const Divider(indent: kSpace16, endIndent: kSpace16), // 时间快捷(今天为摘要默认口径,§4 页面 14)。 RadioGroup( groupValue: filter.time, onChanged: (time) { if (time != null) { ref.read(queueFilterProvider.notifier).setTime(time); } close(); }, child: Column( children: [ for (final time in QueueTimeFilter.values) RadioListTile( value: time, title: Text(_queueTimeLabel(l10n, time)), ), ], ), ), const Divider(indent: kSpace16, endIndent: kSpace16), // 已完成(主三段之外的状态组,收进筛选菜单——§4 页面 14)。 ListTile( leading: Icon( filter.state == QueueStateFilter.completed ? Icons.check_circle : Icons.check_circle_outline, color: filter.state == QueueStateFilter.completed ? theme.colorScheme.primary : theme.colorScheme.onSurfaceVariant, ), title: Text(l10n.queueStateCompleted), onTap: () { ref .read(queueFilterProvider.notifier) .setStateGroup( filter.state == QueueStateFilter.completed ? QueueStateFilter.all : QueueStateFilter.completed, ); close(); }, ), ], ), ); } } /// 异常视图说明行(页面 15):Needs attention 主段选中时常驻,说明列表 /// 已过滤而摘要仍是全量口径。 class _NeedsAttentionNote extends ConsumerWidget { const _NeedsAttentionNote(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final state = ref.watch( queueFilterProvider.select((filter) => filter.state), ); if (state != QueueStateFilter.needsAttention) { return const SizedBox.shrink(); } return Padding( padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, 0), child: Row( children: [ Icon( Icons.filter_alt_outlined, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: kSpace8), Expanded( child: Text( l10n.uploadsShowingNeedsAttention, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), ); } } // ── 任务列表 ────────────────────────────────────────────────────────────── class _TaskListView extends ConsumerWidget { const _TaskListView(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final stateFilter = ref.watch( queueFilterProvider.select((filter) => filter.state), ); final tasksAsync = ref.watch(queueTasksProvider); return tasksAsync.when( skipLoadingOnReload: true, loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => ErrorState( message: l10n.errorGeneric, onRetry: () => ref.invalidate(queueTasksProvider), ), data: (tasks) { if (tasks.isEmpty) { // 页面 15:异常视图空态专用文案(区别于整体队列空态)。 return EmptyState( icon: stateFilter == QueueStateFilter.needsAttention ? Icons.task_alt_outlined : Icons.cloud_upload_outlined, title: stateFilter == QueueStateFilter.needsAttention ? l10n.uploadsNoNeedsAttention : l10n.queueEmpty, ); } return ListView.builder( padding: const EdgeInsets.symmetric(vertical: 4), itemCount: tasks.length, itemBuilder: (context, index) => _TaskTile(task: tasks[index]), ); }, ); } } /// 单条任务行(§4 页面 14 状态表逐态落地)。 class _TaskTile extends ConsumerWidget { const _TaskTile({required this.task}); final UploadTask task; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final album = ref .watch(albumListProvider) .items .where((a) => a.id == task.albumId) .firstOrNull; final albumName = album != null ? albumDisplayTitle(album, l10n.localeName) : task.albumId; final failedLike = task.state == UploadTaskState.preparationFailed || task.state == UploadTaskState.retryableFailed; return Padding( // 行键(task.id)供 widget 测试行级锚定(状态徽章/动作图标定位)。 key: ValueKey('queue-task-${task.id}'), padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace16, kSpace8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _Thumb(path: task.cleanedAt == null ? task.sourceFilePath : null), const SizedBox(width: kSpace12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( task.filename, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 2), Text( albumName, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: kSpace4), Row( children: [ // 状态徽章即 §4.4「界面含义」列的唯一文案表达(图标 + // 颜色 + 文字三重编码);尝试次数(>0)为补充说明。 StateBadge( compact: true, variant: _badgeVariant(task.state), label: _badgeLabel(l10n, task.state), ), if (task.attempt > 0) ...[ const SizedBox(width: kSpace8), Text( l10n.queueAttemptCount(task.attempt), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ], ), if (task.state == UploadTaskState.waitingNetwork) ...[ const SizedBox(height: 2), Text( l10n.queueWaitingNetworkHint, style: theme.textTheme.bodySmall?.copyWith( color: theme.brightness == Brightness.light ? kColorWaiting : kColorWaitingDark, ), ), ], if (failedLike && task.lastError != null) ...[ const SizedBox(height: 2), Text( l10n.messageForTaskError(task.lastError!), maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ], if (task.state == UploadTaskState.uploading || task.state == UploadTaskState.uploadedConfirming) _ProgressLine( taskId: task.id, confirming: task.state == UploadTaskState.uploadedConfirming, ), ], ), ), _TaskActions(task: task), ], ), ); } /// 徽章语义变体(§4 页面 14:进行=主色、等待=琥珀、失败=红、完成=绿、 /// 取消=灰)。 static BadgeVariant _badgeVariant(UploadTaskState state) => switch (state) { UploadTaskState.preparing || UploadTaskState.uploading || UploadTaskState.uploadedConfirming => BadgeVariant.info, UploadTaskState.queued || UploadTaskState.waitingNetwork => BadgeVariant.warning, UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => BadgeVariant.error, UploadTaskState.completed => BadgeVariant.success, UploadTaskState.cancelled => BadgeVariant.neutral, }; /// 徽章文案 = §4.4 界面含义列(确认中不显示「已公开」等超集语义)。 static String _badgeLabel(AppLocalizations l10n, UploadTaskState state) => switch (state) { UploadTaskState.preparing => l10n.queueRowPreparing, UploadTaskState.preparationFailed => l10n.uploadsFileUnavailable, UploadTaskState.queued => l10n.uploadsWaitingUpload, UploadTaskState.uploading => l10n.taskStateUploading, UploadTaskState.uploadedConfirming => l10n.uploadsConfirming, UploadTaskState.completed => l10n.taskStateCompleted, UploadTaskState.waitingNetwork => l10n.uploadsWaitingNetwork, UploadTaskState.retryableFailed => l10n.taskStateRetryableFailed, UploadTaskState.cancelled => l10n.taskStateCancelled, }; } /// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。 class _Thumb extends StatelessWidget { const _Thumb({required this.path}); final String? path; @override Widget build(BuildContext context) { final theme = Theme.of(context); Widget placeholder() => Container( width: 48, height: 48, decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(kRadiusBadge), ), child: Icon( Icons.image_outlined, size: 24, color: theme.colorScheme.onSurfaceVariant, ), ); if (path == null) return placeholder(); return ClipRRect( borderRadius: BorderRadius.circular(kRadiusBadge), child: SizedBox( width: 48, height: 48, child: Image.file( File(path!), fit: BoxFit.cover, errorBuilder: (_, _, _) => placeholder(), ), ), ); } } /// 传输进度行:上传中 = 进度总线行级订阅(分数进度条 + 百分比文案); /// 确认中 = 不确定进度条(等待服务端核验,不伪造百分比)。 class _ProgressLine extends ConsumerWidget { const _ProgressLine({required this.taskId, required this.confirming}); final String taskId; final bool confirming; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.only(top: 6, right: 8), child: StreamBuilder( stream: ref .watch(uploadProgressHubProvider) .stream .where((p) => p.taskId == taskId), builder: (context, snapshot) { final fraction = snapshot.data?.fraction; return Row( children: [ Expanded( child: LinearProgressIndicator( value: confirming ? null : fraction, minHeight: 4, ), ), if (!confirming && fraction != null) ...[ const SizedBox(width: kSpace8), Text( l10n.queueRowUploadingPercent((fraction * 100).round()), style: Theme.of(context).textTheme.labelSmall, ), ], ], ); }, ), ); } } /// 行级动作(按状态;未登录账号缺席时禁用)。 class _TaskActions extends ConsumerWidget { const _TaskActions({required this.task}); final UploadTask task; @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final account = ref.watch(sessionAccountProvider).value; switch (task.state) { // 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)—— // 不能普通重试冒充恢复(§4.4)。 case UploadTaskState.preparationFailed: return _RowAction( icon: Icons.image_search_outlined, label: l10n.uploadsSelectAgain, onPressed: account == null ? null : () => _retryPreparation(context, ref, account), ); case UploadTaskState.retryableFailed: return _RowAction( icon: Icons.refresh, label: l10n.retry, onPressed: account == null ? null : () => ref.read(uploadEngineProvider).retryNow(account, task.id), ); // 网络等待自动恢复,保留手动重试入口(§4.4「保留已有重试」)。 case UploadTaskState.waitingNetwork: return IconButton( tooltip: l10n.retry, icon: const Icon(Icons.refresh), onPressed: account == null ? null : () => ref.read(uploadEngineProvider).retryNow(account, task.id), ); case UploadTaskState.queued: case UploadTaskState.preparing: return IconButton( tooltip: l10n.cancel, icon: const Icon(Icons.close), onPressed: account == null ? null : () => ref.read(uploadEngineProvider).cancel(account, task.id), ); // 终态:移除本机记录(不删云端照片,确认弹窗明确区分)。 case UploadTaskState.completed: case UploadTaskState.cancelled: return _RowAction( icon: Icons.delete_outline, label: l10n.uploadsRemoveRecord, foreground: Theme.of(context).colorScheme.onSurfaceVariant, onPressed: account == null ? null : () => _confirmRemoveRecord(context, ref, account), ); case UploadTaskState.uploading: case UploadTaskState.uploadedConfirming: return const SizedBox(width: 48); } } /// 准备失败重试:重新选图 → preparer.retry(换源重新复制入队)。 Future _retryPreparation( BuildContext context, WidgetRef ref, String account, ) async { final l10n = AppLocalizations.of(context); final sourcePath = await ref.read(singleImagePickerProvider)(); if (sourcePath == null || !context.mounted) return; final messenger = ScaffoldMessenger.of(context); try { await ref .read(uploadPreparerProvider) .retry(accountId: account, taskId: task.id, sourcePath: sourcePath); } on Object { messenger.showSnackBar(SnackBar(content: Text(l10n.errorGeneric))); } } /// 移除记录确认:明确仅清本机记录、不删 OSS 照片(§4.4)。 Future _confirmRemoveRecord( BuildContext context, WidgetRef ref, String account, ) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text(l10n.queueRemoveRecordTitle), content: Text(l10n.queueRemoveRecordBody), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: Text(l10n.cancel), ), FilledButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: Text(l10n.uploadsRemoveRecordConfirm), ), ], ), ); if (confirmed != true || !context.mounted) return; // 删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。 final store = ref.read(taskStoreProvider); await store.delete(task.id); if (task.cleanedAt == null) { try { final file = File(task.sourceFilePath); if (await file.exists()) { await file.delete(); } } on Object { // 文件删除失败接受(无行可追责,残留由用户重装/系统清理回收)。 } } } } /// 行内文字动作(重新选择/重试/移除记录):紧凑档,弱化为次级视觉。 class _RowAction extends StatelessWidget { const _RowAction({ required this.icon, required this.label, required this.onPressed, this.foreground, }); final IconData icon; final String label; final VoidCallback? onPressed; final Color? foreground; @override Widget build(BuildContext context) { return TextButton.icon( onPressed: onPressed, style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: kSpace8), minimumSize: const Size(48, 36), foregroundColor: foreground ?? Theme.of(context).colorScheme.onSurfaceVariant, ), icon: Icon(icon, size: 16), label: Text(label), ); } } /// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed, /// 按钮文案「重试可恢复项」明确范围——§4 页面 15)。 class _BatchRetryBar extends ConsumerWidget { const _BatchRetryBar(); @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); final theme = Theme.of(context); final counts = ref.watch(taskCountsProvider); // 标题计数与摘要卡「需关注」同口径(准备失败 + 上传失败);按钮仅 // 重试其中的可重试子集,范围文案明确区分(出现条件见 QueueTab)。 final needsAttention = failedCountOf(counts); return Material( color: theme.colorScheme.errorContainer, child: SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ Icon( Icons.error_outline, color: theme.colorScheme.onErrorContainer, ), const SizedBox(width: 8), Expanded( child: Column( // Scaffold 对 bottomNavigationBar 给的是宽松高度约束, // 默认 max 会把整条栏撑到剩余全高、把 body 压成 0;min 使 // 栏高 = 两行文字 + 内边距。 mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${l10n.queueStateNeedsAttention} · $needsAttention', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onErrorContainer, ), ), Text( l10n.uploadsRetryEligibleScope, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onErrorContainer, ), ), ], ), ), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: theme.colorScheme.error, foregroundColor: theme.colorScheme.onError, ), icon: const Icon(Icons.refresh, size: 18), label: Text(l10n.uploadsRetryEligible), onPressed: () => _retryAll(ref), ), ], ), ), ), ); } /// 全部可重试失败任务(retryableFailed,跨当前筛选)立即重新入队。 /// 等待网络任务自动恢复、准备失败需换源重选,均不进入批量重试。 Future _retryAll(WidgetRef ref) async { final account = ref.read(sessionAccountProvider).value; if (account == null) return; final engine = ref.read(uploadEngineProvider); final tasks = await ref .read(taskStoreProvider) .listByAccount( account, states: const {UploadTaskState.retryableFailed}, ); for (final task in tasks) { await engine.retryNow(account, task.id); } } }