feat(flutter): 增加关键操作震动反馈

This commit is contained in:
Zhou Weidong committed 2026-10-17 16:15:46 +08:00
1 parent e609e3ad8d
commit 8a481d5c62
20 files changed
+308 -72

No files matched your search

+11 -3
View File
@@ -11,10 +11,12 @@ import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/auth/auth_view.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_haptics.dart';
/// 登录/注册页的可读证据:校验提示 + 登录/注册切换 + 未登录时不会去发请求。
void main() {
@@ -27,6 +29,7 @@ void main() {
WidgetTester tester, {
bool signUp = false,
AuthApi? api,
FakeHaptics? haptics,
}) async {
final auth = AuthController(
api ??
@@ -42,8 +45,11 @@ void main() {
AuthSessionStore(),
);
await tester.pumpWidget(
ChangeNotifierProvider<AuthController>.value(
value: auth,
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
@@ -168,7 +174,8 @@ void main() {
tester,
) async {
final api = _PendingAuthApi();
await pumpAuthPage(tester, api: api);
final haptics = FakeHaptics();
await pumpAuthPage(tester, api: api, haptics: haptics);
await tester.enterText(find.byKey(const Key('auth-email')), 'a@b.com');
await tester.enterText(find.byKey(const Key('auth-password')), 'abcd1234');
await tester.tap(find.byKey(const Key('auth-password')));
@@ -176,6 +183,7 @@ void main() {
await tester.pump();
expect(api.loginCalls, 1);
expect(haptics.lightImpacts, 1);
expect(
tester.widget<TextField>(find.byKey(const Key('auth-email'))).enabled,
isFalse,
+41 -34
View File
@@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/comment_repository.dart';
import 'package:fruitshorts/core/data/repositories/interaction_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/player/widgets/drama_comments_sheet.dart';
import 'package:provider/provider.dart';
@@ -18,6 +19,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
const _drama = Drama(
@@ -66,28 +68,29 @@ void main() {
expect(repository.pageCalls.last.cursor, '10');
});
testWidgets('should_keep_input_and_show_backend_message_when_comment_is_blocked', (
tester,
) async {
final repository = FakeCommentRepository(
pages: [const CommentPage()],
publishError: const ApiException(400, '评论包含敏感词'),
);
testWidgets(
'should_keep_input_and_show_backend_message_when_comment_is_blocked',
(tester) async {
final repository = FakeCommentRepository(
pages: [const CommentPage()],
publishError: const ApiException(400, '评论包含敏感词'),
);
await _pumpSheet(tester, repository: repository);
await tester.enterText(
find.byKey(const Key('comment-input')),
'blocked text',
);
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
await _pumpSheet(tester, repository: repository);
await tester.enterText(
find.byKey(const Key('comment-input')),
'blocked text',
);
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
expect(find.text('评论包含敏感词'), findsOneWidget);
final field = tester.widget<TextField>(
find.byKey(const Key('comment-input')),
);
expect(field.controller?.text, 'blocked text');
});
expect(find.text('评论包含敏感词'), findsOneWidget);
final field = tester.widget<TextField>(
find.byKey(const Key('comment-input')),
);
expect(field.controller?.text, 'blocked text');
},
);
testWidgets('should_show_review_badge_and_server_replaced_content', (
tester,
@@ -101,8 +104,9 @@ void main() {
mine: true,
),
);
final haptics = FakeHaptics();
await _pumpSheet(tester, repository: repository);
await _pumpSheet(tester, repository: repository, haptics: haptics);
await tester.enterText(
find.byKey(const Key('comment-input')),
'hello badword',
@@ -112,6 +116,7 @@ void main() {
expect(find.text('hello ***'), findsOneWidget);
expect(find.text('Under review'), findsOneWidget);
expect(haptics.lightImpacts, 1);
final field = tester.widget<TextField>(
find.byKey(const Key('comment-input')),
);
@@ -139,10 +144,7 @@ void main() {
await tester.tap(find.byKey(const Key('comment-reply-10')));
await tester.pump();
expect(find.textContaining('Replying to'), findsOneWidget);
await tester.enterText(
find.byKey(const Key('comment-input')),
'My reply',
);
await tester.enterText(find.byKey(const Key('comment-input')), 'My reply');
await tester.tap(find.byKey(const Key('comment-post')));
await tester.pumpAndSettle();
@@ -153,9 +155,7 @@ void main() {
testWidgets('should_show_error_and_retry_when_comment_page_fails', (
tester,
) async {
final repository = FakeCommentRepository(
pageError: StateError('offline'),
);
final repository = FakeCommentRepository(pageError: StateError('offline'));
await _pumpSheet(tester, repository: repository);
@@ -175,16 +175,19 @@ void main() {
);
final interactions = FakeInteractionRepository()
..nextLikeResult = const LikeToggleResult(liked: true, count: 7);
final haptics = FakeHaptics();
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
haptics: haptics,
);
await tester.tap(find.byKey(const Key('comment-like-10')));
await tester.pumpAndSettle();
expect(interactions.likeTargets, ['COMMENT:10']);
expect(haptics.lightImpacts, 1);
expect(find.text('7'), findsOneWidget);
});
@@ -199,11 +202,13 @@ void main() {
],
);
final interactions = FakeInteractionRepository();
final haptics = FakeHaptics();
await _pumpSheet(
tester,
repository: repository,
interactions: interactions,
haptics: haptics,
);
await tester.tap(find.byKey(const Key('comment-menu-10')));
await tester.pumpAndSettle();
@@ -212,9 +217,13 @@ void main() {
await tester.tap(find.byKey(const Key('report-submit')));
await tester.pumpAndSettle();
expect(interactions.reports.single.targetType, InteractionTargetType.comment);
expect(
interactions.reports.single.targetType,
InteractionTargetType.comment,
);
expect(interactions.reports.single.targetId, '10');
expect(interactions.reports.single.reasonCode, 'SPAM');
expect(haptics.lightImpacts, 1);
expect(find.text('Report submitted. Thank you.'), findsOneWidget);
});
@@ -281,11 +290,7 @@ void main() {
pages: [
CommentPage(
comments: [
commentFixture(
id: '10',
content: 'My comment',
mine: true,
),
commentFixture(id: '10', content: 'My comment', mine: true),
],
),
],
@@ -315,6 +320,7 @@ Future<void> _pumpSheet(
WidgetTester tester, {
required FakeCommentRepository repository,
FakeInteractionRepository? interactions,
FakeHaptics? haptics,
}) async {
final auth = await _auth();
await tester.pumpWidget(
@@ -325,6 +331,7 @@ Future<void> _pumpSheet(
value: interactions ?? FakeInteractionRepository(),
),
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('should_keep_haptic_calls_inside_platform_wrapper', () {
final violations = <String>[];
for (final entity in Directory('lib').listSync(recursive: true)) {
if (entity is! File || !entity.path.endsWith('.dart')) continue;
if (entity.path.endsWith('lib/core/platform/haptics.dart')) continue;
final lines = entity.readAsLinesSync();
for (var index = 0; index < lines.length; index++) {
if (lines[index].contains('HapticFeedback.')) {
violations.add('${entity.path}:${index + 1}');
}
}
}
expect(violations, isEmpty, reason: violations.join('\n'));
});
}
+24
View File
@@ -0,0 +1,24 @@
// Author: fruit.com.cn
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('should_ignore_platform_errors_when_haptics_are_unavailable', () async {
const channel = SystemChannels.platform;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(channel, (_) async {
throw PlatformException(code: 'unavailable');
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
const haptics = FlutterHaptics();
await expectLater(haptics.selectionClick(), completes);
await expectLater(haptics.lightImpact(), completes);
await expectLater(haptics.mediumImpact(), completes);
});
}
+36 -26
View File
@@ -10,6 +10,7 @@ import 'package:fruitshorts/core/data/repositories/download_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/download_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/downloads/downloads_page.dart';
import 'package:provider/provider.dart';
@@ -17,20 +18,19 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_haptics.dart';
DownloadPolicy _policy({
bool wifiOnly = true,
String maxQuality = 'AUTO',
}) => DownloadPolicy(
maxPerDevice: 20,
licenseDays: 30,
wifiOnly: wifiOnly,
maxQuality: maxQuality,
maxStorageBytes: 0,
used: 1,
remaining: 19,
expirePolicyText: 'Licenses last 30 days',
);
DownloadPolicy _policy({bool wifiOnly = true, String maxQuality = 'AUTO'}) =>
DownloadPolicy(
maxPerDevice: 20,
licenseDays: 30,
wifiOnly: wifiOnly,
maxQuality: maxQuality,
maxStorageBytes: 0,
used: 1,
remaining: 19,
expirePolicyText: 'Licenses last 30 days',
);
DownloadLicense _license(String episodeId) => DownloadLicense(
licenseId: 'license-$episodeId',
@@ -145,8 +145,14 @@ void main() {
licenses: [_license('1')],
);
final controller = DownloadController(repository);
final haptics = FakeHaptics();
await _pumpPage(tester, auth: auth, controller: controller);
await _pumpPage(
tester,
auth: auth,
controller: controller,
haptics: haptics,
);
await tester.tap(find.byKey(const Key('download-clear')));
await tester.pumpAndSettle();
expect(repository.clearCalls, 0);
@@ -155,23 +161,25 @@ void main() {
await tester.pumpAndSettle();
expect(repository.clearCalls, 1);
expect(haptics.mediumImpacts, 1);
expect(find.byKey(const Key('downloads-empty')), findsOneWidget);
});
testWidgets('should_render_unique_quality_options_when_policy_uses_a_preset', (
tester,
) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(
policy: _policy(maxQuality: '720P'),
);
final controller = DownloadController(repository);
testWidgets(
'should_render_unique_quality_options_when_policy_uses_a_preset',
(tester) async {
final auth = await _auth(signedIn: true);
final repository = FakeDownloadRepository(
policy: _policy(maxQuality: '720P'),
);
final controller = DownloadController(repository);
await _pumpPage(tester, auth: auth, controller: controller);
await _pumpPage(tester, auth: auth, controller: controller);
expect(tester.takeException(), isNull);
expect(find.byKey(const Key('download-quality')), findsOneWidget);
});
expect(tester.takeException(), isNull);
expect(find.byKey(const Key('download-quality')), findsOneWidget);
},
);
}
Future<AuthController> _auth({required bool signedIn}) async {
@@ -198,12 +206,14 @@ Future<void> _pumpPage(
WidgetTester tester, {
required AuthController auth,
required DownloadController controller,
FakeHaptics? haptics,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<DownloadController>.value(value: controller),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
+25 -2
View File
@@ -12,6 +12,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
import '../support/fake_catalog.dart';
import '../support/fake_category.dart';
import '../support/fake_haptics.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/widget_player.dart';
@@ -36,7 +37,9 @@ void main() {
testWidgets('should_theme_four_tabs_and_switch_selected_state_when_tapped', (
tester,
) async {
await _pumpApp(tester);
final haptics = FakeHaptics();
await _pumpApp(tester, haptics);
haptics.reset();
for (final key in _keys) {
expect(find.byKey(key), findsOneWidget);
@@ -73,6 +76,10 @@ void main() {
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(haptics.selectionClicks, 1);
await tester.tap(find.byKey(const Key('tab-discover')));
await tester.pumpAndSettle();
expect(haptics.selectionClicks, 1);
expect(
find.descendant(
of: find.byKey(const Key('tab-discover')),
@@ -100,9 +107,24 @@ void main() {
expect(find.byKey(key), findsNothing);
}
});
testWidgets('should_not_haptic_when_ordinary_list_item_is_opened', (
tester,
) async {
final haptics = FakeHaptics();
await _pumpApp(tester, haptics);
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
haptics.reset();
await tester.tap(find.text('App Language'));
await tester.pumpAndSettle();
expect(haptics.calls, isEmpty);
});
}
Future<void> _pumpApp(WidgetTester tester) async {
Future<void> _pumpApp(WidgetTester tester, [FakeHaptics? haptics]) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
@@ -118,6 +140,7 @@ Future<void> _pumpApp(WidgetTester tester) async {
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
haptics: haptics ?? FakeHaptics(),
),
);
await tester.pumpAndSettle();
+7 -1
View File
@@ -9,6 +9,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/favorite_controller.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/favorites/favorites_page.dart';
import 'package:provider/provider.dart';
@@ -16,6 +17,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_haptics.dart';
Drama _card(String id) => Drama(
id: id,
@@ -58,11 +60,13 @@ void main() {
cards: [_card('signal')],
)..failRemove = true;
final favorites = FavoriteController(repository);
final haptics = FakeHaptics();
await _pumpPage(tester, auth: auth, favorites: favorites);
await _pumpPage(tester, auth: auth, favorites: favorites, haptics: haptics);
await tester.tap(find.byKey(const Key('favorite-remove-signal')));
await tester.pumpAndSettle();
expect(haptics.lightImpacts, 1);
expect(find.text('Drama signal'), findsOneWidget);
expect(
find.text('Could not update favorites. Please retry.'),
@@ -134,12 +138,14 @@ Future<void> _pumpPage(
WidgetTester tester, {
required AuthController auth,
required FavoriteController favorites,
FakeHaptics? haptics,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<FavoriteController>.value(value: favorites),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),
+36
View File
@@ -0,0 +1,36 @@
// Author: fruit.com.cn
import 'package:fruitshorts/core/platform/haptics.dart';
/// Deterministic haptics recorder for widget tests.
class FakeHaptics implements Haptics {
final List<String> calls = [];
int selectionClicks = 0;
int lightImpacts = 0;
int mediumImpacts = 0;
void reset() {
calls.clear();
selectionClicks = 0;
lightImpacts = 0;
mediumImpacts = 0;
}
@override
Future<void> selectionClick() async {
calls.add('selection');
selectionClicks++;
}
@override
Future<void> lightImpact() async {
calls.add('light');
lightImpacts++;
}
@override
Future<void> mediumImpact() async {
calls.add('medium');
mediumImpacts++;
}
}
+7
View File
@@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/platform/haptics.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/watch_records/watch_records_page.dart';
import 'package:provider/provider.dart';
@@ -19,6 +20,7 @@ import 'package:shared_preferences_platform_interface/shared_preferences_async_p
import '../support/fake_catalog.dart';
import '../support/fake_http_adapter.dart';
import '../support/fake_haptics.dart';
void main() {
setUp(() {
@@ -101,12 +103,14 @@ void main() {
(tester) async {
final auth = await _auth(signedIn: true);
final history = CountingWatchHistory([_progress('the-signal-demo')]);
final haptics = FakeHaptics();
await _pumpPage(
tester,
auth: auth,
history: history,
catalog: FakeCatalogRepository(),
haptics: haptics,
);
await tester.tap(find.byKey(const Key('watch-records-clear')));
await tester.pumpAndSettle();
@@ -118,6 +122,7 @@ void main() {
await tester.pumpAndSettle();
expect(history.clearCalls, 1);
expect(haptics.mediumImpacts, 1);
expect(find.text('No watch history yet.'), findsOneWidget);
},
);
@@ -156,6 +161,7 @@ Future<void> _pumpPage(
required AuthController auth,
required WatchHistoryRepository history,
required CatalogRepository catalog,
FakeHaptics? haptics,
}) async {
await tester.pumpWidget(
MultiProvider(
@@ -163,6 +169,7 @@ Future<void> _pumpPage(
ChangeNotifierProvider<AuthController>.value(value: auth),
Provider<WatchHistoryRepository>.value(value: history),
Provider<CatalogRepository>.value(value: catalog),
Provider<Haptics>.value(value: haptics ?? FakeHaptics()),
],
child: MaterialApp(
locale: const Locale('en'),