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
+31
View File
@@ -199,6 +199,37 @@
"queueRetryAllFailed": "Retry all failed",
"queueAttemptCount": "{count} attempt(s)",
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
"captureQueued": "{count} queued",
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
"captureFlipCamera": "Switch camera",
"captureFlash": "Flash",
"uploadsHistory": "History",
"uploadsToday": "Today",
"uploadsThisDevice": "This device",
"uploadsCompleted": "completed",
"uploadsUploading": "uploading",
"uploadsWaiting": "waiting",
"uploadsNeedsAttention": "Needs attention",
"uploadsAll": "All",
"uploadsActive": "Active",
"uploadsConfirming": "Confirming…",
"uploadsWaitingUpload": "Waiting to upload",
"uploadsWaitingNetwork": "Waiting for network",
"uploadsFileUnavailable": "File unavailable",
"uploadsSelectAgain": "Select again",
"uploadsRemoveRecord": "Remove record",
"uploadsRemoveRecordConfirm": "Remove",
"uploadsRetryEligible": "Retry eligible",
"uploadsRetryEligibleScope": "Retries failed uploads that can recover on their own. Files marked unavailable need to be selected again.",
"uploadsNoNeedsAttention": "No tasks need attention",
"uploadsShowingNeedsAttention": "Showing tasks that need your attention",
"uploadsFilters": "Filters",
"serverRecords": "Server records",
"serverSubtitle": "All devices · Your account",
"serverConfirmed": "Confirmed",
"serverReady": "Ready",
"serverProcessing": "Processing",
"serverDeviceUnknown": "Unknown device",
"taskStatePreparing": "Preparing",
"taskStatePreparationFailed": "Preparation failed",
"taskStateQueued": "Queued",
+180
View File
@@ -1256,6 +1256,186 @@ abstract class AppLocalizations {
/// **'已尝试 {count} 次'**
String queueAttemptCount(int count);
/// No description provided for @captureQueued.
///
/// In zh, this message translates to:
/// **'{count} 张待传'**
String captureQueued(int count);
/// No description provided for @captureFlipCamera.
///
/// In zh, this message translates to:
/// **'切换前后摄像头'**
String get captureFlipCamera;
/// No description provided for @captureFlash.
///
/// In zh, this message translates to:
/// **'闪光灯'**
String get captureFlash;
/// No description provided for @uploadsHistory.
///
/// In zh, this message translates to:
/// **'历史记录'**
String get uploadsHistory;
/// No description provided for @uploadsToday.
///
/// In zh, this message translates to:
/// **'今天'**
String get uploadsToday;
/// No description provided for @uploadsThisDevice.
///
/// In zh, this message translates to:
/// **'本设备'**
String get uploadsThisDevice;
/// No description provided for @uploadsCompleted.
///
/// In zh, this message translates to:
/// **'已完成'**
String get uploadsCompleted;
/// No description provided for @uploadsUploading.
///
/// In zh, this message translates to:
/// **'上传中'**
String get uploadsUploading;
/// No description provided for @uploadsWaiting.
///
/// In zh, this message translates to:
/// **'等待中'**
String get uploadsWaiting;
/// No description provided for @uploadsNeedsAttention.
///
/// In zh, this message translates to:
/// **'需关注'**
String get uploadsNeedsAttention;
/// No description provided for @uploadsAll.
///
/// In zh, this message translates to:
/// **'全部'**
String get uploadsAll;
/// No description provided for @uploadsActive.
///
/// In zh, this message translates to:
/// **'进行中'**
String get uploadsActive;
/// No description provided for @uploadsConfirming.
///
/// In zh, this message translates to:
/// **'确认中…'**
String get uploadsConfirming;
/// No description provided for @uploadsWaitingUpload.
///
/// In zh, this message translates to:
/// **'等待上传'**
String get uploadsWaitingUpload;
/// No description provided for @uploadsWaitingNetwork.
///
/// In zh, this message translates to:
/// **'等待网络'**
String get uploadsWaitingNetwork;
/// No description provided for @uploadsFileUnavailable.
///
/// In zh, this message translates to:
/// **'文件不可用'**
String get uploadsFileUnavailable;
/// No description provided for @uploadsSelectAgain.
///
/// In zh, this message translates to:
/// **'重新选择'**
String get uploadsSelectAgain;
/// No description provided for @uploadsRemoveRecord.
///
/// In zh, this message translates to:
/// **'移除记录'**
String get uploadsRemoveRecord;
/// No description provided for @uploadsRemoveRecordConfirm.
///
/// In zh, this message translates to:
/// **'移除'**
String get uploadsRemoveRecordConfirm;
/// No description provided for @uploadsRetryEligible.
///
/// In zh, this message translates to:
/// **'重试可恢复项'**
String get uploadsRetryEligible;
/// No description provided for @uploadsRetryEligibleScope.
///
/// In zh, this message translates to:
/// **'仅重试可自动恢复的上传失败任务;文件不可用的任务需重新选择。'**
String get uploadsRetryEligibleScope;
/// No description provided for @uploadsNoNeedsAttention.
///
/// In zh, this message translates to:
/// **'暂无需要关注的任务'**
String get uploadsNoNeedsAttention;
/// No description provided for @uploadsShowingNeedsAttention.
///
/// In zh, this message translates to:
/// **'正在显示需要你处理的任务'**
String get uploadsShowingNeedsAttention;
/// No description provided for @uploadsFilters.
///
/// In zh, this message translates to:
/// **'筛选'**
String get uploadsFilters;
/// No description provided for @serverRecords.
///
/// In zh, this message translates to:
/// **'服务端记录'**
String get serverRecords;
/// No description provided for @serverSubtitle.
///
/// In zh, this message translates to:
/// **'全部设备 · 当前账号'**
String get serverSubtitle;
/// No description provided for @serverConfirmed.
///
/// In zh, this message translates to:
/// **'已确认'**
String get serverConfirmed;
/// No description provided for @serverReady.
///
/// In zh, this message translates to:
/// **'已就绪'**
String get serverReady;
/// No description provided for @serverProcessing.
///
/// In zh, this message translates to:
/// **'处理中'**
String get serverProcessing;
/// No description provided for @serverDeviceUnknown.
///
/// In zh, this message translates to:
/// **'未知设备'**
String get serverDeviceUnknown;
/// No description provided for @taskStatePreparing.
///
/// In zh, this message translates to:
@@ -617,6 +617,100 @@ class AppLocalizationsEn extends AppLocalizations {
return '$count attempt(s)';
}
@override
String captureQueued(int count) {
return '$count queued';
}
@override
String get captureFlipCamera => 'Switch camera';
@override
String get captureFlash => 'Flash';
@override
String get uploadsHistory => 'History';
@override
String get uploadsToday => 'Today';
@override
String get uploadsThisDevice => 'This device';
@override
String get uploadsCompleted => 'completed';
@override
String get uploadsUploading => 'uploading';
@override
String get uploadsWaiting => 'waiting';
@override
String get uploadsNeedsAttention => 'Needs attention';
@override
String get uploadsAll => 'All';
@override
String get uploadsActive => 'Active';
@override
String get uploadsConfirming => 'Confirming…';
@override
String get uploadsWaitingUpload => 'Waiting to upload';
@override
String get uploadsWaitingNetwork => 'Waiting for network';
@override
String get uploadsFileUnavailable => 'File unavailable';
@override
String get uploadsSelectAgain => 'Select again';
@override
String get uploadsRemoveRecord => 'Remove record';
@override
String get uploadsRemoveRecordConfirm => 'Remove';
@override
String get uploadsRetryEligible => 'Retry eligible';
@override
String get uploadsRetryEligibleScope =>
'Retries failed uploads that can recover on their own. Files marked unavailable need to be selected again.';
@override
String get uploadsNoNeedsAttention => 'No tasks need attention';
@override
String get uploadsShowingNeedsAttention =>
'Showing tasks that need your attention';
@override
String get uploadsFilters => 'Filters';
@override
String get serverRecords => 'Server records';
@override
String get serverSubtitle => 'All devices · Your account';
@override
String get serverConfirmed => 'Confirmed';
@override
String get serverReady => 'Ready';
@override
String get serverProcessing => 'Processing';
@override
String get serverDeviceUnknown => 'Unknown device';
@override
String get taskStatePreparing => 'Preparing';
@@ -600,6 +600,98 @@ class AppLocalizationsZh extends AppLocalizations {
return '已尝试 $count 次';
}
@override
String captureQueued(int count) {
return '$count 张待传';
}
@override
String get captureFlipCamera => '切换前后摄像头';
@override
String get captureFlash => '闪光灯';
@override
String get uploadsHistory => '历史记录';
@override
String get uploadsToday => '今天';
@override
String get uploadsThisDevice => '本设备';
@override
String get uploadsCompleted => '已完成';
@override
String get uploadsUploading => '上传中';
@override
String get uploadsWaiting => '等待中';
@override
String get uploadsNeedsAttention => '需关注';
@override
String get uploadsAll => '全部';
@override
String get uploadsActive => '进行中';
@override
String get uploadsConfirming => '确认中…';
@override
String get uploadsWaitingUpload => '等待上传';
@override
String get uploadsWaitingNetwork => '等待网络';
@override
String get uploadsFileUnavailable => '文件不可用';
@override
String get uploadsSelectAgain => '重新选择';
@override
String get uploadsRemoveRecord => '移除记录';
@override
String get uploadsRemoveRecordConfirm => '移除';
@override
String get uploadsRetryEligible => '重试可恢复项';
@override
String get uploadsRetryEligibleScope => '仅重试可自动恢复的上传失败任务;文件不可用的任务需重新选择。';
@override
String get uploadsNoNeedsAttention => '暂无需要关注的任务';
@override
String get uploadsShowingNeedsAttention => '正在显示需要你处理的任务';
@override
String get uploadsFilters => '筛选';
@override
String get serverRecords => '服务端记录';
@override
String get serverSubtitle => '全部设备 · 当前账号';
@override
String get serverConfirmed => '已确认';
@override
String get serverReady => '已就绪';
@override
String get serverProcessing => '处理中';
@override
String get serverDeviceUnknown => '未知设备';
@override
String get taskStatePreparing => '准备中';
+31
View File
@@ -199,6 +199,37 @@
"queueRetryAllFailed": "一键重试全部失败",
"queueAttemptCount": "已尝试 {count} 次",
"@queueAttemptCount": { "placeholders": { "count": { "type": "int" } } },
"captureQueued": "{count} 张待传",
"@captureQueued": { "placeholders": { "count": { "type": "int" } } },
"captureFlipCamera": "切换前后摄像头",
"captureFlash": "闪光灯",
"uploadsHistory": "历史记录",
"uploadsToday": "今天",
"uploadsThisDevice": "本设备",
"uploadsCompleted": "已完成",
"uploadsUploading": "上传中",
"uploadsWaiting": "等待中",
"uploadsNeedsAttention": "需关注",
"uploadsAll": "全部",
"uploadsActive": "进行中",
"uploadsConfirming": "确认中…",
"uploadsWaitingUpload": "等待上传",
"uploadsWaitingNetwork": "等待网络",
"uploadsFileUnavailable": "文件不可用",
"uploadsSelectAgain": "重新选择",
"uploadsRemoveRecord": "移除记录",
"uploadsRemoveRecordConfirm": "移除",
"uploadsRetryEligible": "重试可恢复项",
"uploadsRetryEligibleScope": "仅重试可自动恢复的上传失败任务;文件不可用的任务需重新选择。",
"uploadsNoNeedsAttention": "暂无需要关注的任务",
"uploadsShowingNeedsAttention": "正在显示需要你处理的任务",
"uploadsFilters": "筛选",
"serverRecords": "服务端记录",
"serverSubtitle": "全部设备 · 当前账号",
"serverConfirmed": "已确认",
"serverReady": "已就绪",
"serverProcessing": "处理中",
"serverDeviceUnknown": "未知设备",
"taskStatePreparing": "准备中",
"taskStatePreparationFailed": "准备失败",
"taskStateQueued": "待上传",
+351 -136
View File
@@ -1,17 +1,20 @@
/// 连拍页(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
/// 连拍页(UI-03 页面 13 深色摄影界面;P3 计划 Task 9 / R17 连拍入队)。
///
/// 进入时选定目标相册与分类(相册详情 FAB 传入);快门每张立即经准备器
/// 入队(preparing→queued,不逐张确认);常驻信息层:相册名 + 分类(可
/// 切换,**仅影响之后拍摄**——引擎只读任务行归属,切换不改既有任务)+
/// 待传/失败徽章(TaskStore 写通知实时刷新)。相册多选按钮补充入队;
/// 退出时若有待传任务弹确认(任务在后台继续——Android 原生 WorkManager
/// 承担,见 native_transport.dart)。
/// 入队(preparing→queued,不逐张确认)。全屏取景(恒深色 #10161E,不随
/// 系统主题):顶部常驻层 = 返回 + 相册名 + 待传/失败徽章 + 分类 chips
/// (可切换,**仅影响之后拍摄**——引擎只读任务行归属);底部 = 最近一张
/// 缩略图(兼相册多选入口,iOS 相机惯例)+ 快门(≥72dp 白环)+ 前后摄
/// 切换 + 闪光灯(源支持才出现)。入队成功以触觉 + 200ms 浮层轻反馈,
/// 不逐张弹确认;退出时若有待传任务弹确认(任务在后台继续——Android
/// 原生 WorkManager 承担,见 native_transport.dart)。
///
/// 相机/相册经注入点([captureSourceFactoryProvider]/[multiImagePickerProvider])
/// 隔离平台通道,widget 测试注入替身驱动。
library;
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
@@ -22,6 +25,7 @@ import '../features/albums/models.dart';
import '../features/uploads/enqueue.dart';
import '../features/uploads/queue_controller.dart';
import '../l10n/app_localizations.dart';
import '../ui/tokens.dart';
// ── 相机源抽象(生产=camera 插件;测试替身) ─────────────────────────────
@@ -33,28 +37,38 @@ abstract class CaptureSource {
/// 拍摄一张(返回源文件)。
Future<XFile> capture();
/// 取景预览。
/// 取景预览(含纵横比校正——全屏 cover 铺满,不拉伸)。
Widget buildPreview();
/// 释放底层资源。
Future<void> dispose();
/// 闪光灯能力(默认不支持——子类按硬件能力覆写;不支持则 UI 不显示
/// 闪光开关,不提供假开关)。
bool get supportsFlash => false;
/// 设置闪光灯(仅 [supportsFlash] 为真时有意义;失败抛异常由调用方回滚)。
Future<void> setFlashEnabled(bool enabled) async {}
}
/// 生产拍摄源:camera 插件(后摄优先,最高分辨率,静音连拍)。
class CameraPluginSource implements CaptureSource {
class CameraPluginSource extends CaptureSource {
CameraPluginSource._(this._controller);
/// 创建并初始化(后摄优先)。
static Future<CameraPluginSource> create() async {
/// 创建并初始化([preferFront] 为真选前摄,否则后摄优先)。
static Future<CameraPluginSource> create({bool preferFront = false}) async {
final cameras = await availableCameras();
if (cameras.isEmpty) {
throw CameraException('noCamera', 'no camera available');
}
final back = cameras.firstWhere(
(c) => c.lensDirection == CameraLensDirection.back,
orElse: () => cameras.first,
);
final controller = CameraController(back, ResolutionPreset.max,
CameraDescription pick() => cameras.firstWhere(
(c) => c.lensDirection ==
(preferFront
? CameraLensDirection.front
: CameraLensDirection.back),
orElse: () => cameras.first,
);
final controller = CameraController(pick(), ResolutionPreset.max,
enableAudio: false);
await controller.initialize();
return CameraPluginSource._(controller);
@@ -69,7 +83,28 @@ class CameraPluginSource implements CaptureSource {
Future<XFile> capture() => _controller.takePicture();
@override
Widget buildPreview() => CameraPreview(_controller);
Widget buildPreview() {
// 纵横比校正(§4 页面 13):预览按传感器尺寸布局,再 cover 铺满——
// CameraPreview 直填父约束会把画面拉扁/拉高。
final size = _controller.value.previewSize;
Widget preview = CameraPreview(_controller);
if (size != null && size.width > 0 && size.height > 0) {
preview = FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(width: size.width, height: size.height, child: preview),
);
}
return preview;
}
@override
bool get supportsFlash => true;
@override
Future<void> setFlashEnabled(bool enabled) => _controller.setFlashMode(
enabled ? FlashMode.always : FlashMode.off,
);
@override
Future<void> dispose() => _controller.dispose();
@@ -77,7 +112,8 @@ class CameraPluginSource implements CaptureSource {
/// 拍摄源工厂 Provider(测试 override 为替身)。
final captureSourceFactoryProvider =
Provider<Future<CaptureSource> Function()>((ref) => CameraPluginSource.create);
Provider<Future<CaptureSource> Function({bool preferFront})>(
(ref) => CameraPluginSource.create);
// ── 连拍页 ────────────────────────────────────────────────────────────────
@@ -119,6 +155,15 @@ class _CapturePageState extends ConsumerState<CapturePage> {
CaptureSource? _source;
_CameraPhase _phase = _CameraPhase.loading;
/// 当前镜头方向(快门旁切换按钮翻转;重试沿用)。
bool _preferFront = false;
/// 闪光灯开关(源不支持则无 UI;重开相机后复位)。
bool _flashOn = false;
/// 最近一张拍摄文件(底部缩略图;拍摄前显示相册入口占位)。
String? _lastShotPath;
/// 当前分类(null = 相册默认分类;切换仅影响之后拍摄)。
String? _categoryId;
@@ -142,17 +187,24 @@ class _CapturePageState extends ConsumerState<CapturePage> {
super.dispose();
}
Future<void> _initCamera() async {
Future<void> _initCamera({bool? preferFront}) async {
final front = preferFront ?? _preferFront;
setState(() => _phase = _CameraPhase.loading);
try {
final source = await ref.read(captureSourceFactoryProvider)();
final source = await ref.read(captureSourceFactoryProvider)(
preferFront: front,
);
await source.initialize();
if (!mounted) {
await source.dispose();
return;
}
_source = source;
setState(() => _phase = _CameraPhase.ready);
setState(() {
_preferFront = front;
_flashOn = false; // 新相机实例回默认关。
_phase = _CameraPhase.ready;
});
} on Object catch (error) {
if (!mounted) return;
setState(() => _phase = _isPermissionDenied(error)
@@ -162,12 +214,34 @@ class _CapturePageState extends ConsumerState<CapturePage> {
}
/// camera 插件的权限拒绝错误码族(CameraAccessDenied /
/// CameraAccessDeniedWithoutD restriction 等)。
/// CameraAccessDeniedWithoutRestriction 等)。
static bool _isPermissionDenied(Object error) =>
error is CameraException &&
(error.code.toLowerCase().contains('denied') ||
error.code.toLowerCase().contains('permission'));
/// 前后摄切换:释放当前源 → 反向重建(能力缺失自然落 failed 态重试)。
Future<void> _flipCamera() async {
if (_phase != _CameraPhase.ready) return;
final old = _source;
_source = null;
await old?.dispose();
await _initCamera(preferFront: !_preferFront);
}
/// 闪光灯开关:硬件设置失败则回滚(不留假状态)。
Future<void> _toggleFlash() async {
final source = _source;
if (source == null || !source.supportsFlash) return;
final next = !_flashOn;
try {
await source.setFlashEnabled(next);
if (mounted) setState(() => _flashOn = next);
} on Object {
// 设置失败:保持原状态(下次点击重试)。
}
}
/// 快门:立即入队(R17 不逐张确认);连点串行不丢张。
void _shoot() {
final source = _source;
@@ -176,6 +250,8 @@ class _CapturePageState extends ConsumerState<CapturePage> {
_shotTail = _shotTail.then((_) async {
final photo = await source.capture();
if (!mounted) return;
// 最近一张缩略图(拍摄原文件;缺失走占位)。
setState(() => _lastShotPath = photo.path);
final enqueued = await enqueueForUpload(
ref,
accountId: widget.target.accountId,
@@ -200,7 +276,7 @@ class _CapturePageState extends ConsumerState<CapturePage> {
});
}
/// 相册多选入队(目标 = 当前相册与分类)。
/// 相册多选入队(目标 = 当前相册与分类;入口=底部缩略图位)。
Future<void> _pickFromGallery() async {
final paths = await ref.read(multiImagePickerProvider)();
if (paths.isEmpty || !mounted) return;
@@ -272,33 +348,34 @@ class _CapturePageState extends ConsumerState<CapturePage> {
_confirmExit();
},
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black87,
foregroundColor: Colors.white,
title: Text(l10n.captureTitle),
),
// 深色摄影界面恒深色(§3):不读主题,固定 #10161E。
backgroundColor: kColorPhotoBackdrop,
body: Stack(
fit: StackFit.expand,
children: [
_buildCameraArea(l10n),
// 常驻信息层:相册 + 分类 + 失败徽章(R17/O01)。
// 顶部常驻层:返回 + 相册 + 待传/失败徽章 + 分类 chips。
SafeArea(
child: Column(
children: [
_TargetOverlay(
_TopBar(
albumTitle: widget.target.albumTitle,
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
queued: pending,
failed: failed,
onBack: () => Navigator.of(context).maybePop(),
),
if (categories.isNotEmpty)
_CategoryChips(
categories: categories,
categoryId: _categoryId,
onCategorySelected: _switchCategory,
),
],
),
),
// 入队成功短暂反馈浮层(200ms 自动消退,不逐张弹确认)。
Positioned(
top: 16,
top: 132,
left: 0,
right: 0,
child: IgnorePointer(
@@ -338,9 +415,24 @@ class _CapturePageState extends ConsumerState<CapturePage> {
),
),
),
// 底部操作层:缩略图(=相册入口)+ 快门(+闪光灯)+ 前后切换。
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _BottomControls(
enabled: _phase == _CameraPhase.ready,
lastShotPath: _lastShotPath,
flashSupported: _source?.supportsFlash ?? false,
flashOn: _flashOn,
onShutter: _shoot,
onGallery: _pickFromGallery,
onFlip: _flipCamera,
onFlash: _toggleFlash,
),
),
],
),
bottomNavigationBar: _buildBottomBar(l10n, pending),
),
);
}
@@ -370,126 +462,113 @@ class _CapturePageState extends ConsumerState<CapturePage> {
);
}
}
}
Widget _buildBottomBar(AppLocalizations l10n, int pending) {
return BottomAppBar(
color: Colors.black87,
// 默认高 56 放不下「快门 + 待传徽章」竖排(内容 ≈105,BottomAppBar
// 内部再占 24),显式加高避免溢出。
height: 132,
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻层:返回 + 相册名 + 待传/失败徽章(§4 页面 13)。
class _TopBar extends StatelessWidget {
const _TopBar({
required this.albumTitle,
required this.queued,
required this.failed,
required this.onBack,
});
final String albumTitle;
final int queued;
final int failed;
final VoidCallback onBack;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 12, 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
IconButton(
tooltip: l10n.capturePickFromGallery,
icon: const Icon(Icons.photo_library_outlined,
color: Colors.white),
onPressed: _pickFromGallery,
tooltip: MaterialLocalizations.of(context)
.backButtonTooltip, // 语义标签;深色界面固定白色前景。
color: Colors.white,
icon: const Icon(Icons.arrow_back),
onPressed: onBack,
),
// 快门 + 入队计数徽章(§4.5:底部清晰快门与入队计数)。
Column(
mainAxisSize: MainAxisSize.min,
children: [
_ShutterButton(
onPressed: _shoot,
enabled: _phase == _CameraPhase.ready,
const SizedBox(width: kSpace4),
const Icon(Icons.photo_album_outlined, size: 16, color: Colors.white70),
const SizedBox(width: 6),
Expanded(
child: Text(
albumTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
const SizedBox(height: 4),
_Badge(
label: l10n.captureQueuedBadge(pending),
color: Colors.white24,
foreground: Colors.white,
),
],
),
),
if (failed > 0) ...[
_Badge(
label: l10n.captureFailedBadge(failed),
color: kColorErrorDark,
foreground: Colors.white,
),
const SizedBox(width: kSpace8),
],
_Badge(
label: l10n.captureQueued(queued),
color: Colors.white24,
foreground: Colors.white,
),
// 占位对齐(快门居中)。
const SizedBox(width: 48),
],
),
);
}
}
// ── 信息层组件 ────────────────────────────────────────────────────────────
/// 顶部常驻信息层:相册名 + 分类 chips(切换仅影响之后拍摄)+ 失败徽章
/// (待传计数在快门旁常驻,见底部栏)。
class _TargetOverlay extends StatelessWidget {
const _TargetOverlay({
required this.albumTitle,
/// 分类 chips(切换仅影响之后拍摄;A15 归属不变)。
class _CategoryChips extends StatelessWidget {
const _CategoryChips({
required this.categories,
required this.categoryId,
required this.onCategorySelected,
required this.failed,
});
final String albumTitle;
final List<Category> categories;
final String? categoryId;
final void Function(String?) onCategorySelected;
final int failed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
const Icon(Icons.photo_album_outlined,
size: 16, color: Colors.white70),
const SizedBox(width: 6),
Expanded(
child: Text(
albumTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white),
),
),
const SizedBox(width: 8),
if (failed > 0)
_Badge(
label: l10n.captureFailedBadge(failed),
color: Colors.red,
foreground: Colors.white,
),
],
return SizedBox(
height: 44,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
children: [
ChoiceChip(
label: Text(l10n.captureCategoryDefault),
selected: categoryId == null,
onSelected: (_) => onCategorySelected(null),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
),
if (categories.isNotEmpty)
SizedBox(
height: 44,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
children: [
ChoiceChip(
label: Text(l10n.captureCategoryDefault),
selected: categoryId == null,
onSelected: (_) => onCategorySelected(null),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
for (final category in categories) ...[
const SizedBox(width: 8),
ChoiceChip(
label: Text(_categoryLabel(category, l10n)),
selected: categoryId == category.id,
onSelected: (_) => onCategorySelected(category.id),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
],
],
for (final category in categories) ...[
const SizedBox(width: 8),
ChoiceChip(
label: Text(_categoryLabel(category, l10n)),
selected: categoryId == category.id,
onSelected: (_) => onCategorySelected(category.id),
labelStyle: const TextStyle(color: Colors.white),
selectedColor: Colors.white24,
backgroundColor: Colors.black38,
),
),
],
],
],
),
);
}
@@ -538,7 +617,123 @@ class _Badge extends StatelessWidget {
}
}
/// 快门按钮(连拍:不进入模态确认,逐张自动入队)。
/// 底部操作层:最近一张缩略图(兼相册多选入口)+ 快门(≥72dp 白环,
/// 源支持时上方附闪光灯)+ 前后摄切换。
class _BottomControls extends StatelessWidget {
const _BottomControls({
required this.enabled,
required this.lastShotPath,
required this.flashSupported,
required this.flashOn,
required this.onShutter,
required this.onGallery,
required this.onFlip,
required this.onFlash,
});
final bool enabled;
final String? lastShotPath;
final bool flashSupported;
final bool flashOn;
final VoidCallback onShutter;
final VoidCallback onGallery;
final VoidCallback onFlip;
final VoidCallback onFlash;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return IgnorePointer(
// 相机未就绪(加载/权限/失败)时收起操作层——那里是错误态的重试区。
ignoring: !enabled,
child: SafeArea(
minimum: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Align(
alignment: AlignmentDirectional.bottomStart,
child: _LastShotButton(
lastShotPath: lastShotPath,
onPressed: onGallery,
),
),
const Spacer(),
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (flashSupported)
IconButton(
tooltip: l10n.captureFlash,
color: Colors.white,
icon: Icon(flashOn ? Icons.flash_on : Icons.flash_off),
onPressed: onFlash,
),
_ShutterButton(onPressed: onShutter, enabled: enabled),
],
),
const Spacer(),
Align(
alignment: AlignmentDirectional.bottomStart,
child: IconButton(
tooltip: l10n.captureFlipCamera,
color: Colors.white,
icon: const Icon(Icons.flip_camera_ios_outlined),
onPressed: onFlip,
),
),
],
),
),
);
}
}
/// 最近一张缩略图:拍摄前为相册入口占位(photo_library 图标),拍摄后
/// 显示最近一张(缺失走占位);点击均打开相册多选(iOS 相机惯例)。
class _LastShotButton extends StatelessWidget {
const _LastShotButton({required this.lastShotPath, required this.onPressed});
final String? lastShotPath;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
Widget content;
if (lastShotPath == null) {
content = const Icon(
Icons.photo_library_outlined,
size: 24,
color: Colors.white,
);
} else {
content = Image.file(
File(lastShotPath!),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.image_outlined,
size: 24,
color: Colors.white,
),
);
}
return IconButton(
tooltip: l10n.capturePickFromGallery,
onPressed: onPressed,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
fixedSize: const Size(56, 56),
),
icon: ClipRRect(
borderRadius: BorderRadius.circular(kRadiusBadge),
child: SizedBox(width: 48, height: 48, child: content),
),
);
}
}
/// 快门按钮(§4 页面 13:≥72dp 白环;连拍不进入模态确认,逐张自动入队)。
class _ShutterButton extends StatelessWidget {
const _ShutterButton({required this.onPressed, required this.enabled});
@@ -551,23 +746,43 @@ class _ShutterButton extends StatelessWidget {
button: true,
label: AppLocalizations.of(context).captureShutter,
child: IconButton(
key: const ValueKey('capture-shutter'),
onPressed: enabled ? onPressed : null,
iconSize: 64,
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size(76, 76),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
icon: Container(
width: 64,
height: 64,
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
border: Border.all(color: Colors.white, width: 5),
),
alignment: Alignment.center,
child: Container(
width: 58,
height: 58,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 就绪=白心(图标反色);未就绪=仅白环。
color: enabled ? Colors.white : Colors.transparent,
),
alignment: Alignment.center,
child: Icon(
Icons.camera,
size: 28,
color: enabled ? kColorPhotoBackdrop : Colors.white70,
),
),
child: const Icon(Icons.camera, color: Colors.white),
),
),
);
}
}
/// 相机不可用视图(权限拒绝/初始化失败):说明 + 重试。
/// 相机不可用视图(权限拒绝/初始化失败):深色说明 + 重试(§4 页面 13)。
class _CameraMessageView extends StatelessWidget {
const _CameraMessageView({
required this.icon,
@@ -584,7 +799,7 @@ class _CameraMessageView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black87,
color: kColorPhotoBackdrop,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
+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);
}
+377 -114
View File
@@ -1,14 +1,16 @@
/// 服务端上传历史页(UI-01 §4.4 主题统一,P3 计划 Task 6 / A19 语义)。
/// 服务端上传历史页(UI-03 页面 16「Server records」)。
///
/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备**
/// 的上传会话记录:会话状态 / photo.processingState / sourceDeviceId /
/// 创建时间。页面标题即「服务端历史(跨设备)」,顶部说明条明确「本机
/// 待传队列在上传页」——本机队列是 drift 任务库(TaskStore),两者数据源
/// 与生命周期不同,不合并统计(A19 标注要求)。
/// 的上传会话记录:标题 +「全部设备 · 当前账号」副标题明确**跨设备服务端
/// 口径**;顶部说明条强调本机待传队列在「上传」页——本机队列是 drift
/// 任务库(TaskStore),两者数据源与生命周期不同,不合并统计(A19)。
/// 相册/日期为客户端过滤(已加载记录内),分页按服务端原序继续;下拉
/// 刷新 + 加载更多失败可重试。
///
/// 记录不含 filename(契约 UploadSessionRecord 无该字段),行首展示
/// clientTaskId 前 8 位作识别;sourceDeviceId 与本机 [DeviceId] 一致时
/// 标记「本设备」。
/// 记录不含 filename 与设备名(契约 UploadSessionRecord 无该字段),行首
/// 展示 clientTaskId 前 8 位作识别;设备来源做**安全回退**——sourceDeviceId
/// 与本机 [DeviceId] 一致标「本设备」,否则显示通用占位(不展示裸设备
/// ID,不编造设备名)。
library;
import 'dart:async';
@@ -21,15 +23,18 @@ import '../core/app_exception.dart';
import '../core/device_id.dart';
import '../core/error_codes.dart';
import '../core/error_l10n.dart';
import '../features/albums/album_format.dart';
import '../features/albums/providers.dart';
import '../features/photos/models.dart';
import '../features/uploads/models.dart';
import '../features/uploads/queue_controller.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';
/// 服务端上传历史(从队列页占位入口进入)。
/// 服务端上传历史(从上传中心「History」入口进入;无根底部导航)。
class ServerHistoryPage extends ConsumerStatefulWidget {
const ServerHistoryPage({super.key});
@@ -45,8 +50,16 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
bool _hasMore = false;
bool _loading = true;
bool _loadingMore = false;
/// 加载更多失败(页脚重试行出现;重试成功后清除)。
bool _loadMoreFailed = false;
String? _loadMoreErrorCode;
String? _errorCode;
/// 客户端次级筛选(相册 × 时间;作用于已加载记录)。
String? _albumId;
QueueTimeFilter _time = QueueTimeFilter.all;
/// 本机设备标识(「本设备」标记比对,A19)。
String? _ownDeviceId;
@@ -65,6 +78,7 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
setState(() {
_loading = true;
_errorCode = null;
_loadMoreFailed = false;
});
try {
final page = await ref
@@ -95,9 +109,37 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
}
}
/// 下拉刷新:保留当前列表(不切换全局加载态);失败以 snackbar 提示。
Future<void> _refresh() async {
try {
final page = await ref
.read(apiClientProvider)
.listUploads(limit: _pageSize);
if (mounted) {
setState(() {
_items = page.items;
_nextCursor = page.nextCursor;
_hasMore = page.hasMore;
_loadMoreFailed = false;
});
}
} on Object {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).errorGeneric),
),
);
}
}
}
Future<void> _loadMore() async {
if (_loading || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
setState(() {
_loadingMore = true;
_loadMoreFailed = false;
});
try {
final page = await ref.read(apiClientProvider).listUploads(
cursor: _nextCursor,
@@ -111,36 +153,63 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
_loadingMore = false;
});
}
} on AppException {
} on AppException catch (e) {
// 页脚重试行(§4 页面 16:加载失败可重试),不弹全局错误。
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)
.messageForErrorCode(ErrorCodes.networkError),
),
),
);
setState(() {
_loadingMore = false;
_loadMoreFailed = true;
_loadMoreErrorCode = e.code;
});
}
} on Object {
if (mounted) {
setState(() => _loadingMore = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).errorGeneric),
),
);
setState(() {
_loadingMore = false;
_loadMoreFailed = true;
_loadMoreErrorCode = ErrorCodes.internalError;
});
}
}
}
/// 已加载记录按相册 × 时间过滤(分页按服务端原序继续,不因过滤中断)。
List<UploadSessionRecord> get _visibleItems {
final cutoff = _time.since(DateTime.now());
return _items.where((record) {
if (_albumId != null && record.albumId != _albumId) return false;
if (cutoff != null && record.createdAt.toLocal().isBefore(cutoff)) {
return false;
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final albums = ref.watch(albumListProvider).items;
final selectedAlbum = _albumId == null
? null
: albums.where((a) => a.id == _albumId).firstOrNull;
return Scaffold(
appBar: AppBar(title: Text(l10n.serverHistoryTitle)),
appBar: AppBar(
// 标题 + 跨设备副标题(§4 页面 16:Server records · All devices)。
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(l10n.serverRecords),
Text(
l10n.serverSubtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -169,105 +238,217 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
),
),
),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _errorCode != null
? ErrorState(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _reload,
)
: _items.isEmpty
? EmptyState(
icon: Icons.cloud_done_outlined,
title: l10n.serverHistoryEmpty,
)
: ListView.builder(
itemCount: _items.length +
(_hasMore || _loadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _items.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
);
}
if (index >= _items.length - 3 &&
_hasMore &&
!_loadingMore) {
WidgetsBinding.instance.addPostFrameCallback(
(_) => _loadMore(),
);
}
return _sessionTile(l10n, theme, _items[index]);
},
),
Padding(
padding: const EdgeInsets.fromLTRB(
kSpace16, kSpace8, kSpace16, kSpace4),
child: Row(
children: [
PopupMenuButton<String>(
initialValue: _albumId ?? '',
onSelected: (albumId) => setState(() {
_albumId = albumId == '' ? null : albumId;
}),
itemBuilder: (context) => [
PopupMenuItem<String>(
value: '',
child: Text(l10n.queueAlbumFilterAll),
),
for (final album in albums)
PopupMenuItem<String>(
value: album.id,
child: Text(
albumDisplayTitle(album, l10n.localeName),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
child: _FilterPill(
icon: Icons.photo_album_outlined,
label: selectedAlbum == null
? l10n.queueAlbumFilterAll
: albumDisplayTitle(selectedAlbum, l10n.localeName),
),
),
const SizedBox(width: kSpace8),
PopupMenuButton<QueueTimeFilter>(
initialValue: _time,
onSelected: (time) => setState(() => _time = time),
itemBuilder: (context) => [
for (final time in QueueTimeFilter.values)
PopupMenuItem<QueueTimeFilter>(
value: time,
child: Text(switch (time) {
QueueTimeFilter.all => l10n.queueTimeAll,
QueueTimeFilter.today => l10n.uploadsToday,
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
QueueTimeFilter.last30Days =>
l10n.queueTimeLast30Days,
}),
),
],
child: _FilterPill(
icon: Icons.schedule_outlined,
label: switch (_time) {
QueueTimeFilter.all => l10n.queueTimeAll,
QueueTimeFilter.today => l10n.uploadsToday,
QueueTimeFilter.last7Days => l10n.queueTimeLast7Days,
QueueTimeFilter.last30Days => l10n.queueTimeLast30Days,
},
),
),
],
),
),
Expanded(child: _buildBody(l10n)),
],
),
);
}
Widget _sessionTile(
AppLocalizations l10n,
ThemeData theme,
UploadSessionRecord record,
) {
Widget _buildBody(AppLocalizations l10n) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorCode != null) {
return ErrorState(
message: l10n.messageForErrorCode(_errorCode!),
onRetry: _reload,
);
}
final visible = _visibleItems;
if (visible.isEmpty) {
return EmptyState(
icon: Icons.cloud_done_outlined,
title: l10n.serverHistoryEmpty,
);
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: visible.length + (_hasMore || _loadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= visible.length) {
if (_loadMoreFailed) {
return _LoadMoreFailedRow(
message: l10n.messageForErrorCode(
_loadMoreErrorCode ?? ErrorCodes.networkError,
),
onRetry: _loadMore,
);
}
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
// 预取触发;加载更多失败后停住——页脚重试行接管(不自动循环
// 重试,§4 页面 16)。
if (index >= visible.length - 3 &&
_hasMore &&
!_loadingMore &&
!_loadMoreFailed) {
WidgetsBinding.instance.addPostFrameCallback((_) => _loadMore());
}
return _sessionTile(context, visible[index]);
},
),
);
}
Widget _sessionTile(BuildContext context, UploadSessionRecord record) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
// createdAt 以本机时区展示(记录跨相册,无单一相册时区可依)。
final local = record.createdAt.toLocal();
final time = '${local.year}/${local.month}/${local.day} '
'${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
final deviceShort = record.sourceDeviceId?.substring(0, 8);
// 设备来源安全回退:本机 → 「本设备」;其余 → 通用占位(契约无设备
// 名,不展示裸 ID、不编造名称)。
final ownDevice =
_ownDeviceId != null && record.sourceDeviceId == _ownDeviceId;
return ListTile(
leading: const Icon(Icons.cloud_done_outlined),
title: Text(
'${record.clientTaskId.substring(0, 8)} · '
'${(record.expectedBytes / 1024).round()} KB',
style: theme.textTheme.bodyMedium?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()],
),
),
subtitle: Text(
deviceShort == null
? time
: '$time · $deviceShort${ownDevice ? '(${l10n.thisDevice})' : ''}',
),
trailing: Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
final deviceLabel = ownDevice ? l10n.thisDevice : l10n.serverDeviceUnknown;
return Padding(
padding: const EdgeInsets.fromLTRB(kSpace16, kSpace8, kSpace12, kSpace8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
StateBadge(
compact: true,
variant: switch (record.state) {
UploadSessionState.confirmed => BadgeVariant.success,
UploadSessionState.pending => BadgeVariant.info,
UploadSessionState.cancelled ||
UploadSessionState.expired => BadgeVariant.neutral,
},
label: _sessionStateLabel(l10n, record.state),
// 契约无图片 URL——中性占位(不伪造缩略图)。
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(kRadiusBadge),
),
child: Icon(
Icons.cloud_done_outlined,
size: 24,
color: theme.colorScheme.onSurfaceVariant,
),
),
StateBadge(
compact: true,
variant: switch (record.photo.processingState) {
PhotoProcessingState.ready => BadgeVariant.success,
PhotoProcessingState.processing ||
PhotoProcessingState.uploaded => BadgeVariant.info,
PhotoProcessingState.pendingUpload => BadgeVariant.warning,
PhotoProcessingState.processingFailed => BadgeVariant.error,
},
label: _processingLabel(l10n, record.photo.processingState),
const SizedBox(width: kSpace12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${record.clientTaskId.substring(0, 8)} · '
'${(record.expectedBytes / 1024).round()} KB',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
const SizedBox(height: 2),
Text(
'$time · $deviceLabel',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
StateBadge(
compact: true,
variant: switch (record.state) {
UploadSessionState.confirmed => BadgeVariant.success,
UploadSessionState.pending => BadgeVariant.info,
UploadSessionState.cancelled ||
UploadSessionState.expired => BadgeVariant.neutral,
},
label: _sessionStateLabel(l10n, record.state),
),
StateBadge(
compact: true,
variant: switch (record.photo.processingState) {
PhotoProcessingState.ready => BadgeVariant.success,
PhotoProcessingState.processing ||
PhotoProcessingState.uploaded => BadgeVariant.info,
PhotoProcessingState.pendingUpload => BadgeVariant.warning,
PhotoProcessingState.processingFailed => BadgeVariant.error,
},
label: _processingLabel(l10n, record.photo.processingState),
),
],
),
],
),
@@ -282,7 +463,7 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
case UploadSessionState.pending:
return l10n.uploadStatePending;
case UploadSessionState.confirmed:
return l10n.uploadStateConfirmed;
return l10n.serverConfirmed;
case UploadSessionState.cancelled:
return l10n.uploadStateCancelled;
case UploadSessionState.expired:
@@ -300,11 +481,93 @@ class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
case PhotoProcessingState.uploaded:
return l10n.procUploaded;
case PhotoProcessingState.processing:
return l10n.procProcessing;
return l10n.serverProcessing;
case PhotoProcessingState.ready:
return l10n.procReady;
return l10n.serverReady;
case PhotoProcessingState.processingFailed:
return l10n.procProcessingFailed;
}
}
}
/// 筛选药丸(相册/日期入口):边框胶囊 + 展开箭头(§4 页面 16)。
///
/// 作为 [PopupMenuButton.child] 使用——点击弹下拉菜单选择筛选值。
class _FilterPill extends StatelessWidget {
const _FilterPill({required this.icon, required this.label});
final IconData icon;
final String label;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
height: 36,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: 6),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Icon(
Icons.arrow_drop_down,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
);
}
}
/// 加载更多失败页脚:错误说明 + 重试(§4 页面 16)。
class _LoadMoreFailedRow extends StatelessWidget {
const _LoadMoreFailedRow({required this.message, required this.onRetry});
final String message;
final VoidCallback onRetry;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: kSpace16, vertical: 8),
child: Row(
children: [
Icon(Icons.cloud_off, size: 16, color: theme.colorScheme.error),
const SizedBox(width: kSpace8),
Expanded(
child: Text(
message,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 16),
label: Text(l10n.retry),
),
],
),
);
}
}
+6
View File
@@ -49,6 +49,12 @@ const Color kColorInfoDark = Color(0xFF8AB4F8);
/// 次文字(深色表面用)。
const Color kColorTextSecondaryDark = Color(0xFFA6B0C0);
/// 摄影深色背景(§3:相机取景/大图浏览 #10161E)。
///
/// 深色摄影界面**恒深色**——不随系统主题切换(spec §3),文字/图标直接
/// 用固定白/白70,不读主题色。
const Color kColorPhotoBackdrop = Color(0xFF10161E);
// ── 间距(4/8/12/16/24/32)───────────────────────────────────────────────
const double kSpace4 = 4.0;