merge: 追剧页新增三栏与双列网格
This commit is contained in:
commit
af6b9bb3db
15 files changed
+1001
-12
No files matched your search
@@ -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
@@ -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
@@ -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?"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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?';
|
||||
}
|
||||
@@ -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?';
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user