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
+112 -15
View File
@@ -1,10 +1,11 @@
/// 连拍页 widget 测试(P3 计划 Task 9,R17 连拍入队 / O01 现场反馈)。
/// 连拍页 widget 测试(UI-03 页面 13 深色摄影界面;R17 连拍入队 / O01)。
///
/// 打桩面:FakeCaptureSource(capture 返回真实 JPEG 临时文件,无相机
/// 平台通道)+ 内存 TaskStore + 临时目录准备器 + 非 Android 权限门替身
/// (静默跳过通道)。覆盖:快门即拍即入队、连拍串行、分类切换仅影响
/// 之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务退出确认、
/// 权限拒绝/初始化失败视图与重试。
/// 平台通道;可注入闪光灯能力/记录翻转释放)+ 内存 TaskStore + 临时目录
/// 准备器 + 非 Android 权限门替身。覆盖:快门即拍即入队、连拍串行、分类
/// 切换仅影响之后拍摄、多选补充入队、待传/失败徽章实时刷新、有待传任务
/// 退出确认、权限拒绝/初始化失败视图与重试、深色界面(#10161E)、快门
/// ≥72dp、闪光灯按能力显示、前后摄切换重建、最近一张缩略图。
library;
import 'dart:io';
@@ -26,6 +27,7 @@ import 'package:photo_live/features/uploads/task_state.dart';
import 'package:photo_live/features/uploads/task_store.dart';
import 'package:photo_live/l10n/app_localizations.dart';
import 'package:photo_live/pages/capture_page.dart';
import 'package:photo_live/ui/tokens.dart';
import '../helpers/fakes.dart';
@@ -33,8 +35,9 @@ const List<int> kJpgBytes = <int>[
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
];
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数。
class FakeCaptureSource implements CaptureSource {
/// 拍摄源替身:capture 依次弹出预置文件;initialize 可注入失败次数;
/// 闪光灯能力可注入(记录开关调用);释放计数供前后切换断言。
class FakeCaptureSource extends CaptureSource {
FakeCaptureSource(this._files);
final List<File> _files;
@@ -42,6 +45,13 @@ class FakeCaptureSource implements CaptureSource {
int failNextInits = 0;
Object? initError;
/// 闪光灯能力(默认不支持——UI 不显示开关)。
bool flashSupported = false;
final List<bool> flashCalls = <bool>[];
/// dispose 计数(前后摄切换释放断言)。
int disposeCalls = 0;
@override
Future<void> initialize() async {
initializeCalls++;
@@ -61,7 +71,14 @@ class FakeCaptureSource implements CaptureSource {
);
@override
Future<void> dispose() async {}
bool get supportsFlash => flashSupported;
@override
Future<void> setFlashEnabled(bool enabled) async =>
flashCalls.add(enabled);
@override
Future<void> dispose() async => disposeCalls++;
}
void main() {
@@ -71,6 +88,9 @@ void main() {
late FakeCaptureSource source;
late List<String> galleryPaths;
/// 工厂调用记录(前后摄切换断言)。
late List<bool> factoryPreferFront;
final categories = <Category>[
const Category(
id: 'cat-1',
@@ -106,11 +126,18 @@ void main() {
final shot3 = File('${tmp.path}/IMG_0003.jpg')..writeAsBytesSync(kJpgBytes);
source = FakeCaptureSource([shot1, shot2, shot3]);
galleryPaths = <String>[];
factoryPreferFront = <bool>[];
});
tearDown(() async {
await store.close();
await tmp.delete(recursive: true);
try {
await tmp.delete(recursive: true);
} on Object {
// Windows:底部缩略图 FileImage 的异步读取可能仍持有临时文件句柄
//(fake-async 区内真实 IO 完成回调不被冲刷)——删除失败不判定
// 业务断言失败,残留目录交由系统临时目录清理。
}
});
CaptureTarget target() => CaptureTarget(
@@ -134,8 +161,12 @@ void main() {
(_) => throw StateError('连拍页不应发起 API 请求')),
),
),
captureSourceFactoryProvider
.overrideWith((ref) => () async => source),
captureSourceFactoryProvider.overrideWith(
(ref) => ({bool preferFront = false}) async {
factoryPreferFront.add(preferFront);
return source;
},
),
uploadPreparerProvider.overrideWith(
(ref) => UploadPreparer(
store,
@@ -188,10 +219,33 @@ void main() {
}
}
testWidgets('快门即拍即入队:任务落库 queued + 待传徽章 1', (tester) async {
testWidgets('深色摄影界面:页面背景 #10161E(不随主题)', (tester) async {
await pumpCapture(tester);
expect(
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
kColorPhotoBackdrop,
reason: '§3 相机界面固定深色背景',
);
});
testWidgets('快门 ≥72dp(§4 页面 13)+ 相机控制齐备', (tester) async {
await pumpCapture(tester);
final shutterSize = tester.getSize(find.byKey(const ValueKey('capture-shutter')));
expect(shutterSize.width, greaterThanOrEqualTo(72));
expect(shutterSize.height, greaterThanOrEqualTo(72));
// 前后摄切换 + 相册入口(缩略图占位)常驻;闪光灯未注入能力不出现。
expect(find.byIcon(Icons.flip_camera_ios_outlined), findsOneWidget);
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
expect(find.byIcon(Icons.flash_off), findsNothing,
reason: '源不支持闪光灯时不显示开关');
});
testWidgets('快门即拍即入队:任务落库 queued + 顶栏待传徽章 1', (tester) async {
await pumpCapture(tester);
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '相机预览就绪');
expect(find.text('Queued 0'), findsOneWidget);
expect(find.text('0 queued'), findsOneWidget);
await shoot(tester);
@@ -202,7 +256,21 @@ void main() {
expect(tasks.single.albumId, 'alb-1');
expect(tasks.single.categoryId, isNull, reason: '默认分类');
expect(tasks.single.filename, 'IMG_0001.jpg');
expect(find.text('Queued 1'), findsOneWidget, reason: '徽章实时刷新');
expect(find.text('1 queued'), findsOneWidget, reason: '徽章实时刷新');
});
testWidgets('最近一张缩略图:拍摄前为相册占位,拍摄后显示照片',
(tester) async {
await pumpCapture(tester);
expect(find.byType(Image), findsNothing, reason: '拍摄前无缩略图');
await shoot(tester);
expect(
find.byType(Image),
findsOneWidget,
reason: '拍摄后底部显示最近一张(缩略图位)',
);
});
testWidgets('入队成功反馈:短暂浮层出现(不逐张弹确认,§4.5)',
@@ -247,7 +315,36 @@ void main() {
'IMG_0001.jpg',
'IMG_0002.jpg',
]));
expect(find.text('Queued 2'), findsOneWidget);
expect(find.text('2 queued'), findsOneWidget);
});
testWidgets('闪光灯:源支持才显示;点击开→关记录调用', (tester) async {
source.flashSupported = true;
await pumpCapture(tester);
expect(find.byIcon(Icons.flash_off), findsOneWidget, reason: '默认关');
await tester.tap(find.byIcon(Icons.flash_off));
await tester.pump();
expect(source.flashCalls, [true]);
expect(find.byIcon(Icons.flash_on), findsOneWidget, reason: '开启后图标翻转');
});
testWidgets('前后摄切换:释放当前源 → 反向重建(工厂收 preferFront)',
(tester) async {
await pumpCapture(tester);
expect(factoryPreferFront, [false]);
await tester.tap(find.byIcon(Icons.flip_camera_ios_outlined));
await tester.pump();
var guard = 0;
while (find.text('PREVIEW_MARK').evaluate().isEmpty && guard++ < 20) {
await tester.pump(const Duration(milliseconds: 50));
}
expect(source.disposeCalls, 1, reason: '旧源已释放(相机生命周期)');
expect(factoryPreferFront, [false, true], reason: '反向重建');
expect(find.text('PREVIEW_MARK'), findsOneWidget, reason: '预览恢复');
});
testWidgets('分类切换:hint 提示 + 仅影响之后拍摄(A15 归属不变)',