1f67b7c818
Co-Authored-By: Claude Code <noreply@anthropic.com>
812 lines
29 KiB
Dart
812 lines
29 KiB
Dart
/// 上传中心(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
|
||
///
|
||
/// 顶部摘要卡(筛选范围标注「相册·时间·本设备」+ 四组任务数:已完成/
|
||
/// 上传中/等待中/需关注——按任务数统计,单任务百分比才走字节进度)→
|
||
/// 筛选栏(相册下拉 × 时间快捷 × 状态分组 chips:全部/进行中/需关注/
|
||
/// 已完成)→ 任务行 = 缩略图 + 文件名 + 状态徽章(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/models.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 '../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(
|
||
title: Text(l10n.queue),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: l10n.serverHistoryEntry,
|
||
icon: const Icon(Icons.history_outlined),
|
||
onPressed: () => Navigator.of(context).pushNamed('/server-history'),
|
||
),
|
||
],
|
||
),
|
||
body: const Column(
|
||
children: [
|
||
_SummaryCard(),
|
||
_FilterBar(),
|
||
Expanded(child: _TaskListView()),
|
||
],
|
||
),
|
||
bottomNavigationBar: retryable > 0 ? const _BatchRetryBar() : null,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 摘要卡 ────────────────────────────────────────────────────────────────
|
||
|
||
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径)。
|
||
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),
|
||
_FilterBar._timeLabel(l10n, filter.time),
|
||
l10n.thisDevice,
|
||
].join(' · ');
|
||
|
||
final light = theme.brightness == Brightness.light;
|
||
return Card(
|
||
margin: const EdgeInsets.fromLTRB(kSpace16, kSpace12, kSpace16, 0),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(kSpace16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.summarize_outlined,
|
||
size: 16,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: kSpace8),
|
||
Expanded(
|
||
child: Text(
|
||
scope,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: theme.textTheme.bodySmall,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: kSpace8),
|
||
Wrap(
|
||
spacing: kSpace16,
|
||
runSpacing: kSpace4,
|
||
children: [
|
||
_SummaryCount(
|
||
value: completedSummaryOf(counts),
|
||
label: l10n.queueSummaryCompleted,
|
||
color: light ? kColorSuccess : kColorSuccessDark,
|
||
),
|
||
_SummaryCount(
|
||
value: uploadingSummaryOf(counts),
|
||
label: l10n.queueSummaryUploading,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
_SummaryCount(
|
||
value: waitingSummaryOf(counts),
|
||
label: l10n.queueSummaryWaiting,
|
||
color: light ? kColorWaiting : kColorWaitingDark,
|
||
),
|
||
_SummaryCount(
|
||
value: failedCountOf(counts),
|
||
label: l10n.queueSummaryNeedAttention,
|
||
color: light ? kColorError : kColorErrorDark,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色)。
|
||
class _SummaryCount extends StatelessWidget {
|
||
const _SummaryCount({
|
||
required this.value,
|
||
required this.label,
|
||
required this.color,
|
||
});
|
||
|
||
final int value;
|
||
final String label;
|
||
final Color color;
|
||
|
||
@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: theme.textTheme.titleMedium?.copyWith(
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
const SizedBox(width: kSpace4),
|
||
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 筛选栏 ────────────────────────────────────────────────────────────────
|
||
|
||
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(§4.4 四组)。
|
||
class _FilterBar extends ConsumerWidget {
|
||
const _FilterBar();
|
||
|
||
@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;
|
||
|
||
return Material(
|
||
color: theme.colorScheme.surfaceContainerLow,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.filter_list,
|
||
size: 18,
|
||
color: theme.colorScheme.onSurfaceVariant,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _AlbumDropdown(filter: filter, albums: albums),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 4),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
for (final (i, time) in QueueTimeFilter.values.indexed) ...[
|
||
if (i > 0) const SizedBox(width: 8),
|
||
ChoiceChip(
|
||
label: Text(_timeLabel(l10n, time)),
|
||
selected: filter.time == time,
|
||
onSelected: (_) =>
|
||
ref.read(queueFilterProvider.notifier).setTime(time),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
SingleChildScrollView(
|
||
scrollDirection: Axis.horizontal,
|
||
child: Row(
|
||
children: [
|
||
for (final (i, state) in QueueStateFilter.values.indexed) ...[
|
||
if (i > 0) const SizedBox(width: 8),
|
||
ChoiceChip(
|
||
label: Text(_stateLabel(l10n, state)),
|
||
selected: filter.state == state,
|
||
onSelected: (_) => ref
|
||
.read(queueFilterProvider.notifier)
|
||
.setStateGroup(state),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static String _stateLabel(AppLocalizations l10n, QueueStateFilter state) =>
|
||
switch (state) {
|
||
QueueStateFilter.all => l10n.queueStateAll,
|
||
QueueStateFilter.inProgress => l10n.queueStateInProgress,
|
||
QueueStateFilter.needsAttention => l10n.queueStateNeedsAttention,
|
||
QueueStateFilter.completed => l10n.queueStateCompleted,
|
||
};
|
||
|
||
static String _timeLabel(AppLocalizations l10n, QueueTimeFilter time) =>
|
||
switch (time) {
|
||
QueueTimeFilter.all => l10n.queueTimeAll,
|
||
QueueTimeFilter.today => l10n.queueTimeToday,
|
||
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
|
||
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
|
||
};
|
||
}
|
||
|
||
/// 相册下拉(全部相册 + 相册列表缓存;选中相册不在列表时退回全部)。
|
||
class _AlbumDropdown extends ConsumerWidget {
|
||
const _AlbumDropdown({required this.filter, required this.albums});
|
||
|
||
final QueueFilter filter;
|
||
final List<AlbumSummary> albums;
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context);
|
||
final knownIds = albums.map((a) => a.id).toSet();
|
||
final value = filter.albumId != null && knownIds.contains(filter.albumId)
|
||
? filter.albumId
|
||
: null;
|
||
return DropdownButton<String?>(
|
||
value: value,
|
||
isExpanded: true,
|
||
underline: const SizedBox.shrink(),
|
||
hint: Text(l10n.queueAlbumFilterAll),
|
||
items: [
|
||
DropdownMenuItem<String?>(
|
||
value: null,
|
||
child: Text(
|
||
l10n.queueAlbumFilterAll,
|
||
style: Theme.of(context).textTheme.bodyMedium,
|
||
),
|
||
),
|
||
for (final album in albums)
|
||
DropdownMenuItem<String?>(
|
||
value: album.id,
|
||
child: Text(
|
||
albumDisplayTitle(album, l10n.localeName),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
onChanged: (albumId) =>
|
||
ref.read(queueFilterProvider.notifier).setAlbum(albumId),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── 任务列表 ──────────────────────────────────────────────────────────────
|
||
|
||
class _TaskListView extends ConsumerWidget {
|
||
const _TaskListView();
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context);
|
||
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) {
|
||
return EmptyState(
|
||
icon: Icons.cloud_upload_outlined,
|
||
title: l10n.queueEmpty,
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
itemCount: tasks.length,
|
||
itemBuilder: (context, index) => _TaskTile(task: tasks[index]),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 单条任务行(§4.4 状态表逐态落地)。
|
||
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.4:进行=主色、等待=琥珀、失败=红、完成=绿、取消=灰)。
|
||
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.queueRowFileUnavailable,
|
||
UploadTaskState.queued => l10n.queueRowWaitingUpload,
|
||
UploadTaskState.uploading => l10n.taskStateUploading,
|
||
UploadTaskState.uploadedConfirming => l10n.queueRowConfirming,
|
||
UploadTaskState.completed => l10n.taskStateCompleted,
|
||
UploadTaskState.waitingNetwork => l10n.taskStateWaitingNetwork,
|
||
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.queueActionSelectAgain,
|
||
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.queueActionRemoveRecord,
|
||
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.queueRemoveRecordConfirm),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
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)。
|
||
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.queueRetryAllScope,
|
||
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.queueRetryAllFailed),
|
||
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);
|
||
}
|
||
}
|
||
}
|