feat(mobile): UI-03 capture and upload pages (13-16)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 02:06:23 +08:00
parent 8bd09eefb9
commit ab51616c70
13 changed files with 1907 additions and 475 deletions
+258 -147
View File
@@ -1,13 +1,16 @@
/// 上传中心(UI-01 §4.4,P3 计划 Task 9 基础上重构)。
/// 上传中心(UI-03 页面 14/15:上传中心 + 异常任务视图)。
///
/// 顶部摘要卡(筛选范围标注「相册·时间·本设备」+ 四组任务数:已完成/
/// 上传中/等待中/需关注——按任务数统计,单任务百分比才走字节进度)→
/// 筛选栏(相册下拉 × 时间快捷 × 状态分组 chips:全部/进行中/需关注/
/// 已完成)→ 任务行 = 缩略图 + 文件名 + 状态徽章(StateBadge 图标+文案+
/// 页头「Uploads」(24sp)+ 右上「History」入口(→ 服务端记录页,与
/// 本机队列数据源区分)。摘要卡(范围标注「相册·时间·本设备」+ 四组
/// 任务数:已完成(放大突出)/上传中/等待中/需关注——按任务数统计,
/// 单任务百分比才走字节进度;**不随状态筛选变口径**,异常视图下摘要
/// 仍显示全量计数)。主筛选 = 大号三段(All / Active / Needs attention);
/// 次级筛选(相册 × 时间 × 已完成)收进筛选图标菜单(BottomSheet)。
/// 任务行 = 缩略图 + 文件名 + 相册 + 状态徽章(StateBadge 图标+文案+
/// 颜色三重表达)+ 状态专属文案/操作(§4.4 表格:确认中不显示已公开、
/// 准备失败走重新选择而非普通重试、终态走「移除记录」并明确不删云端
/// 照片)+ 传输进度(进度总线行级订阅)。存在可重试失败时底部
/// 「一键重试」条(仅作用于可重试任务,文案说明范围)。
/// 照片)+ 传输进度(进度总线行级订阅,不整页刷新)。存在可重试失败时
/// 底部「重试可恢复项」条(仅作用于可重试任务,文案说明范围)。
///
/// 列表经 TaskStore 写通知 + 200ms 防抖实时刷新(连拍风暴不逐条重建)。
library;
@@ -18,7 +21,6 @@ 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';
@@ -46,19 +48,25 @@ class QueueTab extends ConsumerWidget {
final retryable = counts[UploadTaskState.retryableFailed] ?? 0;
return Scaffold(
appBar: AppBar(
// 24sp 主标题(theme titleLarge)+ 右上「History」链接(§4 页面14)。
title: Text(l10n.queue),
actions: [
IconButton(
tooltip: l10n.serverHistoryEntry,
icon: const Icon(Icons.history_outlined),
onPressed: () => Navigator.of(context).pushNamed('/server-history'),
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(),
_FilterBar(),
_PrimaryFilterBar(),
_NeedsAttentionNote(),
Expanded(child: _TaskListView()),
],
),
@@ -69,7 +77,8 @@ class QueueTab extends ConsumerWidget {
// ── 摘要卡 ────────────────────────────────────────────────────────────────
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径)。
/// 顶部摘要:筛选范围标注 + 四组任务数(SQL 聚合,与列表同口径;**不受
/// 状态筛选影响**——页面 15 异常视图下仍显示全量计数,配说明行)。
class _SummaryCard extends ConsumerWidget {
const _SummaryCard();
@@ -88,8 +97,8 @@ class _SummaryCard extends ConsumerWidget {
: albums.where((a) => a.id == filter.albumId).firstOrNull;
final scope = [
if (album != null) albumDisplayTitle(album, l10n.localeName),
_FilterBar._timeLabel(l10n, filter.time),
l10n.thisDevice,
_queueTimeLabel(l10n, filter.time),
l10n.uploadsThisDevice,
].join(' · ');
final light = theme.brightness == Brightness.light;
@@ -119,23 +128,26 @@ class _SummaryCard extends ConsumerWidget {
],
),
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: completedSummaryOf(counts),
label: l10n.queueSummaryCompleted,
color: light ? kColorSuccess : kColorSuccessDark,
),
_SummaryCount(
value: uploadingSummaryOf(counts),
label: l10n.queueSummaryUploading,
label: l10n.uploadsUploading,
color: theme.colorScheme.primary,
),
_SummaryCount(
value: waitingSummaryOf(counts),
label: l10n.queueSummaryWaiting,
label: l10n.uploadsWaiting,
color: light ? kColorWaiting : kColorWaitingDark,
),
_SummaryCount(
@@ -152,17 +164,20 @@ class _SummaryCard extends ConsumerWidget {
}
}
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色)。
/// 摘要单项:数字(主文字色、表格数字)+ 标签(语义色);[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) {
@@ -174,9 +189,12 @@ class _SummaryCount extends StatelessWidget {
children: [
Text(
'$value',
style: theme.textTheme.titleMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
style: (emphasized
? theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
)
: theme.textTheme.titleMedium)
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
),
const SizedBox(width: kSpace4),
Text(label, style: theme.textTheme.bodySmall?.copyWith(color: color)),
@@ -187,9 +205,86 @@ class _SummaryCount extends StatelessWidget {
// ── 筛选栏 ────────────────────────────────────────────────────────────────
/// 筛选栏:相册下拉 + 时间快捷 chips + 状态分组 chips(§4.4 四组)。
class _FilterBar extends ConsumerWidget {
const _FilterBar();
/// 时间快捷标签(「今天」与摘要口径共用一词,§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(
child: SegmentedButton<QueueStateFilter>(
// 已完成经筛选菜单选择时三段不亮(属次级筛选,非主段)。
selected: filter.state == QueueStateFilter.completed
? const <QueueStateFilter>{}
: <QueueStateFilter>{filter.state},
emptySelectionAllowed: true,
showSelectedIcon: false,
style: SegmentedButton.styleFrom(
minimumSize: const Size.fromHeight(44),
padding: const EdgeInsets.symmetric(horizontal: kSpace12),
),
segments: [
ButtonSegment(
value: QueueStateFilter.all,
label: Text(l10n.uploadsAll),
),
ButtonSegment(
value: QueueStateFilter.inProgress,
label: Text(l10n.uploadsActive),
),
ButtonSegment(
value: QueueStateFilter.needsAttention,
label: Text(l10n.uploadsNeedsAttention),
),
],
onSelectionChanged: (selection) => ref
.read(queueFilterProvider.notifier)
.setStateGroup(selection.first),
),
),
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) {
@@ -198,125 +293,131 @@ class _FilterBar extends ConsumerWidget {
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(
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: [
Icon(
Icons.filter_list,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: _AlbumDropdown(filter: filter, albums: albums),
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 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 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 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 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,
),
const SizedBox(height: 4),
],
),
title: Text(l10n.queueStateCompleted),
onTap: () {
ref.read(queueFilterProvider.notifier).setStateGroup(
filter.state == QueueStateFilter.completed
? QueueStateFilter.all
: QueueStateFilter.completed,
);
close();
},
),
],
),
);
}
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;
/// 异常视图说明行(页面 15):Needs attention 主段选中时常驻,说明列表
/// 已过滤而摘要仍是全量口径。
class _NeedsAttentionNote extends ConsumerWidget {
const _NeedsAttentionNote();
@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,
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,
),
),
for (final album in albums)
DropdownMenuItem<String?>(
value: album.id,
const SizedBox(width: kSpace8),
Expanded(
child: Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
l10n.uploadsShowingNeedsAttention,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
],
onChanged: (albumId) =>
ref.read(queueFilterProvider.notifier).setAlbum(albumId),
],
),
);
}
}
@@ -329,6 +430,9 @@ class _TaskListView extends ConsumerWidget {
@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,
@@ -339,9 +443,14 @@ class _TaskListView extends ConsumerWidget {
),
data: (tasks) {
if (tasks.isEmpty) {
// 页面 15:异常视图空态专用文案(区别于整体队列空态)。
return EmptyState(
icon: Icons.cloud_upload_outlined,
title: l10n.queueEmpty,
icon: stateFilter == QueueStateFilter.needsAttention
? Icons.task_alt_outlined
: Icons.cloud_upload_outlined,
title: stateFilter == QueueStateFilter.needsAttention
? l10n.uploadsNoNeedsAttention
: l10n.queueEmpty,
);
}
return ListView.builder(
@@ -354,7 +463,7 @@ class _TaskListView extends ConsumerWidget {
}
}
/// 单条任务行(§4.4 状态表逐态落地)。
/// 单条任务行(§4 页面 14 状态表逐态落地)。
class _TaskTile extends ConsumerWidget {
const _TaskTile({required this.task});
@@ -466,7 +575,8 @@ class _TaskTile extends ConsumerWidget {
);
}
/// 徽章语义变体(§4.4:进行=主色、等待=琥珀、失败=红、完成=绿、取消=灰)。
/// 徽章语义变体(§4 页面 14:进行=主色、等待=琥珀、失败=红、完成=绿、
/// 取消=灰)。
static BadgeVariant _badgeVariant(UploadTaskState state) =>
switch (state) {
UploadTaskState.preparing ||
@@ -484,12 +594,12 @@ class _TaskTile extends ConsumerWidget {
static String _badgeLabel(AppLocalizations l10n, UploadTaskState state) =>
switch (state) {
UploadTaskState.preparing => l10n.queueRowPreparing,
UploadTaskState.preparationFailed => l10n.queueRowFileUnavailable,
UploadTaskState.queued => l10n.queueRowWaitingUpload,
UploadTaskState.preparationFailed => l10n.uploadsFileUnavailable,
UploadTaskState.queued => l10n.uploadsWaitingUpload,
UploadTaskState.uploading => l10n.taskStateUploading,
UploadTaskState.uploadedConfirming => l10n.queueRowConfirming,
UploadTaskState.uploadedConfirming => l10n.uploadsConfirming,
UploadTaskState.completed => l10n.taskStateCompleted,
UploadTaskState.waitingNetwork => l10n.taskStateWaitingNetwork,
UploadTaskState.waitingNetwork => l10n.uploadsWaitingNetwork,
UploadTaskState.retryableFailed => l10n.taskStateRetryableFailed,
UploadTaskState.cancelled => l10n.taskStateCancelled,
};
@@ -592,7 +702,7 @@ class _TaskActions extends ConsumerWidget {
case UploadTaskState.preparationFailed:
return _RowAction(
icon: Icons.image_search_outlined,
label: l10n.queueActionSelectAgain,
label: l10n.uploadsSelectAgain,
onPressed: account == null
? null
: () => _retryPreparation(context, ref, account),
@@ -628,7 +738,7 @@ class _TaskActions extends ConsumerWidget {
case UploadTaskState.cancelled:
return _RowAction(
icon: Icons.delete_outline,
label: l10n.queueActionRemoveRecord,
label: l10n.uploadsRemoveRecord,
foreground: Theme.of(context).colorScheme.onSurfaceVariant,
onPressed: account == null
? null
@@ -678,7 +788,7 @@ class _TaskActions extends ConsumerWidget {
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.queueRemoveRecordConfirm),
child: Text(l10n.uploadsRemoveRecordConfirm),
),
],
),
@@ -731,7 +841,8 @@ class _RowAction extends StatelessWidget {
}
}
/// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed)。
/// 底部批量重试条(存在可重试失败任务时出现;仅作用于 retryableFailed,
/// 按钮文案「重试可恢复项」明确范围——§4 页面 15)。
class _BatchRetryBar extends ConsumerWidget {
const _BatchRetryBar();
@@ -770,7 +881,7 @@ class _BatchRetryBar extends ConsumerWidget {
),
),
Text(
l10n.queueRetryAllScope,
l10n.uploadsRetryEligibleScope,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
),
@@ -784,7 +895,7 @@ class _BatchRetryBar extends ConsumerWidget {
foregroundColor: theme.colorScheme.onError,
),
icon: const Icon(Icons.refresh, size: 18),
label: Text(l10n.queueRetryAllFailed),
label: Text(l10n.uploadsRetryEligible),
onPressed: () => _retryAll(ref),
),
],
@@ -801,9 +912,9 @@ class _BatchRetryBar extends ConsumerWidget {
if (account == null) return;
final engine = ref.read(uploadEngineProvider);
final tasks = await ref.read(taskStoreProvider).listByAccount(
account,
states: const {UploadTaskState.retryableFailed},
);
account,
states: const {UploadTaskState.retryableFailed},
);
for (final task in tasks) {
await engine.retryNow(account, task.id);
}