/// 上传中心(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 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, _) => 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( 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 _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 _confirmRemoveRecord( BuildContext context, WidgetRef ref, String account, ) async { final l10n = AppLocalizations.of(context); final confirmed = await showDialog( 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 _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); } } }