diff --git a/lib/core/widgets/paged_local_list.dart b/lib/core/widgets/paged_local_list.dart new file mode 100644 index 0000000..ce5a80d --- /dev/null +++ b/lib/core/widgets/paged_local_list.dart @@ -0,0 +1,116 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../theme/app_colors.dart'; + +const int _defaultPageSize = 20; +const Duration _defaultLoadDelay = Duration(milliseconds: 300); + +/// 客户端本地列表分页状态。 +class PagedLocalListController extends ChangeNotifier { + PagedLocalListController({ + List items = const [], + this.pageSize = _defaultPageSize, + this.minimumLoadDelay = _defaultLoadDelay, + }) : _items = List.unmodifiable(items); + + final int pageSize; + final Duration minimumLoadDelay; + + List _items; + int _pageCount = 1; + int _generation = 0; + bool _loading = false; + bool _disposed = false; + + List get items => _items; + bool get loading => _loading; + bool get hasMore => visibleItems.length < _items.length; + + List get visibleItems => + _items.take(_pageCount * pageSize).toList(growable: false); + + /// 用最新目录替换本地数据,并回到第一页。 + void replaceAll(List items) { + _generation++; + _items = List.unmodifiable(items); + _pageCount = 1; + _loading = false; + _notify(); + } + + /// 追加下一页;已加载完或正在加载时不重复触发。 + Future loadMore() async { + if (_loading || !hasMore) return; + final generation = _generation; + _loading = true; + _notify(); + await Future.delayed(minimumLoadDelay); + if (_disposed || generation != _generation) return; + _pageCount++; + _loading = false; + _notify(); + } + + void _notify() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} + +/// 分页列表底部加载态与到底提示。 +class PagedLoadFooter extends StatelessWidget { + const PagedLoadFooter({ + super.key, + required this.loading, + required this.hasMore, + required this.hasItems, + required this.loadMoreKey, + required this.noMoreKey, + required this.noMoreLabel, + }); + + final bool loading; + final bool hasMore; + final bool hasItems; + final Key loadMoreKey; + final Key noMoreKey; + final String noMoreLabel; + + @override + Widget build(BuildContext context) { + if (loading) { + return SizedBox( + key: loadMoreKey, + height: 56, + child: const Center( + child: SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.blue, + ), + ), + ), + ); + } + if (hasItems && !hasMore) { + return Padding( + key: noMoreKey, + padding: const EdgeInsets.symmetric(vertical: 14), + child: Text( + noMoreLabel, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted, fontSize: 12), + ), + ); + } + return const SizedBox(height: 16); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 43dca2a..6affe0c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -341,6 +341,7 @@ "pullToRefresh": "Pull to refresh", "releaseToRefresh": "Release to refresh", "refreshingLabel": "Refreshing…", + "noMore": "No more", "backToSeries": "Back to series", "nextEpisode": "Next episode", "pause": "Pause", @@ -633,6 +634,7 @@ "rankingSubtitle": "Ranked from today's live catalog feed.", "rankingHot": "Trending", "rankingNew": "New releases", + "newReleasesAll": "All new releases", "newReleasesSubtitle": "Newest arrivals", "reserveSubtitle": "Reserve before launch", "reserveEmptyTitle": "No reservations yet", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 38c5f27..68f8893 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -315,6 +315,7 @@ "pullToRefresh": "Tarik untuk menyegarkan", "releaseToRefresh": "Lepaskan untuk menyegarkan", "refreshingLabel": "Menyegarkan…", + "noMore": "Tidak ada lagi", "backToSeries": "Kembali ke serial", "nextEpisode": "Episode berikutnya", "pause": "Jeda", @@ -595,6 +596,7 @@ "rankingSubtitle": "Diurutkan dari feed katalog terkini.", "rankingHot": "Populer", "rankingNew": "Rilis baru", + "newReleasesAll": "Semua rilis baru", "newReleasesSubtitle": "Rilis terbaru", "reserveSubtitle": "Reservasi sebelum rilis", "reserveEmptyTitle": "Belum ada reservasi", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 1247720..650b5bb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1354,6 +1354,12 @@ abstract class AppLocalizations { /// **'Refreshing…'** String get refreshingLabel; + /// No description provided for @noMore. + /// + /// In en, this message translates to: + /// **'No more'** + String get noMore; + /// No description provided for @backToSeries. /// /// In en, this message translates to: @@ -2704,6 +2710,12 @@ abstract class AppLocalizations { /// **'New releases'** String get rankingNew; + /// No description provided for @newReleasesAll. + /// + /// In en, this message translates to: + /// **'All new releases'** + String get newReleasesAll; + /// No description provided for @newReleasesSubtitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index af56d36..6437937 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -685,6 +685,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get refreshingLabel => 'Refreshing…'; + @override + String get noMore => 'No more'; + @override String get backToSeries => 'Back to series'; @@ -1401,6 +1404,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rankingNew => 'New releases'; + @override + String get newReleasesAll => 'All new releases'; + @override String get newReleasesSubtitle => 'Newest arrivals'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index a8627ff..5b3e823 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -688,6 +688,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get refreshingLabel => 'Menyegarkan…'; + @override + String get noMore => 'Tidak ada lagi'; + @override String get backToSeries => 'Kembali ke serial'; @@ -1401,6 +1404,9 @@ class AppLocalizationsId extends AppLocalizations { @override String get rankingNew => 'Rilis baru'; + @override + String get newReleasesAll => 'Semua rilis baru'; + @override String get newReleasesSubtitle => 'Rilis terbaru'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 923626c..30037c8 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -677,6 +677,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get refreshingLabel => '正在刷新…'; + @override + String get noMore => '没有更多了'; + @override String get backToSeries => '返回剧集详情'; @@ -1379,6 +1382,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get rankingNew => '新剧'; + @override + String get newReleasesAll => '全部新剧'; + @override String get newReleasesSubtitle => '最新上架短剧'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index efd2b33..8ceaa1d 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -277,6 +277,7 @@ "pullToRefresh": "下拉刷新", "releaseToRefresh": "松手刷新", "refreshingLabel": "正在刷新…", + "noMore": "没有更多了", "backToSeries": "返回剧集详情", "nextEpisode": "下一集", "pause": "暂停", @@ -557,6 +558,7 @@ "rankingSubtitle": "依据目录实时分区排序。", "rankingHot": "热播", "rankingNew": "新剧", + "newReleasesAll": "全部新剧", "newReleasesSubtitle": "最新上架短剧", "reserveSubtitle": "上线前预约,开播即通知", "reserveEmptyTitle": "暂无预约剧集", diff --git a/lib/pages/new_releases/new_releases_page.dart b/lib/pages/new_releases/new_releases_page.dart index a02d874..e499302 100644 --- a/lib/pages/new_releases/new_releases_page.dart +++ b/lib/pages/new_releases/new_releases_page.dart @@ -10,9 +10,12 @@ import '../../core/data/models/drama.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/remote_image.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; +import '../discover/widgets/drama_grid.dart'; import '../player/open_player.dart'; const int _moreLimit = 12; @@ -21,18 +24,83 @@ const double _cardPadding = 8; const double _cardWidth = 124; const double _rowHeight = 204; const double _cardRadius = 12; +const double _loadMoreThreshold = 200; /// 新剧页:暖色首映厅,今日首映大卡 + 横向更多新剧。 -class NewReleasesPage extends StatelessWidget { +class NewReleasesPage extends StatefulWidget { const NewReleasesPage({super.key}); + @override + State createState() => _NewReleasesPageState(); +} + +class _NewReleasesPageState extends State { + final ScrollController _scroll = ScrollController(); + PagedLocalListController? _pager; + bool _refreshing = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_pager != null) return; + _pager = PagedLocalListController( + items: _newest(context.read().dramas), + )..addListener(_rebuild); + } + + @override + void dispose() { + _pager + ?..removeListener(_rebuild) + ..dispose(); + _scroll.dispose(); + super.dispose(); + } + + void _rebuild() { + if (mounted) setState(() {}); + } + + List _newest(List source) { + return [...source]..sort((a, b) => b.id.compareTo(a.id)); + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + try { + final catalog = context.read(); + await catalog.refresh(); + if (!mounted) return; + _pager!.replaceAll(_newest(catalog.dramas)); + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.minScrollExtent); + } + } finally { + _refreshing = false; + } + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || _refreshing) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(_pager!.loadMore()); + } + return false; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = [...context.read().dramas] - ..sort((a, b) => b.id.compareTo(a.id)); - final hero = dramas.isEmpty ? null : dramas.first; - final more = dramas.skip(1).take(_moreLimit).toList(); + final pager = _pager!; + final visible = pager.visibleItems; + final hero = visible.isEmpty ? null : visible.first; + final more = pager.items.skip(1).take(_moreLimit).toList(); return AnnotatedRegion( value: SystemUiOverlayStyle.dark, child: Scaffold( @@ -54,54 +122,93 @@ class NewReleasesPage extends StatelessWidget { title: l10n.newReleasesTitle, subtitle: l10n.newReleasesSubtitle, backKey: const Key('new-releases-back'), - actions: _countChip(l10n, dramas.length), + actions: _countChip(l10n, pager.items.length), ), Expanded( - child: hero == null - ? Center( - child: Text( - l10n.noStories, - key: const Key('new-releases-empty'), - style: const TextStyle(color: AppColors.muted), - ), - ) - : ListView( - padding: const EdgeInsets.only(bottom: 24), - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - ), - child: _heroCard(context, l10n, hero), - ), - const SizedBox(height: 14), - const _FilmStrip(), - const SizedBox(height: 14), - if (more.isNotEmpty) ...[ - _moreHeader(l10n), - const SizedBox(height: 10), - SizedBox( - height: _rowHeight, - child: ListView.separated( - key: const Key('new-releases-row'), - scrollDirection: Axis.horizontal, + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('new-releases-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('new-releases-list'), + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 24), + children: hero == null + ? [ + SizedBox( + height: 420, + child: Center( + child: Text( + l10n.noStories, + key: const Key('new-releases-empty'), + style: const TextStyle( + color: AppColors.muted, + ), + ), + ), + ), + ] + : [ + Padding( padding: const EdgeInsets.symmetric( horizontal: 16, ), - itemCount: more.length, - separatorBuilder: (_, _) => - const SizedBox(width: 10), - itemBuilder: (context, index) => _moreCard( - context, - l10n, - more[index], - index, + child: _heroCard(context, l10n, hero), + ), + const SizedBox(height: 14), + const _FilmStrip(), + const SizedBox(height: 14), + if (more.isNotEmpty) ...[ + _moreHeader(l10n), + const SizedBox(height: 10), + SizedBox( + height: _rowHeight, + child: ListView.separated( + key: const Key('new-releases-row'), + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), + itemCount: more.length, + separatorBuilder: (_, _) => + const SizedBox(width: 10), + itemBuilder: (context, index) => + _moreCard( + context, + l10n, + more[index], + index, + ), + ), + ), + ], + const SizedBox(height: 20), + _allHeader(l10n), + const SizedBox(height: 10), + DramaGrid( + key: const Key('new-releases-all-grid'), + l10n: l10n, + dramas: visible, + onOpen: (drama) => unawaited( + playDramaFromCatalog(context, drama), ), ), - ), - ], - ], - ), + PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: pager.items.isNotEmpty, + loadMoreKey: const Key( + 'new-releases-load-more', + ), + noMoreKey: const Key('new-releases-no-more'), + noMoreLabel: l10n.noMore, + ), + ], + ), + ), + ), ), ], ), @@ -267,6 +374,21 @@ class NewReleasesPage extends StatelessWidget { ); } + Widget _allHeader(AppLocalizations l10n) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + l10n.newReleasesAll, + key: const Key('new-releases-all-header'), + style: const TextStyle( + color: AppColors.onSurface, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + ); + } + Widget _moreCard( BuildContext context, AppLocalizations l10n, diff --git a/lib/pages/ranking/ranking_page.dart b/lib/pages/ranking/ranking_page.dart index 0e34da9..bb2b3bb 100644 --- a/lib/pages/ranking/ranking_page.dart +++ b/lib/pages/ranking/ranking_page.dart @@ -10,7 +10,9 @@ import '../../core/data/services/catalog_controller.dart'; import '../../core/formatting/count_format.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_theme.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_local_list.dart'; import '../../core/widgets/plain_page_scaffold.dart'; import '../../core/widgets/remote_image.dart'; import '../../l10n/app_localizations.dart'; @@ -24,21 +26,87 @@ const double _rowGap = 12; const double _rankColumnWidth = 56; const double _listCoverWidth = 72; const double _listCoverHeight = 96; +const double _loadMoreThreshold = 200; const Color _goldStart = Color(0xFFF3B34C); const Color _goldEnd = Color(0xFFD9A23A); const Color _bronzeStart = Color(0xFFCD7F32); const Color _bronzeEnd = Color(0xFFA9713A); /// 剧场榜单页:前三名横排领奖台,第 4 名起纵向列表。 -class RankingPage extends StatelessWidget { +class RankingPage extends StatefulWidget { const RankingPage({super.key}); + @override + State createState() => _RankingPageState(); +} + +class _RankingPageState extends State { + final ScrollController _scroll = ScrollController(); + PagedLocalListController? _pager; + bool _refreshing = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_pager != null) return; + _pager = PagedLocalListController( + items: _ranked(context.read().dramas), + )..addListener(_rebuild); + } + + @override + void dispose() { + _pager + ?..removeListener(_rebuild) + ..dispose(); + _scroll.dispose(); + super.dispose(); + } + + void _rebuild() { + if (mounted) setState(() {}); + } + + List _ranked(List source) { + final dramas = [...source] + ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); + return dramas.take(_topCount).toList(growable: false); + } + + Future _refresh() async { + if (_refreshing) return; + _refreshing = true; + try { + final catalog = context.read(); + await catalog.refresh(); + if (!mounted) return; + _pager!.replaceAll(_ranked(catalog.dramas)); + if (_scroll.hasClients) { + _scroll.jumpTo(_scroll.position.minScrollExtent); + } + } finally { + _refreshing = false; + } + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0 || _refreshing) return false; + final reachedBottom = + notification.metrics.extentAfter <= _loadMoreThreshold; + if (reachedBottom && + (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification)) { + unawaited(_pager!.loadMore()); + } + return false; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final dramas = [...context.read().dramas] - ..sort((a, b) => b.viewCount.compareTo(a.viewCount)); - final top = dramas.take(_topCount).toList(); + final pager = _pager!; + final top = pager.visibleItems; final podium = top.take(3).toList(); final rest = top.skip(3).toList(); return PlainPageScaffold( @@ -46,32 +114,58 @@ class RankingPage extends StatelessWidget { backKey: const Key('ranking-back'), title: l10n.topCharts, subtitle: l10n.rankingSubtitle, - child: top.isEmpty - ? EmptyState(icon: Icons.leaderboard_outlined, title: l10n.noStories) - : ListView( - padding: const EdgeInsets.only(top: 4, bottom: 24), - children: [ - if (podium.isNotEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _Podium(podium: podium, l10n: l10n), - ), - if (rest.isNotEmpty) ...[ - const SizedBox(height: 16), - for (var index = 0; index < rest.length; index++) ...[ - if (index > 0) const SizedBox(height: _rowGap), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 14), - child: _RankingRow( - index: index + 3, - drama: rest[index], - l10n: l10n, + child: NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + key: const Key('ranking-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('ranking-list'), + controller: _scroll, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 4, bottom: 24), + children: top.isEmpty + ? [ + SizedBox( + height: 420, + child: EmptyState( + icon: Icons.leaderboard_outlined, + title: l10n.noStories, ), ), + ] + : [ + if (podium.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _Podium(podium: podium, l10n: l10n), + ), + if (rest.isNotEmpty) ...[ + const SizedBox(height: 16), + for (var index = 0; index < rest.length; index++) ...[ + if (index > 0) const SizedBox(height: _rowGap), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: _RankingRow( + index: index + 3, + drama: rest[index], + l10n: l10n, + ), + ), + ], + ], + PagedLoadFooter( + loading: pager.loading, + hasMore: pager.hasMore, + hasItems: pager.items.isNotEmpty, + loadMoreKey: const Key('ranking-load-more'), + noMoreKey: const Key('ranking-no-more'), + noMoreLabel: l10n.noMore, + ), ], - ], - ], - ), + ), + ), + ), ); } } diff --git a/lib/pages/reserve/reserve_page.dart b/lib/pages/reserve/reserve_page.dart index 26f5bd3..a4a3bc8 100644 --- a/lib/pages/reserve/reserve_page.dart +++ b/lib/pages/reserve/reserve_page.dart @@ -1,8 +1,11 @@ // Author: fruit.com.cn import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../core/data/services/catalog_controller.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; import '../../core/widgets/subpage_header.dart'; import '../../l10n/app_localizations.dart'; import '../new_releases/new_releases_page.dart'; @@ -41,32 +44,42 @@ class _ReservePageState extends State { key: const Key('reserve-page'), backgroundColor: AppColors.ink, body: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), - children: [ - AppSubpageHeader( - title: l10n.reserveTitle, - subtitle: l10n.reserveSubtitle, - backKey: const Key('reserve-back'), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), - child: _weekStrip(l10n), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), - child: _ticketCard(context, l10n), - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), - child: _steps(l10n), - ), - ], + child: AppPullToRefresh( + key: const Key('reserve-refresh'), + onRefresh: _refresh, + child: ListView( + key: const Key('reserve-list'), + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(0, 4, 0, 24), + children: [ + AppSubpageHeader( + title: l10n.reserveTitle, + subtitle: l10n.reserveSubtitle, + backKey: const Key('reserve-back'), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: _weekStrip(l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + child: _ticketCard(context, l10n), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 0), + child: _steps(l10n), + ), + ], + ), ), ), ); } + Future _refresh() async { + await context.read().refresh(); + } + Widget _weekStrip(AppLocalizations l10n) { return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/test/discover/theater_pages_test.dart b/test/discover/theater_pages_test.dart index bd12f50..7b6ae6e 100644 --- a/test/discover/theater_pages_test.dart +++ b/test/discover/theater_pages_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/app.dart'; import 'package:fruitshorts/core/data/models/drama.dart'; +import 'package:fruitshorts/core/data/models/home_feed.dart'; import 'package:fruitshorts/core/theme/app_colors.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; @@ -172,14 +173,119 @@ void main() { expect(find.byKey(const Key('ranking-page')), findsNothing); expect(find.byKey(const Key('discover-search')), findsOneWidget); }); + + testWidgets('should_page_ranking_and_reset_after_refresh', (tester) async { + final repository = _CountingCatalogRepository(dramas: _rankDramas(40)); + await _pumpTheater(tester, catalogRepository: repository); + await tester.tap(find.byKey(const Key('theater-toolbar-ranking'))); + await tester.pumpAndSettle(); + + final list = find.byKey(const Key('ranking-list')); + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + + expect(find.byKey(const Key('ranking-row-19')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-20')), findsNothing); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + await tester.pump(const Duration(milliseconds: 299)); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); + + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + expect(find.byKey(const Key('ranking-row-39')), findsOneWidget); + expect(find.byKey(const Key('ranking-row-40')), findsNothing); + expect(find.byKey(const Key('ranking-no-more')), findsOneWidget); + + final position = _scrollPosition(tester, const Key('ranking-list')); + position.jumpTo(0); + await tester.pump(); + final beforeRefresh = repository.fetchCalls; + await tester.drag(list, const Offset(0, 120)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(repository.fetchCalls, beforeRefresh + 1); + expect(position.pixels, 0); + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + expect(find.byKey(const Key('ranking-load-more')), findsOneWidget); + expect(find.byKey(const Key('ranking-no-more')), findsNothing); + await tester.pumpAndSettle(); + }); + + testWidgets('should_page_new_releases_and_reset_after_refresh', ( + tester, + ) async { + final repository = _CountingCatalogRepository(dramas: _rankDramas(40)); + await _pumpTheater(tester, catalogRepository: repository); + await tester.tap(find.byKey(const Key('theater-toolbar-new'))); + await tester.pumpAndSettle(); + + final list = find.byKey(const Key('new-releases-list')); + await tester.drag(list, const Offset(0, -400)); + await tester.pump(); + expect(_countKeys('home-drama-card-'), 20); + + await tester.drag(list, const Offset(0, -8000)); + await tester.pump(); + expect(find.byKey(const Key('new-releases-load-more')), findsOneWidget); + await tester.pump(const Duration(milliseconds: 299)); + expect(_countKeys('home-drama-card-'), 20); + await tester.pump(const Duration(milliseconds: 1)); + await tester.pump(); + expect(_countKeys('home-drama-card-'), 40); + + await tester.pumpAndSettle(); + final position = _scrollPosition(tester, const Key('new-releases-list')); + position.jumpTo(position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('new-releases-no-more')), findsOneWidget); + + position.jumpTo(0); + await tester.pump(); + final beforeRefresh = repository.fetchCalls; + await tester.drag(list, const Offset(0, 120)); + await tester.pump(); + await tester.pumpAndSettle(); + expect(repository.fetchCalls, beforeRefresh + 1); + expect(position.pixels, 0); + + await tester.drag(list, const Offset(0, -400)); + await tester.pump(); + expect(_countKeys('home-drama-card-'), 20); + }); + + testWidgets('should_refresh_reserve_without_load_more', (tester) async { + final repository = _CountingCatalogRepository(dramas: _rankDramas(3)); + await _pumpTheater(tester, catalogRepository: repository); + await tester.tap(find.byKey(const Key('theater-toolbar-reserve'))); + await tester.pumpAndSettle(); + + final beforeRefresh = repository.fetchCalls; + await tester.drag( + find.byKey(const Key('reserve-list')), + const Offset(0, 120), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(repository.fetchCalls, beforeRefresh + 1); + expect(find.byKey(const Key('reserve-load-more')), findsNothing); + expect(find.byKey(const Key('reserve-no-more')), findsNothing); + }); } -Future _pumpTheater(WidgetTester tester, {List? dramas}) async { +Future _pumpTheater( + WidgetTester tester, { + List? dramas, + FakeCatalogRepository? catalogRepository, +}) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); - final catalog = FakeCatalogRepository(dramas: dramas); + final catalog = catalogRepository ?? FakeCatalogRepository(dramas: dramas); await tester.pumpWidget( LumaDramaApp( playerFactory: createWidgetPlayer, @@ -199,6 +305,34 @@ Future _pumpTheater(WidgetTester tester, {List? dramas}) async { await tester.pumpAndSettle(); } +ScrollPosition _scrollPosition(WidgetTester tester, Key listKey) { + final scrollable = find.descendant( + of: find.byKey(listKey), + matching: find.byType(Scrollable), + ); + return tester.state(scrollable.first).position; +} + +int _countKeys(String prefix) => find + .byWidgetPredicate((widget) { + final key = widget.key; + return key is ValueKey && key.value.startsWith(prefix); + }) + .evaluate() + .length; + +class _CountingCatalogRepository extends FakeCatalogRepository { + _CountingCatalogRepository({super.dramas}); + + int fetchCalls = 0; + + @override + Future fetchHomeFeed() { + fetchCalls++; + return super.fetchHomeFeed(); + } +} + List _rankDramas(int count) => [ for (var index = 0; index < count; index++) Drama(