feat(mobile): upload queue ui and continuous capture page
Task 9 (P3 plan): queue tab replaces the stub, continuous capture page enqueues per shot, album detail gains an upload FAB, and the upload engine lifecycle is now bound to the login session. - Engine scope: session account drives UploadEngine start/stop; wired from app boot, login success, logout, and 401 eviction. Tasks stay in the store across logout (accountId hard isolation). - Queue tab: album x state x time filter bar, task rows with thumbnail, state chip, localized error copy, attempt count, per-row progress via a broadcast progress hub, state-dependent actions (re-pick retry, retryNow, cancel, delete), batch retry bar, 200ms-debounced live refresh from drift table updates. - Capture page: camera preview via injected CaptureSource, one enqueue per shutter press, persistent album/category/badges overlay with a switch-only-affects-new-shots hint, gallery multi-pick, exit confirm while uploads are pending, permission-denied and init-failure views. - POST_NOTIFICATIONS runtime permission via a small MethodChannel (photolive/permissions) + PermissionPlugin.kt: single runtime permission need, avoids permission_handler dependency; requested once on first enqueue, silent degradation when denied or unavailable. - Album detail FAB: capture-and-upload / pick-from-gallery sheet. - l10n: ~50 new keys in zh/en arbs; tests: +45 (filter matrix, engine scope lifecycle, permission gate, queue tab, capture page, FAB entry); existing page tests updated to inject session/task-store fakes. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -4,16 +4,31 @@
|
||||
/// 分类管理三流(新增对话框载荷、名称空校验拦截、编辑 PATCH、删除确认)。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart' show XFile;
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/core/api_client.dart';
|
||||
import 'package:photo_live/core/notification_permission.dart';
|
||||
import 'package:photo_live/core/session_store.dart';
|
||||
import 'package:photo_live/features/uploads/enqueue.dart';
|
||||
import 'package:photo_live/features/uploads/preparer.dart';
|
||||
import 'package:photo_live/features/uploads/providers.dart';
|
||||
import 'package:photo_live/features/uploads/task_store.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/pages/album_detail_page.dart';
|
||||
import 'package:photo_live/pages/capture_page.dart';
|
||||
|
||||
import '../helpers/fakes.dart';
|
||||
|
||||
/// JPEG 魔数最小字节(入队准备器真实文件驱动)。
|
||||
const List<int> kJpgBytes = <int>[
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore store;
|
||||
late List<Map<String, dynamic>> categories;
|
||||
@@ -55,7 +70,11 @@ void main() {
|
||||
Future<void> pumpDetail(WidgetTester tester, ApiClient client) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [apiClientProvider.overrideWith((ref) => client)],
|
||||
// Task 9 起详情页有上传 FAB(触达 sessionAccountProvider),
|
||||
// 真实平台通道在测试环境挂起,必须注入替身。
|
||||
overrides: [
|
||||
...pageTestOverrides(session: store, client: client),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
@@ -79,7 +98,8 @@ void main() {
|
||||
);
|
||||
expect(find.text('sharetoken000000000001'), findsOneWidget);
|
||||
expect(find.text('大宴会厅'), findsOneWidget);
|
||||
expect(find.text('Upload'), findsOneWidget);
|
||||
// 「Upload」出现两次:开关标签 chip + Task 9 上传 FAB 文案。
|
||||
expect(find.text('Upload'), findsNWidgets(2));
|
||||
expect(find.text('Original'), findsOneWidget);
|
||||
// 动作行(5 项)。
|
||||
expect(find.text('Edit'), findsOneWidget);
|
||||
@@ -266,4 +286,131 @@ void main() {
|
||||
expect(find.text('仪式 · Ceremony'), findsNothing);
|
||||
expect(find.text('No categories yet. Tap + to add one'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('上传入口 FAB(Task 9)', () {
|
||||
late Directory tmp;
|
||||
late List<String> galleryPaths;
|
||||
late TaskStore taskStore;
|
||||
|
||||
setUp(() async {
|
||||
store.session = Session(
|
||||
token: 'tok-1',
|
||||
expiresAt: DateTime.utc(2099, 1, 1),
|
||||
account: 'alice',
|
||||
);
|
||||
tmp = await Directory.systemTemp.createTemp('detail-upload-');
|
||||
galleryPaths = <String>[];
|
||||
taskStore = TaskStore.memory();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await taskStore.close();
|
||||
await tmp.delete(recursive: true);
|
||||
});
|
||||
|
||||
/// 详情页 + 上传链路替身(内存任务库/临时目录准备器/多选替身/
|
||||
/// 非平台相机源)。
|
||||
Future<void> pumpDetailForUpload(WidgetTester tester) async {
|
||||
final adapter = detailAdapter();
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
...pageTestOverrides(
|
||||
session: store,
|
||||
taskStore: taskStore,
|
||||
client: clientOf(adapter),
|
||||
),
|
||||
multiImagePickerProvider
|
||||
.overrideWith((ref) => () async => galleryPaths),
|
||||
uploadPreparerProvider.overrideWith(
|
||||
(ref) => UploadPreparer(
|
||||
taskStore,
|
||||
baseDirProvider: () async => tmp,
|
||||
),
|
||||
),
|
||||
notificationPermissionProvider.overrideWith(
|
||||
(ref) => NotificationPermission(isAndroid: false),
|
||||
),
|
||||
captureSourceFactoryProvider
|
||||
.overrideWith((ref) => () async => _StaticCaptureSource()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const AlbumDetailPage(albumId: 'alb-1'),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('FAB → 底部弹层双入口(拍照上传 / 从相册选择)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Capture and upload'), findsOneWidget);
|
||||
expect(find.text('Pick from gallery'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('拍照上传 → 进入连拍页(目标相册带出)', (tester) async {
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Capture and upload'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(CapturePage), findsOneWidget);
|
||||
// 连拍页常驻信息层显示目标相册名(与详情标题同源:默认 zh 主标题)。
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byType(CapturePage),
|
||||
matching: find.text('开幕晚宴'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('从相册选择 → 多选入队 + 入队数提示', (tester) async {
|
||||
final pick1 = File('${tmp.path}/pick1.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
final pick2 = File('${tmp.path}/pick2.jpg')..writeAsBytesSync(kJpgBytes);
|
||||
galleryPaths.addAll([pick1.path, pick2.path]);
|
||||
await pumpDetailForUpload(tester);
|
||||
|
||||
await tester.tap(find.byType(FloatingActionButton));
|
||||
await tester.pumpAndSettle();
|
||||
// 入队链路含真实文件 IO:真实事件循环内触发行内动作。
|
||||
final tile = find.widgetWithText(ListTile, 'Pick from gallery');
|
||||
await tester.runAsync(() async {
|
||||
(tile.evaluate().single.widget as ListTile).onTap!();
|
||||
await Future<void>.delayed(const Duration(milliseconds: 300));
|
||||
});
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text('Added 2 photo(s) to the upload queue'),
|
||||
findsOneWidget,
|
||||
);
|
||||
final tasks = await taskStore.listByAccount('alice');
|
||||
expect(tasks, hasLength(2));
|
||||
expect(tasks.every((t) => t.albumId == 'alb-1'), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 相机源替身(详情页跳连拍页测试:仅就绪,不拍摄)。
|
||||
class _StaticCaptureSource implements CaptureSource {
|
||||
@override
|
||||
Future<void> initialize() async {}
|
||||
|
||||
@override
|
||||
Future<XFile> capture() => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Widget buildPreview() => const SizedBox();
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user