diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 15aa2db..5971618 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -963,3 +963,40 @@ $ flutter test 解读:推荐三分区、横滑卡片、四行筛选、三列网格、tab 状态保留、搜索、 卡片播放和内嵌榜单均有测试覆盖;所有生效文件低于 500 行。 `pubspec.lock` 未提交,未操作真机。 + +## 2026-10-22 追剧页三 tab 与双列网格 + +- 分支:`feature/following-tabs-grid` +- 基线:`develop` / `origin/develop` = `73380ee` + +### 页面与数据 + +- 根导航追剧槽位改为内嵌 `FollowingPage`,不再 push 收藏路由,也不叠加 + 第二层底部导航。 +- 页面提供追剧 / 播放历史 / 预约三个子 tab,使用 `IndexedStack` 保留各自 + 滚动位置;切换 tab 会退出编辑态。 +- 追剧复用 `FavoriteController`,显示“第 N 集 / 共 M 集”,支持下拉刷新、 + 触底分页、加载/错误/无更多状态。 +- 播放历史复用 `WatchRecordsController`,优先使用分页接口的 + `dramaTitle/dramaCover/episodeNumber`,仅在标题缺失时补取详情;卡片展示 + 已看集数与可用进度条,同样支持刷新和分页。 +- 双列网格使用共享卡片:3:4 封面、播放量胶囊、两行标题、副信息与编辑态 + 减号。删除前二次确认,最后一条移除后自动退出编辑态。 +- 预约没有独立分页数据源,本轮保留真实空态;未登录沿用 `AuthGatePanel`。 + +### 门禁命令与原始输出 + +```text +$ flutter analyze +Analyzing reelshort-bottom-nav... +No issues found! (ran in 1.9s) +``` + +```text +$ flutter test +01:12 +643: All tests passed! +``` + +解读:三 tab、双列网格、后端历史元数据直出、编辑删除确认、最后一条自动 +退出编辑、未登录与空态均有测试覆盖;`following_page.dart` 低于 500 行。 +`pubspec.lock` 未提交,未操作真机。 diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7d1c915..31d95d2 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -730,5 +730,17 @@ "subtitlesOnlyFilter": "Subtitles", "dubbedFilter": "Dubbed", "reversalFilter": "Comeback", - "modernFilter": "Modern" + "modernFilter": "Modern", + "edit": "Edit", + "done": "Done", + "favoriteEpisodeProgress": "Episode {episode} / {total} total", + "@favoriteEpisodeProgress": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "watchedToEpisode": "Watched to Episode {episode}", + "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "confirmRemove": "Remove this item?" } diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index d1fdecb..78edd86 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -687,5 +687,17 @@ "subtitlesOnlyFilter": "Subtitle", "dubbedFilter": "Dubbing", "reversalFilter": "Balas Dendam", - "modernFilter": "Modern" + "modernFilter": "Modern", + "edit": "Edit", + "done": "Selesai", + "favoriteEpisodeProgress": "Episode {episode} / total {total}", + "@favoriteEpisodeProgress": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "watchedToEpisode": "Ditonton hingga Episode {episode}", + "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "confirmRemove": "Hapus item ini?" } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 137e51f..8170424 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -3195,6 +3195,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Modern'** String get modernFilter; + + /// No description provided for @edit. + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + + /// No description provided for @done. + /// + /// In en, this message translates to: + /// **'Done'** + String get done; + + /// No description provided for @favoriteEpisodeProgress. + /// + /// In en, this message translates to: + /// **'Episode {episode} / {total} total'** + String favoriteEpisodeProgress(int episode, int total); + + /// No description provided for @watchedToEpisode. + /// + /// In en, this message translates to: + /// **'Watched to Episode {episode}'** + String watchedToEpisode(int episode); + + /// No description provided for @confirmRemove. + /// + /// In en, this message translates to: + /// **'Remove this item?'** + String get confirmRemove; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1e95bd6..5c4772a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1664,4 +1664,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get modernFilter => 'Modern'; + + @override + String get edit => 'Edit'; + + @override + String get done => 'Done'; + + @override + String favoriteEpisodeProgress(int episode, int total) { + return 'Episode $episode / $total total'; + } + + @override + String watchedToEpisode(int episode) { + return 'Watched to Episode $episode'; + } + + @override + String get confirmRemove => 'Remove this item?'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 4d6038e..6dc7af2 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1666,4 +1666,23 @@ class AppLocalizationsId extends AppLocalizations { @override String get modernFilter => 'Modern'; + + @override + String get edit => 'Edit'; + + @override + String get done => 'Selesai'; + + @override + String favoriteEpisodeProgress(int episode, int total) { + return 'Episode $episode / total $total'; + } + + @override + String watchedToEpisode(int episode) { + return 'Ditonton hingga Episode $episode'; + } + + @override + String get confirmRemove => 'Hapus item ini?'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 7d5ba02..0d5aeb2 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1640,4 +1640,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get modernFilter => '现代'; + + @override + String get edit => '编辑'; + + @override + String get done => '完成'; + + @override + String favoriteEpisodeProgress(int episode, int total) { + return '第 $episode 集 / 共 $total 集'; + } + + @override + String watchedToEpisode(int episode) { + return '已看至 第 $episode 集'; + } + + @override + String get confirmRemove => '确定移除这条内容吗?'; } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 0385554..9e01e24 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -649,5 +649,17 @@ "subtitlesOnlyFilter": "仅字幕", "dubbedFilter": "配音", "reversalFilter": "逆袭", - "modernFilter": "现代" + "modernFilter": "现代", + "edit": "编辑", + "done": "完成", + "favoriteEpisodeProgress": "第 {episode} 集 / 共 {total} 集", + "@favoriteEpisodeProgress": { + "placeholders": { + "episode": { "type": "int" }, + "total": { "type": "int" } + } + }, + "watchedToEpisode": "已看至 第 {episode} 集", + "@watchedToEpisode": { "placeholders": { "episode": { "type": "int" } } }, + "confirmRemove": "确定移除这条内容吗?" } diff --git a/lib/pages/following/following_grid_card.dart b/lib/pages/following/following_grid_card.dart new file mode 100644 index 0000000..3bcb88d --- /dev/null +++ b/lib/pages/following/following_grid_card.dart @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +import 'package:flutter/material.dart'; + +import '../../core/formatting/count_format.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/remote_image.dart'; + +class FollowingGridCard extends StatelessWidget { + const FollowingGridCard({ + super.key, + required this.dramaId, + required this.title, + required this.posterUrl, + required this.viewCount, + required this.subtitle, + required this.onTap, + this.progress, + this.editing = false, + this.onRemove, + }); + + final String dramaId; + final String title; + final String posterUrl; + final int viewCount; + final String subtitle; + final VoidCallback onTap; + final double? progress; + final bool editing; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + return InkWell( + key: Key('following-card-$dramaId'), + borderRadius: BorderRadius.circular(AppDimens.homeCardRadius), + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppDimens.homeCardRadius, + ), + child: RemoteImage( + posterUrl, + fit: BoxFit.cover, + showFallbackIcon: true, + fallbackColor: AppColors.contentSurface, + ), + ), + ), + Positioned( + left: AppDimens.spaceXs, + bottom: AppDimens.spaceXs, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.playerOverlayPill, + borderRadius: BorderRadius.circular(AppDimens.pillRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.play_arrow_rounded, + color: AppColors.textPrimary, + size: AppDimens.homeCardMeta, + ), + Text( + formatInteractionCount(viewCount), + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardMeta, + ), + ), + ], + ), + ), + ), + if (editing) + Positioned( + top: -6, + right: -6, + child: GestureDetector( + key: Key('following-remove-$dramaId'), + onTap: onRemove, + child: const CircleAvatar( + radius: 13, + backgroundColor: AppColors.contentChip, + child: Icon( + Icons.remove_rounded, + color: AppColors.textPrimary, + size: 18, + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: AppDimens.spaceXs), + Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: AppDimens.homeCardTitle, + fontWeight: FontWeight.w600, + ), + ), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: AppDimens.homeCardMeta, + ), + ), + if (progress != null) ...[ + const SizedBox(height: AppDimens.spaceXs), + ClipRRect( + borderRadius: BorderRadius.circular(AppDimens.progressRadius), + child: LinearProgressIndicator( + key: Key('following-progress-$dramaId'), + value: progress!.clamp(0.0, 1.0).toDouble(), + minHeight: 2, + backgroundColor: AppColors.inactiveTrack, + valueColor: const AlwaysStoppedAnimation( + AppColors.primary, + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/pages/following/following_page.dart b/lib/pages/following/following_page.dart new file mode 100644 index 0000000..c201220 --- /dev/null +++ b/lib/pages/following/following_page.dart @@ -0,0 +1,466 @@ +// Author: fruit.com.cn + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../core/data/models/watch_record.dart'; +import '../../core/data/repositories/catalog_repository.dart'; +import '../../core/data/repositories/watch_history_repository.dart'; +import '../../core/data/services/auth_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; +import '../../core/data/services/watch_records_controller.dart'; +import '../../core/formatting/error_text.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_dimens.dart'; +import '../../core/widgets/app_pull_to_refresh.dart'; +import '../../core/widgets/auth_gate.dart'; +import '../../core/widgets/empty_state.dart'; +import '../../core/widgets/paged_list_footer.dart'; +import '../../l10n/app_localizations.dart'; +import '../player/open_player.dart'; +import 'following_grid_card.dart'; + +enum FollowingTab { favorites, history, reserve } + +class FollowingPage extends StatefulWidget { + const FollowingPage({ + super.key, + required this.onGoHome, + required this.favoriteController, + }); + + final VoidCallback onGoHome; + final FavoriteController favoriteController; + + @override + State createState() => _FollowingPageState(); +} + +class _FollowingPageState extends State { + late final WatchRecordsController _history; + late final ScrollController _favoriteScroll; + late final ScrollController _historyScroll; + FollowingTab _tab = FollowingTab.favorites; + bool _editing = false; + + @override + void initState() { + super.initState(); + _favoriteScroll = ScrollController(); + _historyScroll = ScrollController(); + _history = WatchRecordsController( + history: context.read(), + catalog: context.read(), + ); + unawaited(_history.load()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) unawaited(widget.favoriteController.load()); + }); + } + + @override + void dispose() { + _favoriteScroll.dispose(); + _historyScroll.dispose(); + _history.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final signedIn = context.watch().isSignedIn; + return ColoredBox( + key: const Key('following-page'), + color: AppColors.appBackground, + child: SafeArea( + bottom: false, + child: Column( + children: [ + _header(l10n), + Expanded( + child: !signedIn + ? const AuthGatePanel() + : IndexedStack( + index: _tab.index, + children: [ + _FavoritesGrid( + controller: widget.favoriteController, + editing: _editing, + scrollController: _favoriteScroll, + onListEmptied: _exitEditingIfEmpty, + ), + _HistoryGrid( + controller: _history, + editing: _editing, + scrollController: _historyScroll, + onListEmptied: _exitEditingIfEmpty, + ), + _reserveEmpty(l10n), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _header(AppLocalizations l10n) { + final tabs = [ + (FollowingTab.favorites, l10n.followingTab, 'favorites'), + (FollowingTab.history, l10n.watchHistory, 'history'), + (FollowingTab.reserve, l10n.reserveTitle, 'reserve'), + ]; + return SizedBox( + key: const Key('following-header'), + height: 52, + child: Row( + children: [ + for (final tab in tabs) + Expanded( + child: InkWell( + key: Key('following-tab-${tab.$3}'), + onTap: () => setState(() { + _tab = tab.$1; + _editing = false; + }), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + tab.$2, + maxLines: 1, + style: TextStyle( + color: _tab == tab.$1 + ? AppColors.textPrimary + : AppColors.textSecondary, + fontSize: 16, + fontWeight: _tab == tab.$1 + ? FontWeight.w700 + : FontWeight.w500, + ), + ), + const SizedBox(height: 3), + Container( + width: _tab == tab.$1 ? 20 : 0, + height: 2, + color: AppColors.textPrimary, + ), + ], + ), + ), + ), + IconButton( + key: const Key('following-edit'), + tooltip: _editing ? l10n.done : l10n.edit, + onPressed: () => setState(() => _editing = !_editing), + color: AppColors.textPrimary, + iconSize: 24, + icon: Icon(_editing ? Icons.check_rounded : Icons.edit_outlined), + ), + ], + ), + ); + } + + Widget _reserveEmpty(AppLocalizations l10n) { + return EmptyState( + key: const Key('following-reserve-empty'), + icon: Icons.notifications_none_rounded, + title: l10n.reserveEmptyTitle, + subtitle: l10n.reserveEmptyHint, + actionLabel: l10n.homeTab, + onAction: widget.onGoHome, + ); + } + + void _exitEditingIfEmpty() { + if (_editing && mounted) setState(() => _editing = false); + } +} + +class _FavoritesGrid extends StatelessWidget { + const _FavoritesGrid({ + required this.controller, + required this.editing, + required this.scrollController, + required this.onListEmptied, + }); + + final FavoriteController controller; + final bool editing; + final ScrollController scrollController; + final VoidCallback onListEmptied; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return _PagedGrid( + key: const Key('following-favorites-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.cards.isNotEmpty, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.favoritesError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText(l10n, controller.loadMoreError, l10n.favoritesError), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.cards.length, + empty: EmptyState( + key: const Key('following-favorites-empty'), + icon: Icons.bookmark_border_rounded, + title: l10n.noFavorites, + subtitle: l10n.emptyHint, + ), + itemBuilder: (context, index) { + final drama = controller.cards[index]; + final episode = drama.episodes.isEmpty + ? 1 + : drama.episodes.first.number; + return FollowingGridCard( + dramaId: drama.id, + title: drama.title, + posterUrl: drama.posterUrl, + viewCount: drama.viewCount, + editing: editing, + subtitle: l10n.favoriteEpisodeProgress( + episode, + drama.episodeCount, + ), + onTap: () => unawaited(playDramaFromCatalog(context, drama)), + onRemove: () async { + if (await _confirmRemove(context)) { + await controller.remove(drama.id); + if (controller.cards.isEmpty) onListEmptied(); + } + }, + ); + }, + ); + }, + ); + } +} + +class _HistoryGrid extends StatelessWidget { + const _HistoryGrid({ + required this.controller, + required this.editing, + required this.scrollController, + required this.onListEmptied, + }); + + final WatchRecordsController controller; + final bool editing; + final ScrollController scrollController; + final VoidCallback onListEmptied; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final l10n = AppLocalizations.of(context)!; + return _PagedGrid( + key: const Key('following-history-grid'), + scrollController: scrollController, + loading: controller.loading, + loadingMore: controller.loadingMore, + hasMore: controller.hasMore, + hasItems: controller.records.isNotEmpty, + errorMessage: controller.error == null + ? null + : errorText(l10n, controller.error, l10n.progressError), + loadMoreError: controller.loadMoreError == null + ? null + : errorText(l10n, controller.loadMoreError, l10n.progressError), + onRefresh: controller.load, + onLoadMore: controller.loadMore, + itemCount: controller.records.length, + empty: EmptyState( + key: const Key('following-history-empty'), + icon: Icons.history_rounded, + title: l10n.noWatchRecords, + subtitle: l10n.emptyHint, + ), + itemBuilder: (context, index) { + final record = controller.records[index]; + final title = record.displayTitle.isEmpty + ? l10n.episodeLabel(record.episodeNumber) + : record.displayTitle; + return FollowingGridCard( + dramaId: record.dramaId, + title: title, + posterUrl: record.displayCover, + viewCount: record.drama?.viewCount ?? 0, + progress: record.progress.fraction, + editing: editing, + subtitle: l10n.watchedToEpisode(record.episodeNumber), + onTap: () => unawaited(_openHistory(context, controller, record)), + onRemove: () async { + if (await _confirmRemove(context)) { + await controller.remove(record.dramaId); + if (controller.records.isEmpty) onListEmptied(); + } + }, + ); + }, + ); + }, + ); + } +} + +Future _openHistory( + BuildContext context, + WatchRecordsController controller, + WatchRecord record, +) async { + final drama = record.drama ?? await controller.detailForOpen(record.dramaId); + if (!context.mounted || drama == null) return; + await playDramaFromCatalog( + context, + drama, + episodeId: record.progress.episodeId, + ); +} + +Future _confirmRemove(BuildContext context) async { + final l10n = AppLocalizations.of(context)!; + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + content: Text(l10n.confirmRemove), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: Text(l10n.cancel), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text(l10n.remove), + ), + ], + ), + ) ?? + false; +} + +class _PagedGrid extends StatelessWidget { + const _PagedGrid({ + super.key, + required this.scrollController, + required this.loading, + required this.loadingMore, + required this.hasMore, + required this.hasItems, + required this.itemCount, + required this.itemBuilder, + required this.empty, + required this.onRefresh, + required this.onLoadMore, + this.errorMessage, + this.loadMoreError, + }); + + static const double _loadMoreThreshold = 240; + + final ScrollController scrollController; + final bool loading; + final bool loadingMore; + final bool hasMore; + final bool hasItems; + final int itemCount; + final IndexedWidgetBuilder itemBuilder; + final Widget empty; + final String? errorMessage; + final String? loadMoreError; + final Future Function() onRefresh; + final Future Function() onLoadMore; + + @override + Widget build(BuildContext context) { + if (loading && !hasItems) { + return const Center(child: CircularProgressIndicator()); + } + final emptyContent = errorMessage == null + ? empty + : EmptyState( + icon: Icons.cloud_off_rounded, + title: errorMessage!, + actionLabel: AppLocalizations.of(context)!.retry, + onAction: () => unawaited(onRefresh()), + ); + return NotificationListener( + onNotification: _onScroll, + child: AppPullToRefresh( + onRefresh: onRefresh, + child: CustomScrollView( + key: ValueKey('following-scroll-$runtimeType'), + controller: scrollController, + physics: AppPullToRefresh.physics, + slivers: [ + if (!hasItems) + SliverFillRemaining(hasScrollBody: false, child: emptyContent) + else ...[ + SliverPadding( + padding: const EdgeInsets.fromLTRB( + AppDimens.pageHorizontal, + AppDimens.spaceMd, + AppDimens.pageHorizontal, + AppDimens.spaceSm, + ), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: AppDimens.spaceMd, + mainAxisSpacing: AppDimens.spaceMd, + childAspectRatio: 0.56, + ), + delegate: SliverChildBuilderDelegate( + itemBuilder, + childCount: itemCount, + ), + ), + ), + SliverToBoxAdapter( + child: PagedListFooter( + loading: loadingMore, + hasMore: hasMore, + hasItems: hasItems, + errorMessage: loadMoreError, + onRetry: onLoadMore, + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: AppDimens.spaceXl), + ), + ], + ], + ), + ), + ); + } + + bool _onScroll(ScrollNotification notification) { + if (notification.depth != 0) return false; + if (notification.metrics.extentAfter > _loadMoreThreshold) return false; + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification || + notification is OverscrollNotification) { + unawaited(onLoadMore()); + } + return false; + } +} diff --git a/lib/pages/home/home_view.dart b/lib/pages/home/home_view.dart index 02e860f..15aef75 100644 --- a/lib/pages/home/home_view.dart +++ b/lib/pages/home/home_view.dart @@ -12,6 +12,7 @@ import '../../core/data/services/home_feed_controller.dart'; import '../../core/data/services/catalog_controller.dart'; import '../../core/data/services/catalog_retry.dart'; import '../../core/data/services/category_controller.dart'; +import '../../core/data/services/favorite_controller.dart'; import '../../core/data/services/wallet_controller.dart'; import '../../core/platform/haptics.dart'; import '../../core/theme/app_colors.dart'; @@ -22,7 +23,7 @@ import '../../core/widgets/catalog_endpoint_editor.dart'; import '../../core/widgets/empty_state.dart'; import '../../l10n/app_localizations.dart'; import '../discover/discover_view.dart'; -import '../favorites/favorites_page.dart'; +import '../following/following_page.dart'; import '../filter/filter_page.dart'; import '../mine/mine_types.dart'; import '../new_releases/new_releases_page.dart'; @@ -267,7 +268,10 @@ class _HomeTabsState extends State<_HomeTabs> { children: [ _homeGrid(context), _recommend(context), - const FavoritesContent(key: Key('favorites-page'), showBack: false), + FollowingPage( + onGoHome: () => logic.select(0), + favoriteController: context.read(), + ), _my(context), ], ); diff --git a/test/following/following_page_test.dart b/test/following/following_page_test.dart new file mode 100644 index 0000000..a7bf640 --- /dev/null +++ b/test/following/following_page_test.dart @@ -0,0 +1,210 @@ +// Author: fruit.com.cn + +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/watch_progress.dart'; +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:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; + +import '../support/fake_catalog.dart'; +import '../support/fake_category.dart'; +import '../support/fake_interaction_repositories.dart'; +import '../support/widget_player.dart'; + +void main() { + testWidgets('should_render_following_tabs_grid_and_edit_actions', ( + tester, + ) async { + _signIn(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + favorites: InMemoryFavoriteRepository( + cards: catalog.dramas.take(1).toList(), + saved: {catalog.dramas.first.id}, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-page')), findsOneWidget); + expect(find.byKey(const Key('following-tab-favorites')), findsOneWidget); + expect(find.byKey(const Key('following-tab-history')), findsOneWidget); + expect(find.byKey(const Key('following-tab-reserve')), findsOneWidget); + expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget); + expect(find.byType(CustomScrollView), findsWidgets); + + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('following-remove-the-signal-demo')), + findsOneWidget, + ); + + await tester.tap(find.byKey(const Key('following-remove-the-signal-demo'))); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + await tester.tap( + find.descendant( + of: find.byType(AlertDialog), + matching: find.byType(FilledButton), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-remove-the-signal-demo')), + findsNothing, + ); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsNothing, + ); + + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('following-history-empty')), findsOneWidget); + await tester.tap(find.byKey(const Key('following-tab-reserve'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('following-reserve-empty')), findsOneWidget); + }); + + testWidgets('should_show_auth_gate_when_signed_out', (tester) async { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('auth-gate-panel')), findsOneWidget); + expect(find.byKey(const Key('following-favorites-grid')), findsNothing); + }); + + testWidgets('should_show_empty_favorites_and_history', (tester) async { + _signIn(); + await tester.pumpWidget(_app(catalog: FakeCatalogRepository())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget); + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('following-history-empty')), findsOneWidget); + }); + + testWidgets('should_render_history_metadata_without_detail_fetch', ( + tester, + ) async { + _signIn(); + final catalog = _CountingCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + history: InMemoryWatchHistoryRepository( + seed: [ + WatchProgress( + dramaId: 'the-signal-demo', + episodeId: 'the-signal-demo-3', + dramaTitle: 'Server title', + dramaCover: '/files/server-cover.jpg', + episodeNumber: 3, + totalEpisodes: 8, + position: const Duration(seconds: 25), + duration: const Duration(seconds: 100), + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + + expect(find.text('Server title'), findsOneWidget); + expect(find.text('Watched to Episode 3'), findsOneWidget); + expect( + find.byKey(const Key('following-progress-the-signal-demo')), + findsOneWidget, + ); + expect(catalog.detailFetches, 0); + }); + + testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async { + _signIn(); + final catalog = FakeCatalogRepository(); + await tester.pumpWidget( + _app( + catalog: catalog, + favorites: InMemoryFavoriteRepository( + cards: catalog.dramas, + saved: catalog.dramas.map((drama) => drama.id).toSet(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('tab-follow'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-edit'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('following-tab-history'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('following-tab-favorites'))); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('following-card-the-signal-demo')), + findsOneWidget, + ); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + }); +} + +void _signIn() { + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.withData({ + 'auth.accessToken': 'access', + 'auth.refreshToken': 'refresh', + 'auth.user': '{"uid":"9","nickname":"Viewer","avatarUrl":""}', + }); +} + +Widget _app({ + required FakeCatalogRepository catalog, + FavoriteRepository? favorites, + WatchHistoryRepository? history, +}) { + return LumaDramaApp( + playerFactory: createWidgetPlayer, + catalogRepository: catalog, + categoryRepository: FakeCategoryRepository(catalog: catalog), + watchHistoryRepository: history ?? InMemoryWatchHistoryRepository(), + favoriteRepository: favorites ?? InMemoryFavoriteRepository(), + playbackRepository: FakePlaybackRepository(), + commentRepository: FakeCommentRepository(), + interactionRepository: FakeInteractionRepository(), + ); +} + +class _CountingCatalogRepository extends FakeCatalogRepository { + int detailFetches = 0; + + @override + Future fetchDrama(String dramaId) { + detailFetches += 1; + return super.fetchDrama(dramaId); + } +} diff --git a/test/home/home_nav_ui_test.dart b/test/home/home_nav_ui_test.dart index af25c79..59675ae 100644 --- a/test/home/home_nav_ui_test.dart +++ b/test/home/home_nav_ui_test.dart @@ -8,7 +8,7 @@ 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_bottom_nav.dart'; -import 'package:fruitshorts/pages/favorites/favorites_page.dart'; +import 'package:fruitshorts/pages/following/following_page.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'; @@ -217,10 +217,9 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - final favorites = find.byType(FavoritesContent); + final favorites = find.byType(FollowingPage); expect(favorites, findsOneWidget); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); - expect(find.byKey(const Key('favorites-back')), findsNothing); + expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); expect( find.descendant(of: favorites, matching: find.byType(Scaffold)), diff --git a/test/home/k28_a_navigation_test.dart b/test/home/k28_a_navigation_test.dart index 006bac5..7473163 100644 --- a/test/home/k28_a_navigation_test.dart +++ b/test/home/k28_a_navigation_test.dart @@ -36,8 +36,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); - expect(find.byKey(const Key('favorites-back')), findsNothing); + expect(find.byKey(const Key('following-page')), findsOneWidget); expect(find.byType(AppBottomNav), findsOneWidget); }); diff --git a/test/widget_test.dart b/test/widget_test.dart index b6b0eba..a6621d5 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -117,7 +117,7 @@ void main() { await tester.tap(find.byKey(const Key('tab-follow'))); await tester.pumpAndSettle(); - expect(find.byKey(const Key('favorites-page')), findsOneWidget); + expect(find.byKey(const Key('following-page')), findsOneWidget); await tester.tap(find.byKey(const Key('tab-mine'))); await tester.pumpAndSettle(); expect(find.byKey(const Key('mine-menu')), findsOneWidget);