/// 上传队列页(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/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 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( value: value, isExpanded: true, underline: const SizedBox.shrink(), hint: Text(l10n.queueAlbumFilterAll), items: [ DropdownMenuItem( value: null, child: Text( l10n.queueAlbumFilterAll, style: Theme.of(context).textTheme.bodyMedium, ), ), for (final album in albums) DropdownMenuItem( 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( 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 _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 _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 _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); } } }