Files
photo-live/apps/mobile/lib/pages/queue_tab.dart
T

907 lines
33 KiB
Dart
Raw Normal View History

/// 上传中心(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<QueueStateFilter>(
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<void> _openFilterSheet(BuildContext context) {
return showModalBottomSheet<void>(
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<String>(
groupValue: albumValue,
onChanged: (albumId) {
ref
.read(queueFilterProvider.notifier)
.setAlbum(albumId == '' ? null : albumId);
close();
},
child: Column(
children: [
RadioListTile<String>(
value: '',
title: Text(l10n.queueAlbumFilterAll),
),
for (final album in albums)
RadioListTile<String>(
value: album.id,
title: Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const Divider(indent: kSpace16, endIndent: kSpace16),
// 时间快捷(今天为摘要默认口径,§4 页面 14)。
RadioGroup<QueueTimeFilter>(
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<QueueTimeFilter>(
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<UploadTaskProgress>(
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<void> _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<void> _confirmRemoveRecord(
BuildContext context,
WidgetRef ref,
String account,
) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
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<void> _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);
}
}
}