c94b9e5e58
Co-Authored-By: Claude Code <noreply@anthropic.com>
587 lines
20 KiB
Dart
587 lines
20 KiB
Dart
/// 上传队列页(P3 计划 Task 9,需求 4.3 / R17 / O01)。
|
|
///
|
|
/// 三维筛选(相册下拉 × 状态 chips × 时间快捷)→ [QueueFilter];任务行
|
|
/// = 缩略图(受管副本,缺失/已清理显示占位)+ 文件名 + 相册名 + 状态
|
|
/// 徽章 + 尝试/错误(本地化错误码)+ 传输进度(进度总线行级订阅)+ 按
|
|
/// 状态动作(重试/取消/删除);失败任务存在时底部「一键重试全部失败」。
|
|
/// 列表经 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';
|
|
|
|
/// 队列 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);
|
|
final autoRetryable =
|
|
(counts[UploadTaskState.retryableFailed] ?? 0) +
|
|
(counts[UploadTaskState.waitingNetwork] ?? 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: [
|
|
_FilterBar(),
|
|
Expanded(child: _TaskListView()),
|
|
],
|
|
),
|
|
bottomNavigationBar: autoRetryable > 0 ? const _BatchRetryBar() : null,
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 筛选栏 ────────────────────────────────────────────────────────────────
|
|
|
|
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(需求 4.3 三维)。
|
|
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.preparing => l10n.queueStatePreparing,
|
|
QueueStateFilter.queued => l10n.queueStateQueued,
|
|
QueueStateFilter.uploading => l10n.queueStateUploading,
|
|
QueueStateFilter.waitingNetwork => l10n.queueStateWaitingNetwork,
|
|
QueueStateFilter.failed => l10n.queueStateFailed,
|
|
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, _) => Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(l10n.errorGeneric),
|
|
const SizedBox(height: 8),
|
|
FilledButton.tonal(
|
|
onPressed: () => ref.invalidate(queueTasksProvider),
|
|
child: Text(l10n.retry),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
data: (tasks) {
|
|
if (tasks.isEmpty) {
|
|
return Center(child: Text(l10n.queueEmpty));
|
|
}
|
|
return ListView.builder(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
itemCount: tasks.length,
|
|
itemBuilder: (context, index) => _TaskTile(task: tasks[index]),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 单条任务行。
|
|
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 ||
|
|
task.state == UploadTaskState.waitingNetwork;
|
|
|
|
return ListTile(
|
|
leading: _Thumb(
|
|
path: task.cleanedAt == null ? task.sourceFilePath : null,
|
|
),
|
|
title: Text(task.filename, maxLines: 1, overflow: TextOverflow.ellipsis),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
_StateChip(state: task.state),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
albumName,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (failedLike && task.lastError != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
task.attempt > 0
|
|
? '${l10n.messageForTaskError(task.lastError!)} · '
|
|
'${l10n.queueAttemptCount(task.attempt)}'
|
|
: 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,
|
|
),
|
|
],
|
|
),
|
|
trailing: _TaskActions(task: task),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 缩略图:受管副本存在则显示,缺失/已清理/解码失败显示占位图标。
|
|
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(6),
|
|
),
|
|
child: Icon(
|
|
Icons.image_outlined,
|
|
size: 24,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
);
|
|
if (path == null) return placeholder();
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(6),
|
|
child: SizedBox(
|
|
width: 48,
|
|
height: 48,
|
|
child: Image.file(
|
|
File(path!),
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, _, _) => placeholder(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 状态徽章(分组配色:失败红/完成绿/进行中主色/等待橙/其余中性)。
|
|
class _StateChip extends StatelessWidget {
|
|
const _StateChip({required this.state});
|
|
|
|
final UploadTaskState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final theme = Theme.of(context);
|
|
final (color, onColor) = switch (state) {
|
|
UploadTaskState.preparing => (
|
|
theme.colorScheme.surfaceContainerHighest,
|
|
theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
UploadTaskState.preparationFailed || UploadTaskState.retryableFailed => (
|
|
theme.colorScheme.errorContainer,
|
|
theme.colorScheme.onErrorContainer,
|
|
),
|
|
UploadTaskState.queued => (
|
|
theme.colorScheme.secondaryContainer,
|
|
theme.colorScheme.onSecondaryContainer,
|
|
),
|
|
UploadTaskState.uploading || UploadTaskState.uploadedConfirming => (
|
|
theme.colorScheme.primaryContainer,
|
|
theme.colorScheme.onPrimaryContainer,
|
|
),
|
|
UploadTaskState.completed => (
|
|
theme.colorScheme.tertiaryContainer,
|
|
theme.colorScheme.onTertiaryContainer,
|
|
),
|
|
UploadTaskState.waitingNetwork => (
|
|
theme.colorScheme.tertiaryContainer,
|
|
theme.colorScheme.onTertiaryContainer,
|
|
),
|
|
UploadTaskState.cancelled => (
|
|
theme.colorScheme.surfaceContainerHighest,
|
|
theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
};
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
borderRadius: BorderRadius.circular(999),
|
|
),
|
|
child: Text(
|
|
l10n.labelForTaskState(state),
|
|
style: theme.textTheme.labelSmall?.copyWith(color: onColor),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 传输进度行:上传中 = 进度总线行级订阅(分数进度条);确认中 = 不确定
|
|
/// 进度条(等待服务端核验)。
|
|
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) {
|
|
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 LinearProgressIndicator(
|
|
value: confirming ? null : fraction,
|
|
minHeight: 4,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 行级动作(按状态;未登录账号缺席时禁用)。
|
|
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) {
|
|
case UploadTaskState.preparationFailed:
|
|
// 准备失败重试 = 重新选图(原始源路径不落库,见准备器注释)。
|
|
return IconButton(
|
|
tooltip: l10n.retry,
|
|
icon: const Icon(Icons.refresh),
|
|
onPressed: account == null
|
|
? null
|
|
: () => _retryPreparation(context, ref, account),
|
|
);
|
|
case UploadTaskState.retryableFailed:
|
|
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 IconButton(
|
|
tooltip: l10n.delete,
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: account == null ? null : () => _delete(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)));
|
|
}
|
|
}
|
|
|
|
/// 终态删除:删行 + 尽力删受管副本(行删除后清理执行器不再可见该文件)。
|
|
Future<void> _delete(WidgetRef ref, String account) async {
|
|
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 _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);
|
|
final failed = 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: Text(
|
|
'${l10n.queueStateFailed} · $failed',
|
|
style: theme.textTheme.bodyMedium?.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 + waitingNetwork,跨当前筛选)
|
|
/// 立即重新入队。
|
|
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,
|
|
UploadTaskState.waitingNetwork,
|
|
},
|
|
);
|
|
for (final task in tasks) {
|
|
await engine.retryNow(account, task.id);
|
|
}
|
|
}
|
|
}
|