diff --git a/lib/core/widgets/app_pull_to_refresh.dart b/lib/core/widgets/app_pull_to_refresh.dart new file mode 100644 index 0000000..3cf3d06 --- /dev/null +++ b/lib/core/widgets/app_pull_to_refresh.dart @@ -0,0 +1,163 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../l10n/app_localizations.dart'; +import '../theme/app_colors.dart'; + +const double _maxPullDistance = 96; +const double _refreshThreshold = 56; +const Duration _arrowTurnDuration = Duration(milliseconds: 160); +const Duration _retractDuration = Duration(milliseconds: 200); + +enum _PullState { idle, pull, armed, refreshing } + +/// 带文字状态提示的下拉刷新容器,指示区悬浮在内容上方。 +class AppPullToRefresh extends StatefulWidget { + const AppPullToRefresh({ + super.key, + required this.child, + required this.onRefresh, + this.edgeOffset = 0, + }); + + final Widget child; + final Future Function() onRefresh; + final double edgeOffset; + + @override + State createState() => _AppPullToRefreshState(); +} + +class _AppPullToRefreshState extends State { + _PullState _state = _PullState.idle; + double _pullDistance = 0; + bool _refreshing = false; + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || _refreshing) return false; + if (notification is ScrollStartNotification) { + if (notification.dragDetails != null) _setPullDistance(0); + } else if (notification is OverscrollNotification) { + if (notification.dragDetails != null) { + _setPullDistance(_pullDistance - notification.overscroll); + } + } else if (notification is ScrollEndNotification) { + _release(); + } + return false; + } + + void _setPullDistance(double value) { + final distance = value.clamp(0.0, _maxPullDistance); + final state = distance >= _refreshThreshold + ? _PullState.armed + : distance > 0 + ? _PullState.pull + : _PullState.idle; + if (distance == _pullDistance && state == _state) return; + setState(() { + _pullDistance = distance; + _state = state; + }); + } + + void _release() { + if (_state == _PullState.armed) { + unawaited(_refresh()); + } else if (_state == _PullState.pull) { + setState(() { + _pullDistance = 0; + _state = _PullState.idle; + }); + } + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + setState(() => _state = _PullState.refreshing); + try { + await widget.onRefresh(); + } finally { + await Future.delayed(_retractDuration); + if (mounted) { + setState(() { + _pullDistance = 0; + _state = _PullState.idle; + _refreshing = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: _onScroll, + child: Stack( + fit: StackFit.expand, + children: [ + widget.child, + if (_state != _PullState.idle) + Positioned( + top: widget.edgeOffset, + left: 0, + right: 0, + child: IgnorePointer(child: _indicator(context)), + ), + ], + ), + ); + } + + Widget _indicator(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + if (_state == _PullState.refreshing) { + return _label( + const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.blue, + ), + ), + l10n.refreshingLabel, + ); + } + final armed = _state == _PullState.armed; + return _label( + AnimatedRotation( + turns: armed ? 0.5 : 0, + duration: _arrowTurnDuration, + child: Icon( + Icons.arrow_downward_rounded, + size: 18, + color: armed ? AppColors.blue : AppColors.muted, + ), + ), + armed ? l10n.releaseToRefresh : l10n.pullToRefresh, + emphasized: armed, + ); + } + + Widget _label(Widget icon, String text, {bool emphasized = false}) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + const SizedBox(width: 6), + Text( + text, + style: TextStyle( + color: emphasized ? AppColors.blue : AppColors.muted, + fontSize: 12, + fontWeight: emphasized ? FontWeight.w700 : FontWeight.w500, + ), + ), + ], + ); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b07b940..43dca2a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -338,6 +338,9 @@ } }, "retry": "Retry", + "pullToRefresh": "Pull to refresh", + "releaseToRefresh": "Release to refresh", + "refreshingLabel": "Refreshing…", "backToSeries": "Back to series", "nextEpisode": "Next episode", "pause": "Pause", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 42a50b1..38c5f27 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -312,6 +312,9 @@ } }, "retry": "Coba lagi", + "pullToRefresh": "Tarik untuk menyegarkan", + "releaseToRefresh": "Lepaskan untuk menyegarkan", + "refreshingLabel": "Menyegarkan…", "backToSeries": "Kembali ke serial", "nextEpisode": "Episode berikutnya", "pause": "Jeda", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d217e85..1247720 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1336,6 +1336,24 @@ abstract class AppLocalizations { /// **'Retry'** String get retry; + /// No description provided for @pullToRefresh. + /// + /// In en, this message translates to: + /// **'Pull to refresh'** + String get pullToRefresh; + + /// No description provided for @releaseToRefresh. + /// + /// In en, this message translates to: + /// **'Release to refresh'** + String get releaseToRefresh; + + /// No description provided for @refreshingLabel. + /// + /// In en, this message translates to: + /// **'Refreshing…'** + String get refreshingLabel; + /// No description provided for @backToSeries. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 97f5a2a..af56d36 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -676,6 +676,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get retry => 'Retry'; + @override + String get pullToRefresh => 'Pull to refresh'; + + @override + String get releaseToRefresh => 'Release to refresh'; + + @override + String get refreshingLabel => 'Refreshing…'; + @override String get backToSeries => 'Back to series'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 2991e04..a8627ff 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -679,6 +679,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get retry => 'Coba lagi'; + @override + String get pullToRefresh => 'Tarik untuk menyegarkan'; + + @override + String get releaseToRefresh => 'Lepaskan untuk menyegarkan'; + + @override + String get refreshingLabel => 'Menyegarkan…'; + @override String get backToSeries => 'Kembali ke serial'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 6097883..923626c 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -668,6 +668,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get retry => '重试'; + @override + String get pullToRefresh => '下拉刷新'; + + @override + String get releaseToRefresh => '松手刷新'; + + @override + String get refreshingLabel => '正在刷新…'; + @override String get backToSeries => '返回剧集详情'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 8da3fa4..efd2b33 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -274,6 +274,9 @@ } }, "retry": "重试", + "pullToRefresh": "下拉刷新", + "releaseToRefresh": "松手刷新", + "refreshingLabel": "正在刷新…", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", "pause": "暂停", diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index cb68f9d..606622a 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -14,6 +14,7 @@ import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; import '../../core/widgets/app_bottom_nav.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../filter/filter_page.dart'; @@ -152,11 +153,8 @@ class _DiscoverPageState extends State { child: Stack( children: [ Positioned.fill( - child: RefreshIndicator( + child: AppPullToRefresh( key: const Key('discover-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, - strokeWidth: 2, edgeOffset: _stickyHeaderHeight + 8, onRefresh: categories.load, child: ListView( diff --git a/lib/pages/downloads/downloads_page.dart b/lib/pages/downloads/downloads_page.dart index 84e839a..f3e6997 100644 --- a/lib/pages/downloads/downloads_page.dart +++ b/lib/pages/downloads/downloads_page.dart @@ -16,6 +16,7 @@ import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; @@ -90,9 +91,10 @@ class _DownloadsBody extends StatelessWidget { ); } final policy = downloads.policy!; - return RefreshIndicator( + return AppPullToRefresh( onRefresh: downloads.load, child: ListView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 10, 16, 28), children: [ _PolicyPanel(downloads: downloads, policy: policy), diff --git a/lib/pages/favorites/favorites_page.dart b/lib/pages/favorites/favorites_page.dart index b5be3b2..ee45a08 100644 --- a/lib/pages/favorites/favorites_page.dart +++ b/lib/pages/favorites/favorites_page.dart @@ -13,6 +13,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/remote_image.dart'; @@ -163,10 +164,7 @@ class _FavoritesBody extends StatelessWidget { FavoriteController favorites, { required List children, }) { - return RefreshIndicator( - color: AppColors.blue, - backgroundColor: AppColors.card, - strokeWidth: 2, + return AppPullToRefresh( onRefresh: favorites.load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), diff --git a/lib/pages/home/home_browse_view.dart b/lib/pages/home/home_browse_view.dart index b6ffc64..6924045 100644 --- a/lib/pages/home/home_browse_view.dart +++ b/lib/pages/home/home_browse_view.dart @@ -12,6 +12,7 @@ import '../../core/data/repositories/watch_history_repository.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/widgets/feed_banner_carousel.dart'; @@ -137,10 +138,8 @@ class _HomeBrowseViewState extends State { return Stack( children: [ Positioned.fill( - child: RefreshIndicator( + child: AppPullToRefresh( key: const Key('home-refresh'), - color: AppColors.blue, - backgroundColor: AppColors.card, onRefresh: () async { await Future.wait([catalog.load(), logic.refreshRecent()]); }, diff --git a/lib/pages/notices/notices_page.dart b/lib/pages/notices/notices_page.dart index 605c1a4..4c42abf 100644 --- a/lib/pages/notices/notices_page.dart +++ b/lib/pages/notices/notices_page.dart @@ -13,6 +13,7 @@ import '../../core/formatting/localized_date_time.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../l10n/app_localizations.dart'; @@ -65,7 +66,7 @@ class _NoticesPageState extends State { ); } if (_controller.error != null && _controller.notices.isEmpty) { - return RefreshIndicator( + return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -90,7 +91,7 @@ class _NoticesPageState extends State { ); } if (_controller.notices.isEmpty) { - return RefreshIndicator( + return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -108,7 +109,7 @@ class _NoticesPageState extends State { ), ); } - return RefreshIndicator( + return AppPullToRefresh( onRefresh: _controller.refresh, child: ListView.separated( key: const Key('notices-list'), diff --git a/lib/pages/wallet/wallet_page.dart b/lib/pages/wallet/wallet_page.dart index 6f80f6e..651244f 100644 --- a/lib/pages/wallet/wallet_page.dart +++ b/lib/pages/wallet/wallet_page.dart @@ -12,6 +12,7 @@ import '../../core/formatting/localized_date_time.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../store/recharge_page.dart'; @@ -116,9 +117,10 @@ class _WalletPageState extends State { onAction: () => unawaited(wallet.load()), ); } - return RefreshIndicator( + return AppPullToRefresh( onRefresh: wallet.load, child: ListView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 8, 16, 28), children: [ _BalanceHero(wallet: snapshot.wallet), diff --git a/lib/pages/watch_records/watch_records_page.dart b/lib/pages/watch_records/watch_records_page.dart index b1987f1..00fd4a2 100644 --- a/lib/pages/watch_records/watch_records_page.dart +++ b/lib/pages/watch_records/watch_records_page.dart @@ -14,6 +14,7 @@ import '../../core/formatting/error_text.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/empty_state.dart'; import '../../core/widgets/subpage_header.dart'; @@ -145,10 +146,7 @@ class _WatchRecordsBodyState extends State<_WatchRecordsBody> { } Widget _refreshable({required List children}) { - return RefreshIndicator( - color: AppColors.blue, - backgroundColor: AppColors.card, - strokeWidth: 2, + return AppPullToRefresh( onRefresh: controller.load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), diff --git a/test/core/app_pull_to_refresh_test.dart b/test/core/app_pull_to_refresh_test.dart new file mode 100644 index 0000000..b86aa7e --- /dev/null +++ b/test/core/app_pull_to_refresh_test.dart @@ -0,0 +1,68 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; +import 'package:fruitshorts/l10n/app_localizations.dart'; + +void main() { + testWidgets( + 'should_show_pull_release_and_refreshing_states_and_refresh_once', + (tester) async { + final refresh = Completer(); + var calls = 0; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: AppPullToRefresh( + onRefresh: () { + calls++; + return refresh.future; + }, + child: ListView( + key: const Key('pull-refresh-list'), + physics: const AlwaysScrollableScrollPhysics(), + children: const [SizedBox(height: 1200)], + ), + ), + ), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byKey(const Key('pull-refresh-list'))), + ); + await gesture.moveBy(const Offset(0, 30)); + await tester.pump(); + expect(find.text('Pull to refresh'), findsOneWidget); + + await gesture.moveBy(const Offset(0, 40)); + await tester.pump(); + expect(find.text('Release to refresh'), findsOneWidget); + + await gesture.up(); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + expect(calls, 1); + + final duplicate = await tester.startGesture( + tester.getCenter(find.byKey(const Key('pull-refresh-list'))), + ); + await duplicate.moveBy(const Offset(0, 70)); + await duplicate.up(); + await tester.pump(); + expect(calls, 1); + + refresh.complete(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('Refreshing…'), findsNothing); + expect(find.text('Pull to refresh'), findsNothing); + }, + ); +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index 77c34fd..d90e923 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -11,6 +11,7 @@ import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; @@ -292,15 +293,17 @@ void main() { expect(underline('7'), AppColors.blue); }); - testWidgets('should_use_compact_refresh_indicator', (tester) async { + testWidgets('should_use_text_pull_to_refresh', (tester) async { await _pumpTheater(tester, categoryRepository: _GridCategoryRepository()); - final indicator = tester.widget( + final refresh = tester.widget( find.byKey(const Key('discover-refresh')), ); - expect(indicator.color, AppColors.blue); - expect(indicator.strokeWidth, 2); - expect(indicator.edgeOffset, 52); + expect(refresh.edgeOffset, 52); + await tester.drag(_verticalFeed, const Offset(0, 120)); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + await tester.pumpAndSettle(); }); } diff --git a/test/library/favorites_page_test.dart b/test/library/favorites_page_test.dart index 6af2784..72a951c 100644 --- a/test/library/favorites_page_test.dart +++ b/test/library/favorites_page_test.dart @@ -11,6 +11,7 @@ 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/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/favorites/favorites_page.dart'; @@ -73,12 +74,11 @@ void main() { ); expect(find.byIcon(Icons.star_rounded), findsNWidgets(2)); expect(find.byIcon(Icons.favorite_rounded), findsNothing); - final refresh = tester.widget( - find.byType(RefreshIndicator), - ); - expect(refresh.color, AppColors.blue); - expect(refresh.backgroundColor, AppColors.card); - expect(refresh.strokeWidth, 2); + expect(find.byType(AppPullToRefresh), findsOneWidget); + await tester.drag(find.byType(ListView), const Offset(0, 120)); + await tester.pump(); + expect(find.text('Refreshing…'), findsOneWidget); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('favorite-remove-signal'))); await tester.pumpAndSettle(); diff --git a/test/watch/watch_records_page_test.dart b/test/watch/watch_records_page_test.dart index 6818cd6..e88cef1 100644 --- a/test/watch/watch_records_page_test.dart +++ b/test/watch/watch_records_page_test.dart @@ -13,6 +13,7 @@ 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/core/theme/app_colors.dart'; +import 'package:fruitshorts/core/widgets/app_pull_to_refresh.dart'; import 'package:fruitshorts/core/widgets/empty_state.dart'; import 'package:fruitshorts/l10n/app_localizations.dart'; import 'package:fruitshorts/pages/watch_records/watch_records_page.dart'; @@ -77,15 +78,10 @@ void main() { find.byKey(const Key('watch-record-episode-the-signal-demo')), findsOneWidget, ); - final refresh = tester.widget( - find.byType(RefreshIndicator), - ); - expect(refresh.color, AppColors.blue); - expect(refresh.backgroundColor, AppColors.card); - expect(refresh.strokeWidth, 2); + expect(find.byType(AppPullToRefresh), findsOneWidget); final list = tester.widget( find.descendant( - of: find.byType(RefreshIndicator), + of: find.byType(AppPullToRefresh), matching: find.byType(ListView), ), );