diff --git a/lib/app.dart b/lib/app.dart index f0d10d8..5c292c7 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -33,6 +33,7 @@ import 'core/data/services/favorite_controller.dart'; import 'core/data/services/interaction_controller.dart'; import 'core/data/services/playback_session.dart'; import 'core/data/services/video_player_port.dart'; +import 'core/platform/haptics.dart'; import 'core/theme/app_theme.dart'; import 'l10n/app_localizations.dart'; import 'pages/home/home_view.dart'; @@ -49,6 +50,7 @@ class LumaDramaApp extends StatefulWidget { this.playbackRepository, this.commentRepository, this.interactionRepository, + this.haptics = const FlutterHaptics(), }); final PlayerFactory playerFactory; @@ -62,6 +64,7 @@ class LumaDramaApp extends StatefulWidget { final PlaybackRepository? playbackRepository; final CommentRepository? commentRepository; final InteractionRepository? interactionRepository; + final Haptics haptics; @override State createState() => _LumaDramaAppState(); @@ -176,6 +179,7 @@ class _LumaDramaAppState extends State { Provider.value(value: _favorites), Provider.value(value: _commentRepository), Provider.value(value: _interactionRepository), + Provider.value(value: widget.haptics), ChangeNotifierProvider( create: (_) => CatalogController(_catalogRepository)..load(), ), diff --git a/lib/core/platform/haptics.dart b/lib/core/platform/haptics.dart new file mode 100644 index 0000000..cbdbd17 --- /dev/null +++ b/lib/core/platform/haptics.dart @@ -0,0 +1,39 @@ +// Author: fruit.com.cn + +import 'package:flutter/services.dart'; + +/// User-action haptic feedback boundary. +abstract interface class Haptics { + /// Feedback for a selection change. + Future selectionClick(); + + /// Feedback for a primary action. + Future lightImpact(); + + /// Feedback for a destructive action. + Future mediumImpact(); +} + +/// Default implementation backed by Flutter's platform haptics. +class FlutterHaptics implements Haptics { + const FlutterHaptics(); + + @override + Future selectionClick() => + _ignoreUnavailable(HapticFeedback.selectionClick); + + @override + Future lightImpact() => _ignoreUnavailable(HapticFeedback.lightImpact); + + @override + Future mediumImpact() => + _ignoreUnavailable(HapticFeedback.mediumImpact); + + Future _ignoreUnavailable(Future Function() feedback) async { + try { + await feedback(); + } catch (_) { + // Haptics are optional and may be unavailable on the target device. + } + } +} diff --git a/lib/core/widgets/download_action.dart b/lib/core/widgets/download_action.dart index 6c3a356..6da727b 100644 --- a/lib/core/widgets/download_action.dart +++ b/lib/core/widgets/download_action.dart @@ -1,10 +1,13 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../l10n/app_localizations.dart'; import '../data/services/download_controller.dart'; +import '../platform/haptics.dart'; import 'auth_gate.dart'; /// 详情页与播放页共用的下载授权入口。 @@ -16,6 +19,7 @@ Future requestEpisodeDownload( ) async { if (!await ensureSignedIn(context)) return; if (!context.mounted) return; + unawaited(context.read().lightImpact()); final result = await context.read().grant(episodeId); if (!context.mounted) return; final l10n = AppLocalizations.of(context)!; diff --git a/lib/pages/auth/auth_view.dart b/lib/pages/auth/auth_view.dart index 7e9c07f..af6bdc2 100644 --- a/lib/pages/auth/auth_view.dart +++ b/lib/pages/auth/auth_view.dart @@ -1,10 +1,13 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../core/data/remote/api_client.dart'; import '../../core/data/services/auth_controller.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../l10n/app_localizations.dart'; @@ -71,6 +74,7 @@ class _AuthPageState extends State { setState(() => _logic.errorMessage = _errorText(l10n, invalid)); return; } + unawaited(context.read().lightImpact()); FocusScope.of(context).unfocus(); setState(() { _logic.busy = true; diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index f1228d9..4247658 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -11,6 +11,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/download_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_date_time.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -166,6 +167,7 @@ class _DownloadsBody extends StatelessWidget { } Future _remove(BuildContext context, String episodeId) async { + unawaited(context.read().mediumImpact()); final ok = await downloads.remove(episodeId); if (ok || !context.mounted) return; _showMessage(context, AppLocalizations.of(context)!.downloadDeleteFailed); @@ -191,6 +193,9 @@ class _DownloadsBody extends StatelessWidget { ), ); if (confirmed != true) return; + if (context.mounted) { + unawaited(context.read().mediumImpact()); + } final ok = await downloads.clear(); if (!ok && context.mounted) { _showMessage(context, l10n.downloadClearFailed); diff --git a/lib/pages/drama_detail/drama_detail_view.dart b/lib/pages/drama_detail/drama_detail_view.dart index 62d0ed8..f52b8de 100644 --- a/lib/pages/drama_detail/drama_detail_view.dart +++ b/lib/pages/drama_detail/drama_detail_view.dart @@ -11,6 +11,7 @@ import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/interaction_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/remote_image.dart'; @@ -315,6 +316,7 @@ class _DramaDetailPageState extends State { if (!await ensureSignedIn(context) || !mounted) return; final draft = await showReportDialog(context, title: l10n.reportDrama); if (draft == null || !mounted) return; + unawaited(context.read().lightImpact()); final result = await context.read().reportDrama( logic.drama.id, reason: draft.reason, @@ -341,6 +343,8 @@ class _DramaDetailPageState extends State { icon: Icon(saved ? Icons.bookmark : Icons.bookmark_border), onPressed: () async { if (!await ensureSignedIn(context)) return; + if (!context.mounted) return; + unawaited(context.read().lightImpact()); final ok = await logic.toggleFavorite(); if (ok || !context.mounted) return; // 成功靠图标变化反馈;失败必须说清楚(乐观更新已回滚) diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index 62b7c54..7033488 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -9,6 +9,7 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/favorite_controller.dart'; import '../../core/formatting/error_text.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/library_drama_tile.dart'; @@ -142,6 +143,7 @@ class _FavoritesBody extends StatelessWidget { } Future _remove(BuildContext context, String dramaId) async { + unawaited(context.read().lightImpact()); final ok = await favorites.remove(dramaId); if (ok || !context.mounted) return; final l10n = AppLocalizations.of(context)!; diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 53d6147..e19b838 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -1,5 +1,7 @@ // Author: fruit.com.cn +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +10,7 @@ import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/catalog_controller.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -203,7 +206,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.home_rounded, label: l10n.homeTab, selected: logic.tab == 1, - onTap: () => logic.select(1), + onTap: () => _selectTab(context, 1), ), _TabButton( key: const Key('tab-home'), @@ -211,7 +214,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.movie_rounded, label: l10n.shortsTab, selected: logic.tab == 0, - onTap: () => logic.select(0), + onTap: () => _selectTab(context, 0), ), _TabButton( key: const Key('tab-my-list'), @@ -219,7 +222,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.bookmark_rounded, label: l10n.myListTab, selected: logic.tab == 2 && !logic.profile, - onTap: logic.openLibrary, + onTap: () => _selectTab(context, 2), ), _TabButton( key: const Key('tab-my'), @@ -227,7 +230,7 @@ class _HomeTabsState extends State<_HomeTabs> { selectedIcon: Icons.person_rounded, label: l10n.myTab, selected: logic.tab == 2 && logic.profile, - onTap: logic.openProfile, + onTap: () => _selectTab(context, 2, myProfile: true), ), ], ), @@ -236,6 +239,20 @@ class _HomeTabsState extends State<_HomeTabs> { ), ); } + + void _selectTab(BuildContext context, int tab, {bool myProfile = false}) { + final same = tab == 2 + ? logic.tab == 2 && logic.profile == myProfile + : logic.tab == tab; + if (!same) { + unawaited(context.read().selectionClick()); + } + if (tab == 2) { + myProfile ? logic.openProfile() : logic.openLibrary(); + } else { + logic.select(tab); + } + } } class _TabButton extends StatelessWidget { diff --git a/lib/pages/player/player_view.dart b/lib/pages/player/player_view.dart index 11e4a93..63fafb8 100644 --- a/lib/pages/player/player_view.dart +++ b/lib/pages/player/player_view.dart @@ -15,6 +15,7 @@ import '../../core/data/services/playback_session.dart'; import '../../core/data/services/video_player_port.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_content.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -350,6 +351,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { // 收藏/点赞要对到具体账号:未登录先引导登录(首页/详情仍可匿名浏览) onLike: () async { if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); final result = await interactions.toggleDramaLike(drama.id); if (!result.isSuccess && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -366,6 +369,8 @@ class _PlayerPageState extends State with WidgetsBindingObserver { onSave: () async { final favorites = context.read(); if (await ensureSignedIn(context)) { + if (!context.mounted) return; + unawaited(context.read().lightImpact()); final ok = await favorites.toggle(drama.id, card: drama); if (ok || !context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/pages/player/widgets/drama_comments_sheet.dart b/lib/pages/player/widgets/drama_comments_sheet.dart index 708bb4a..363321b 100644 --- a/lib/pages/player/widgets/drama_comments_sheet.dart +++ b/lib/pages/player/widgets/drama_comments_sheet.dart @@ -11,6 +11,7 @@ import '../../../core/data/repositories/comment_repository.dart'; import '../../../core/data/repositories/interaction_repository.dart'; import '../../../core/data/services/comment_controller.dart'; import '../../../core/formatting/error_text.dart'; +import '../../../core/platform/haptics.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/widgets/auth_gate.dart'; import '../../../l10n/app_localizations.dart'; @@ -60,6 +61,7 @@ class _DramaCommentsSheetState extends State { if (_controller.submitting) return; if (_input.text.trim().isEmpty) return; if (!await ensureSignedIn(context) || !mounted) return; + unawaited(context.read().lightImpact()); final result = await _controller.publish(_input.text, replyTo: _replyTo); if (!mounted) return; if (result.isSuccess) { @@ -84,6 +86,7 @@ class _DramaCommentsSheetState extends State { Future _like(DramaComment comment) async { if (!await ensureSignedIn(context) || !mounted) return; + unawaited(context.read().lightImpact()); final result = await _controller.toggleLike(comment); if (!result.isSuccess && mounted) { _showMessage( @@ -116,7 +119,8 @@ class _DramaCommentsSheetState extends State { final result = await _controller.delete(comment); if (!result.isSuccess && mounted) { _showMessage( - result.errorMessage ?? AppLocalizations.of(context)!.commentDeleteFailed, + result.errorMessage ?? + AppLocalizations.of(context)!.commentDeleteFailed, ); } } @@ -126,6 +130,7 @@ class _DramaCommentsSheetState extends State { final l10n = AppLocalizations.of(context)!; final draft = await showReportDialog(context, title: l10n.reportComment); if (draft == null || !mounted) return; + unawaited(context.read().lightImpact()); final result = await _controller.report( comment, reason: draft.reason, diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index be98702..9401963 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -13,6 +13,7 @@ import '../../core/data/services/auth_controller.dart'; import '../../core/data/services/watch_records_controller.dart'; import '../../core/formatting/error_text.dart'; import '../../core/formatting/localized_date_time.dart'; +import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/auth_gate.dart'; @@ -141,6 +142,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } Future _remove(String dramaId) async { + unawaited(context.read().mediumImpact()); final ok = await controller.remove(dramaId); if (!ok && mounted) _showFailure('recordDeleteFailed'); } @@ -164,7 +166,8 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { ], ), ); - if (confirmed != true) return; + if (confirmed != true || !mounted) return; + unawaited(context.read().mediumImpact()); final ok = await controller.clear(); if (!ok && mounted) _showFailure('recordClearFailed'); } diff --git a/test/auth/auth_page_test.dart b/test/auth/auth_page_test.dart index dc5fd08..ffa24e0 100644 --- a/test/auth/auth_page_test.dart +++ b/test/auth/auth_page_test.dart @@ -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.value( - value: auth, + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: auth), + Provider.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(find.byKey(const Key('auth-email'))).enabled, isFalse, diff --git a/test/comment/drama_comments_sheet_test.dart b/test/comment/drama_comments_sheet_test.dart index f876764..3cadf2d 100644 --- a/test/comment/drama_comments_sheet_test.dart +++ b/test/comment/drama_comments_sheet_test.dart @@ -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( - find.byKey(const Key('comment-input')), - ); - expect(field.controller?.text, 'blocked text'); - }); + expect(find.text('评论包含敏感词'), findsOneWidget); + final field = tester.widget( + 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( 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 _pumpSheet( WidgetTester tester, { required FakeCommentRepository repository, FakeInteractionRepository? interactions, + FakeHaptics? haptics, }) async { final auth = await _auth(); await tester.pumpWidget( @@ -325,6 +331,7 @@ Future _pumpSheet( value: interactions ?? FakeInteractionRepository(), ), ChangeNotifierProvider.value(value: auth), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/core/haptics_scan_test.dart b/test/core/haptics_scan_test.dart new file mode 100644 index 0000000..f8a448c --- /dev/null +++ b/test/core/haptics_scan_test.dart @@ -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 = []; + 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')); + }); +} diff --git a/test/core/haptics_test.dart b/test/core/haptics_test.dart new file mode 100644 index 0000000..f876eba --- /dev/null +++ b/test/core/haptics_test.dart @@ -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); + }); +} diff --git a/test/download/downloads_page_test.dart b/test/download/downloads_page_test.dart index fe64f1e..2866d09 100644 --- a/test/download/downloads_page_test.dart +++ b/test/download/downloads_page_test.dart @@ -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 _auth({required bool signedIn}) async { @@ -198,12 +206,14 @@ Future _pumpPage( WidgetTester tester, { required AuthController auth, required DownloadController controller, + FakeHaptics? haptics, }) async { await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: controller), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index 6a9b9f7..4843dc3 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -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 _pumpApp(WidgetTester tester) async { +Future _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 _pumpApp(WidgetTester tester) async { playbackRepository: FakePlaybackRepository(), commentRepository: FakeCommentRepository(), interactionRepository: FakeInteractionRepository(), + haptics: haptics ?? FakeHaptics(), ), ); await tester.pumpAndSettle(); diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index b49548a..8e2b0ff 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -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 _pumpPage( WidgetTester tester, { required AuthController auth, required FavoriteController favorites, + FakeHaptics? haptics, }) async { await tester.pumpWidget( MultiProvider( providers: [ ChangeNotifierProvider.value(value: auth), ChangeNotifierProvider.value(value: favorites), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'), diff --git a/test/support/fake_haptics.dart b/test/support/fake_haptics.dart new file mode 100644 index 0000000..15314a9 --- /dev/null +++ b/test/support/fake_haptics.dart @@ -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 calls = []; + int selectionClicks = 0; + int lightImpacts = 0; + int mediumImpacts = 0; + + void reset() { + calls.clear(); + selectionClicks = 0; + lightImpacts = 0; + mediumImpacts = 0; + } + + @override + Future selectionClick() async { + calls.add('selection'); + selectionClicks++; + } + + @override + Future lightImpact() async { + calls.add('light'); + lightImpacts++; + } + + @override + Future mediumImpact() async { + calls.add('medium'); + mediumImpacts++; + } +} diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index d42ef0e..4510ff0 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -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 _pumpPage( required AuthController auth, required WatchHistoryRepository history, required CatalogRepository catalog, + FakeHaptics? haptics, }) async { await tester.pumpWidget( MultiProvider( @@ -163,6 +169,7 @@ Future _pumpPage( ChangeNotifierProvider.value(value: auth), Provider.value(value: history), Provider.value(value: catalog), + Provider.value(value: haptics ?? FakeHaptics()), ], child: MaterialApp( locale: const Locale('en'),