feat(following): 新增追剧三栏与双列网格

This commit is contained in:
Zhou Weidong committed 2026-10-22 17:32:09 +08:00
1 parent 73380ee090
commit 97e3f4fc47
15 files changed
+1001 -12

No files matched your search

+37
View File
@@ -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` 未提交,未操作真机。
+13 -1
View File
@@ -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?"
}
+13 -1
View File
@@ -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?"
}
+30
View File
@@ -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
+19
View File
@@ -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?';
}
+19
View File
@@ -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?';
}
+19
View File
@@ -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 => '确定移除这条内容吗?';
}
+13 -1
View File
@@ -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": "确定移除这条内容吗?"
}
@@ -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<Color>(
AppColors.primary,
),
),
),
],
],
),
);
}
}
+466
View File
@@ -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<FollowingPage> createState() => _FollowingPageState();
}
class _FollowingPageState extends State<FollowingPage> {
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<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(),
);
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<AuthController>().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<void> _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<bool> _confirmRemove(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
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<void> Function() onRefresh;
final Future<void> 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<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: onRefresh,
child: CustomScrollView(
key: ValueKey<String>('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;
}
}
+6 -2
View File
@@ -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<FavoriteController>(),
),
_my(context),
],
);
+210
View File
@@ -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<Drama> fetchDrama(String dramaId) {
detailFetches += 1;
return super.fetchDrama(dramaId);
}
}
+3 -4
View File
@@ -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)),
+1 -2
View File
@@ -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);
});
+1 -1
View File
@@ -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);