Files
photo-live/apps/mobile/lib/pages/queue_tab.dart
T
浅念 6074fc8ed3 feat(ui06): App line A0-A7 — nav pill removal, light segments, bare summary, token alignment to app-preview
- tokens:次文字 #69778E、soft/card-border/seg 色组、输入圆角 10(kRadiusField)
- theme:导航 indicatorColor 透明+显式选中蓝;卡片去阴影改 1px #EDF0F5 边框;输入边框同步
- 新组件 SegmentTabs(#EDF1F6 轨道/选中实蓝/light 浅蓝变体/null 无高亮)替换 M3 SegmentedButton
- 上传中心:摘要去白卡直排+主数字 32/40;completed 次级筛选无高亮语义保留
- 相册卡 scrim 30% 起 #0C1728E8;导航角标红底白字白边;登录主标题 27/700
- theme_test 三处旧规格断言随 UI06 值更新;397/397 全绿、analyze 0 issues
- 真机证据:MuMu + 测试服务器 API,登录/相册/详情/上传 4 页截图确认达标
- 登记偏差:语义色保持对比度合规深色版;视口矩阵随 G2;详见验收记录 §6

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 08:09:20 +08:00

907 lines
33 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 上传中心(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);
}
}
}