feat(mobile): UI-01 theme system, shared components and navigation
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
/// EmptyState widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:图标 + 标题 + 说明 + 可选操作按钮全量渲染;操作回调触发;
|
||||
/// 无说明/无按钮时不占位。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/empty_state.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpState(
|
||||
WidgetTester tester, {
|
||||
String? subtitle,
|
||||
String? actionLabel,
|
||||
VoidCallback? onAction,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: EmptyState(
|
||||
icon: Icons.photo_library_outlined,
|
||||
title: 'No albums yet',
|
||||
subtitle: subtitle,
|
||||
actionLabel: actionLabel,
|
||||
onAction: onAction,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('渲染图标 + 标题 + 说明 + 操作按钮', (tester) async {
|
||||
var taps = 0;
|
||||
await pumpState(
|
||||
tester,
|
||||
subtitle: 'Create one to get started',
|
||||
actionLabel: 'Create album',
|
||||
onAction: () => taps++,
|
||||
);
|
||||
|
||||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||||
expect(find.text('No albums yet'), findsOneWidget);
|
||||
expect(find.text('Create one to get started'), findsOneWidget);
|
||||
expect(find.text('Create album'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('Create album'));
|
||||
expect(taps, 1, reason: '操作回调触发');
|
||||
});
|
||||
|
||||
testWidgets('无说明/无按钮 → 仅图标与标题', (tester) async {
|
||||
await pumpState(tester);
|
||||
|
||||
expect(find.byIcon(Icons.photo_library_outlined), findsOneWidget);
|
||||
expect(find.text('No albums yet'), findsOneWidget);
|
||||
expect(find.byType(FilledButton), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/// ErrorState widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:错误图标 + 说明 + 重试按钮(主强调,与 EmptyState 区分);
|
||||
/// 重试回调触发。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/l10n/app_localizations.dart';
|
||||
import 'package:photo_live/ui/components/error_state.dart';
|
||||
import 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpState(WidgetTester tester, VoidCallback onRetry) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: ErrorState(
|
||||
message: 'Network error. Please retry',
|
||||
onRetry: onRetry,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('渲染错误图标 + 说明 + 重试按钮,回调触发', (tester) async {
|
||||
var retries = 0;
|
||||
await pumpState(tester, () => retries++);
|
||||
|
||||
expect(find.byIcon(Icons.cloud_off), findsOneWidget, reason: '错误语义图标');
|
||||
expect(find.text('Network error. Please retry'), findsOneWidget);
|
||||
final retryButton = find.widgetWithText(FilledButton, 'Retry');
|
||||
expect(retryButton, findsOneWidget, reason: '重试为主强调按钮');
|
||||
expect(find.byIcon(Icons.refresh), findsOneWidget);
|
||||
|
||||
await tester.tap(retryButton);
|
||||
expect(retries, 1, reason: '重试回调触发');
|
||||
});
|
||||
|
||||
testWidgets('深色主题错误图标用提亮变体', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.dark,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const Scaffold(
|
||||
body: ErrorState(message: 'boom', onRetry: _noop),
|
||||
),
|
||||
),
|
||||
);
|
||||
final icon = tester.widget<Icon>(find.byIcon(Icons.cloud_off));
|
||||
expect(icon.color, kColorErrorDark);
|
||||
});
|
||||
}
|
||||
|
||||
void _noop() {}
|
||||
@@ -0,0 +1,196 @@
|
||||
/// PhotoThumbnail widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:无 URL 常驻中性占位(固定宽高比)、加载中立即显示占位(非
|
||||
/// 转圈,网格布局稳定)、失败替代图(中性块 + 小图标,不显示破图)、
|
||||
/// 加载成功显示图片(HttpOverrides 注入 1x1 PNG)。
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/photo_thumbnail.dart';
|
||||
|
||||
/// 1x1 透明 PNG(真实可解码字节)。
|
||||
final Uint8List kPngBytes = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ'
|
||||
'AAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
|
||||
);
|
||||
|
||||
void main() {
|
||||
Future<void> pumpThumb(
|
||||
WidgetTester tester, {
|
||||
String? url,
|
||||
double aspectRatio = 16 / 9,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: PhotoThumbnail(url: url, aspectRatio: aspectRatio),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('url 为 null → 常驻中性占位(固定宽高比)', (tester) async {
|
||||
await pumpThumb(tester);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsNothing);
|
||||
|
||||
// 固定宽高比:视口 800x600 → 800 x 450。
|
||||
final size = tester.getSize(find.byType(PhotoThumbnail));
|
||||
expect(size.width, 800);
|
||||
expect(size.height, 450);
|
||||
});
|
||||
|
||||
testWidgets('加载中 → 立即占位;失败 → 中性块 + 小图标(不显示破图)', (tester) async {
|
||||
// 测试环境默认 HttpClient 一律 400 → NetworkImage 失败路径。
|
||||
await pumpThumb(tester, url: 'http://test.local/cover.jpg');
|
||||
|
||||
// 首帧立即显示占位(非转圈;布局稳定)。
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsOneWidget);
|
||||
expect(find.byIcon(Icons.image_outlined), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('加载成功 → 图片显示、占位消失', (tester) async {
|
||||
// debug 钩子注入假 HttpClient:以真实 1x1 PNG 字节应答全部 GET
|
||||
// (覆盖 flutter_test 默认的网络 400 mock)。测试体内复位——painting
|
||||
// 调试变量须在框架不变量校验前归位(addTearDown 时机过晚)。
|
||||
debugNetworkImageHttpClientProvider = _FakeHttpClient.new;
|
||||
try {
|
||||
await pumpThumb(tester, url: 'http://test.local/cover.png');
|
||||
|
||||
// 立即占位(帧未 ready)。
|
||||
expect(find.byIcon(Icons.image_outlined), findsOneWidget);
|
||||
|
||||
// 真实网络字节 + dart:ui 解码在真实事件循环中完成(fake-async 需要
|
||||
// runAsync 放行)。
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 200)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byIcon(Icons.image_outlined),
|
||||
findsNothing,
|
||||
reason: '加载完成后占位消失',
|
||||
);
|
||||
expect(find.byIcon(Icons.broken_image_outlined), findsNothing);
|
||||
final image = tester.widget<Image>(find.byType(Image));
|
||||
expect(image.image, isA<NetworkImage>());
|
||||
} finally {
|
||||
debugNetworkImageHttpClientProvider = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 以真实 1x1 PNG 字节应答全部 GET 的 HttpClient 替身。
|
||||
class _FakeHttpClient implements HttpClient {
|
||||
@override
|
||||
bool autoUncompress = true;
|
||||
|
||||
@override
|
||||
Future<HttpClientRequest> getUrl(Uri url) async => _FakeHttpClientRequest();
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
|
||||
class _FakeHttpClientRequest implements HttpClientRequest {
|
||||
final _FakeHttpHeaders _headers = _FakeHttpHeaders();
|
||||
|
||||
@override
|
||||
HttpHeaders get headers => _headers;
|
||||
|
||||
@override
|
||||
String get method => 'GET';
|
||||
|
||||
@override
|
||||
Future<HttpClientResponse> close() async => _FakeHttpClientResponse();
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
|
||||
class _FakeHttpClientResponse extends Stream<List<int>>
|
||||
implements HttpClientResponse {
|
||||
late final Stream<List<int>> _stream = Stream<List<int>>.value(kPngBytes);
|
||||
|
||||
@override
|
||||
StreamSubscription<List<int>> listen(
|
||||
void Function(List<int> event)? onData, {
|
||||
Function? onError,
|
||||
void Function()? onDone,
|
||||
bool? cancelOnError,
|
||||
}) => _stream.listen(
|
||||
onData,
|
||||
onError: onError,
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError,
|
||||
);
|
||||
|
||||
@override
|
||||
int get statusCode => HttpStatus.ok;
|
||||
|
||||
@override
|
||||
int get contentLength => kPngBytes.length;
|
||||
|
||||
@override
|
||||
bool get isRedirect => false;
|
||||
|
||||
@override
|
||||
bool get persistentConnection => true;
|
||||
|
||||
@override
|
||||
String get reasonPhrase => 'OK';
|
||||
|
||||
@override
|
||||
HttpHeaders get headers => _FakeHttpHeaders();
|
||||
|
||||
@override
|
||||
HttpClientResponseCompressionState get compressionState =>
|
||||
HttpClientResponseCompressionState.notCompressed;
|
||||
|
||||
@override
|
||||
List<Cookie> get cookies => <Cookie>[];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
|
||||
class _FakeHttpHeaders implements HttpHeaders {
|
||||
final Map<String, String> _values = <String, String>{};
|
||||
|
||||
@override
|
||||
String? value(String field) => _values[field.toLowerCase()];
|
||||
|
||||
@override
|
||||
void add(String name, Object value, {bool preserveHeaderCase = false}) {
|
||||
_values[name.toLowerCase()] = value.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void set(String name, Object value, {bool preserveHeaderCase = false}) {
|
||||
_values[name.toLowerCase()] = value.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) =>
|
||||
throw UnimplementedError('${invocation.memberName}');
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/// SectionHeader widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:分组标题渲染(titleMedium 字阶)、可选行尾动作渲染/缺省。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/section_header.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('渲染标题与可选行尾动作', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light,
|
||||
home: const Scaffold(
|
||||
body: SectionHeader(title: '活动信息', trailing: Text('查看全部')),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final title = tester.widget<Text>(find.text('活动信息'));
|
||||
expect(title.style!.fontSize, 17, reason: '分组标题字阶 17(titleMedium)');
|
||||
expect(find.text('查看全部'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('无行尾动作 → 仅标题', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(body: SectionHeader(title: '活动信息')),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('活动信息'), findsOneWidget);
|
||||
expect(find.byType(TextButton), findsNothing);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/// StateBadge widget 测试(UI-01 Task 3)。
|
||||
///
|
||||
/// 覆盖:五个语义变体均渲染图标 + 文案 + 语义色(不只靠颜色)、
|
||||
/// compact/normal 两档高度、深色主题取提亮变体。
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:photo_live/app/theme.dart';
|
||||
import 'package:photo_live/ui/components/state_badge.dart';
|
||||
import 'package:photo_live/ui/tokens.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpBadge(
|
||||
WidgetTester tester, {
|
||||
required BadgeVariant variant,
|
||||
bool compact = false,
|
||||
bool dark = false,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: dark ? AppTheme.dark : AppTheme.light,
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: StateBadge(
|
||||
variant: variant,
|
||||
label: 'badge-label',
|
||||
compact: compact,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Finder containerOf() => find
|
||||
.descendant(of: find.byType(StateBadge), matching: find.byType(Container))
|
||||
.first;
|
||||
|
||||
for (final entry in <(BadgeVariant, IconData, Color)>[
|
||||
(BadgeVariant.success, Icons.check_circle, kColorSuccess),
|
||||
(BadgeVariant.warning, Icons.hourglass_top, kColorWaiting),
|
||||
(BadgeVariant.error, Icons.error_outline, kColorError),
|
||||
(BadgeVariant.neutral, Icons.remove_circle_outline, kColorTextSecondary),
|
||||
(BadgeVariant.info, Icons.info_outline, kColorInfo),
|
||||
]) {
|
||||
final (variant, icon, color) = entry;
|
||||
testWidgets('${variant.name}:图标 + 文案 + 语义色', (tester) async {
|
||||
await pumpBadge(tester, variant: variant);
|
||||
|
||||
expect(find.byIcon(icon), findsOneWidget, reason: '内置语义图标');
|
||||
expect(find.text('badge-label'), findsOneWidget, reason: '状态文案');
|
||||
expect(find.byType(StateBadge), findsOneWidget);
|
||||
|
||||
final container = tester.widget<Container>(containerOf());
|
||||
final decoration = container.decoration as BoxDecoration;
|
||||
expect(
|
||||
decoration.color,
|
||||
color.withValues(alpha: 0.12),
|
||||
reason: '浅色底:语义色 12% 透明度',
|
||||
);
|
||||
|
||||
final text = tester.widget<Text>(find.text('badge-label'));
|
||||
expect(text.style!.color, color, reason: '文字用语义色');
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('compact 高 24 / normal 高 32', (tester) async {
|
||||
await pumpBadge(tester, variant: BadgeVariant.success, compact: true);
|
||||
expect(tester.getSize(find.byType(StateBadge)).height, 24);
|
||||
|
||||
await pumpBadge(tester, variant: BadgeVariant.success);
|
||||
expect(tester.getSize(find.byType(StateBadge)).height, 32);
|
||||
});
|
||||
|
||||
testWidgets('深色主题取提亮变体(success 例)', (tester) async {
|
||||
await pumpBadge(tester, variant: BadgeVariant.success, dark: true);
|
||||
|
||||
expect(find.byIcon(Icons.check_circle), findsOneWidget);
|
||||
final text = tester.widget<Text>(find.text('badge-label'));
|
||||
expect(text.style!.color, kColorSuccessDark);
|
||||
expect(kColorSuccessDark, isNot(kColorSuccess), reason: '深浅变体不同值');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user