merge: 追剧页双列表与三列卡片

This commit is contained in:
Zhou Weidong committed 2026-10-22 21:14:31 +08:00
commit b185200e8c
8 files changed
+435 -276

No files matched your search

+6
View File
@@ -1229,3 +1229,9 @@ $ flutter test
- 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。
- `flutter analyze`:No issues found。
- 按后续更正恢复首页透明三横线按钮(26、无背景、`#7A7A7E`),删除全部短剧第 4 行圆形按钮及回调链路。
## 2026-10-22 追剧页三列改造
- 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。
- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。
- 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。
+17 -2
View File
@@ -147,8 +147,23 @@ abstract final class AppDimens {
static const double allDramasGridGap = 6;
static const double allDramasGridRowGap = 12;
static const double allDramasFilterToGridGap = 12;
static const double followingTabFont = 16;
static const double followingGridAspectRatio = 0.5;
static const double followingHeaderHeight = 52;
static const double followingTabFont = 18;
static const double followingTabGap = 24;
static const double followingEditIconSize = 24;
static const double followingAddIconSize = 28;
static const double followingGridGap = 6;
static const double followingGridRowGap = 12;
static const double followingCardTextExtent = 66;
static const double followingSubtitleFont = 12;
static const double followingSubtitleGap = 4;
static const double followingProgressHeight = 2;
static const Duration followingTabAnimationDuration = Duration(
milliseconds: 180,
);
static const Duration followingPageAnimationDuration = Duration(
milliseconds: 220,
);
/// 「我的」页中性视觉。
static const double mineCardRadius = 16;
+1 -1
View File
@@ -851,7 +851,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get freeBonus => '领免费奖励';
@override
String get watchHistory => '观看历史';
String get watchHistory => '播放历史';
@override
String get settings => '设置';
+1 -1
View File
@@ -339,7 +339,7 @@
"joinVip": "开通会员",
"earnRewards": "赚奖励",
"freeBonus": "领免费奖励",
"watchHistory": "观看历史",
"watchHistory": "播放历史",
"settings": "设置",
"serverAddress": "服务器地址",
"serverSettings": "服务器设置",
+61 -43
View File
@@ -14,7 +14,6 @@ class FollowingGridCard extends StatelessWidget {
required this.title,
required this.posterUrl,
required this.viewCount,
required this.tags,
required this.subtitle,
required this.onTap,
this.progress,
@@ -26,7 +25,6 @@ class FollowingGridCard extends StatelessWidget {
final String title;
final String posterUrl;
final int viewCount;
final List<String> tags;
final String subtitle;
final VoidCallback onTap;
final double? progress;
@@ -37,22 +35,21 @@ class FollowingGridCard extends StatelessWidget {
Widget build(BuildContext context) {
return InkWell(
key: Key('following-card-$dramaId'),
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
AspectRatio(
key: Key('following-cover-$dramaId'),
aspectRatio: AppDimens.cardCoverAspectRatio,
child: Stack(
clipBehavior: Clip.none,
clipBehavior: Clip.hardEdge,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.cardCoverRadius,
AppDimens.homeCardRadius,
),
child: RemoteImage(
posterUrl,
@@ -64,18 +61,18 @@ class FollowingGridCard extends StatelessWidget {
),
if (editing)
Positioned(
top: -6,
right: -6,
top: -2,
right: -2,
child: GestureDetector(
key: Key('following-remove-$dramaId'),
onTap: onRemove,
child: const CircleAvatar(
radius: 13,
radius: 12,
backgroundColor: AppColors.contentChip,
child: Icon(
Icons.remove_rounded,
color: AppColors.textPrimary,
size: 18,
size: 17,
),
),
),
@@ -88,12 +85,29 @@ class FollowingGridCard extends StatelessWidget {
),
Positioned(
right: AppDimens.cardPlayCountRight,
bottom: AppDimens.cardPlayCountBottom,
bottom: progress == null
? AppDimens.cardPlayCountBottom
: AppDimens.followingProgressHeight + 4,
child: CoverPlayCount(
key: Key('following-views-$dramaId'),
count: viewCount,
),
),
if (progress != null)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LinearProgressIndicator(
key: Key('following-progress-$dramaId'),
value: progress!.clamp(0.0, 1.0).toDouble(),
minHeight: AppDimens.followingProgressHeight,
backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
),
),
),
],
),
),
@@ -110,51 +124,55 @@ class FollowingGridCard extends StatelessWidget {
height: AppDimens.cardTitleLineHeight,
),
),
if (tags.isNotEmpty) ...[
const SizedBox(height: AppDimens.cardTitleTagGap),
_tags(),
],
const SizedBox(height: AppDimens.spaceXs),
const SizedBox(height: AppDimens.followingSubtitleGap),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.cardViewsFont,
fontSize: AppDimens.followingSubtitleFont,
),
),
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,
),
),
),
],
],
),
);
}
}
Widget _tags() {
final visible = tags.take(3).toList(growable: false);
return Text(
visible.join(' | '),
key: Key('following-tags-$dramaId'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.cardTagFont,
class FollowingAddCard extends StatelessWidget {
const FollowingAddCard({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: const Key('following-add-card'),
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: AppDimens.cardCoverAspectRatio,
child: DecoratedBox(
decoration: BoxDecoration(
color: AppColors.contentSurface,
borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
),
child: const Center(
child: Icon(
Icons.add,
key: Key('following-add-icon'),
size: AppDimens.followingAddIconSize,
color: AppColors.textPrimary,
),
),
),
),
],
),
);
}
+138 -205
View File
@@ -5,7 +5,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
@@ -15,17 +14,14 @@ 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';
import 'following_paged_grid.dart';
final RegExp _tagSplitter = RegExp(r'[,/、·]');
enum FollowingTab { favorites, history, reserve }
enum FollowingTab { favorites, history }
class FollowingPage extends StatefulWidget {
const FollowingPage({
@@ -42,17 +38,16 @@ class FollowingPage extends StatefulWidget {
}
class _FollowingPageState extends State<FollowingPage> {
final PageController _pageController = PageController();
final ScrollController _favoriteScroll = ScrollController();
final ScrollController _historyScroll = ScrollController();
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>(),
@@ -65,6 +60,7 @@ class _FollowingPageState extends State<FollowingPage> {
@override
void dispose() {
_pageController.dispose();
_favoriteScroll.dispose();
_historyScroll.dispose();
_history.dispose();
@@ -86,13 +82,16 @@ class _FollowingPageState extends State<FollowingPage> {
Expanded(
child: !signedIn
? const AuthGatePanel()
: IndexedStack(
index: _tab.index,
: PageView(
key: const Key('following-tab-pages'),
controller: _pageController,
onPageChanged: _onPageChanged,
children: [
_FavoritesGrid(
controller: widget.favoriteController,
editing: _editing,
scrollController: _favoriteScroll,
onGoHome: widget.onGoHome,
onListEmptied: _exitEditingIfEmpty,
),
_HistoryGrid(
@@ -101,7 +100,6 @@ class _FollowingPageState extends State<FollowingPage> {
scrollController: _historyScroll,
onListEmptied: _exitEditingIfEmpty,
),
_reserveEmpty(l10n),
],
),
),
@@ -112,79 +110,119 @@ class _FollowingPageState extends State<FollowingPage> {
}
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,
height: AppDimens.followingHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
child: Row(
children: [
for (final tab in tabs)
Expanded(
_tabButton(FollowingTab.favorites, l10n.followingTab, 'favorites'),
const SizedBox(width: AppDimens.followingTabGap),
_tabButton(FollowingTab.history, l10n.watchHistory, 'history'),
const Spacer(),
if (_editing)
SizedBox(
width: 44,
height: 44,
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.textTertiary,
fontSize: AppDimens.followingTabFont,
fontWeight: _tab == tab.$1
? FontWeight.w700
: FontWeight.w400,
key: const Key('following-edit'),
onTap: _toggleEditing,
child: Align(
alignment: Alignment.centerRight,
child: Text(
l10n.done,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingTabFont,
fontWeight: FontWeight.w600,
),
),
),
),
)
else
Semantics(
button: true,
label: l10n.edit,
child: SizedBox(
width: 44,
height: 44,
child: InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: const Align(
alignment: Alignment.centerRight,
child: SizedBox.square(
dimension: AppDimens.followingEditIconSize,
child: Icon(
Icons.edit_outlined,
color: AppColors.textPrimary,
),
),
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,
Widget _tabButton(FollowingTab tab, String label, String keyName) {
final selected = _tab == tab;
return InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
child: SizedBox(
height: AppDimens.followingHeaderHeight,
child: Center(
child: AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: AppDimens.followingTabFont,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
),
child: Text(label, maxLines: 1),
),
),
),
);
}
void _selectTab(FollowingTab tab) {
if (_tab != tab || _editing) {
setState(() {
_tab = tab;
_editing = false;
});
}
_pageController.animateToPage(
tab.index,
duration: AppDimens.followingPageAnimationDuration,
curve: Curves.easeOutCubic,
);
}
void _onPageChanged(int index) {
final tab = FollowingTab.values[index];
if (_tab == tab && !_editing) return;
setState(() {
_tab = tab;
_editing = false;
});
}
void _toggleEditing() {
setState(() => _editing = !_editing);
}
void _exitEditingIfEmpty() {
if (_editing && mounted) setState(() => _editing = false);
}
@@ -195,12 +233,14 @@ class _FavoritesGrid extends StatelessWidget {
required this.controller,
required this.editing,
required this.scrollController,
required this.onGoHome,
required this.onListEmptied,
});
final FavoriteController controller;
final bool editing;
final ScrollController scrollController;
final VoidCallback onGoHome;
final VoidCallback onListEmptied;
@override
@@ -209,13 +249,14 @@ class _FavoritesGrid extends StatelessWidget {
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return _PagedGrid(
return FollowingPagedGrid(
key: const Key('following-favorites-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.cards.isNotEmpty,
showInitialLoading: false,
errorMessage: controller.error == null
? null
: errorText(l10n, controller.error, l10n.favoritesError),
@@ -224,15 +265,13 @@ class _FavoritesGrid extends StatelessWidget {
: 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,
),
itemCount: controller.cards.length + 1,
empty: const SizedBox.shrink(),
itemBuilder: (context, index) {
final drama = controller.cards[index];
if (index == 0) {
return FollowingAddCard(onTap: onGoHome);
}
final drama = controller.cards[index - 1];
final episode = drama.episodes.isEmpty
? 1
: drama.episodes.first.number;
@@ -241,7 +280,6 @@ class _FavoritesGrid extends StatelessWidget {
title: drama.title,
posterUrl: drama.posterUrl,
viewCount: drama.viewCount,
tags: _dramaTags(drama),
editing: editing,
subtitle: l10n.favoriteEpisodeProgress(
episode,
@@ -249,9 +287,10 @@ class _FavoritesGrid extends StatelessWidget {
),
onTap: () => unawaited(playDramaFromCatalog(context, drama)),
onRemove: () async {
if (await _confirmRemove(context)) {
await controller.remove(drama.id);
if (controller.cards.isEmpty) onListEmptied();
if (await _confirmRemove(context) &&
await controller.remove(drama.id) &&
controller.cards.isEmpty) {
onListEmptied();
}
},
);
@@ -281,7 +320,7 @@ class _HistoryGrid extends StatelessWidget {
listenable: controller,
builder: (context, _) {
final l10n = AppLocalizations.of(context)!;
return _PagedGrid(
return FollowingPagedGrid(
key: const Key('following-history-grid'),
scrollController: scrollController,
loading: controller.loading,
@@ -305,23 +344,21 @@ class _HistoryGrid extends StatelessWidget {
),
itemBuilder: (context, index) {
final record = controller.records[index];
final title = record.displayTitle.isEmpty
? l10n.episodeLabel(record.episodeNumber)
: record.displayTitle;
final progress = record.progress.fraction;
return FollowingGridCard(
dramaId: record.dramaId,
title: title,
title: _historyTitle(l10n, record),
posterUrl: record.displayCover,
viewCount: record.drama?.viewCount ?? 0,
tags: _dramaTags(record.drama),
progress: record.progress.fraction,
viewCount: record.drama?.viewCount ?? record.progress.playCount,
progress: progress > 0 ? progress : null,
editing: editing,
subtitle: l10n.watchedToEpisode(record.episodeNumber),
subtitle: _historySubtitle(l10n, record),
onTap: () => unawaited(_openHistory(context, controller, record)),
onRemove: () async {
if (await _confirmRemove(context)) {
await controller.remove(record.dramaId);
if (controller.records.isEmpty) onListEmptied();
if (await _confirmRemove(context) &&
await controller.remove(record.dramaId) &&
controller.records.isEmpty) {
onListEmptied();
}
},
);
@@ -332,15 +369,19 @@ class _HistoryGrid extends StatelessWidget {
}
}
List<String> _dramaTags(Drama? drama) {
if (drama == null) return const [];
final seen = <String>{};
return [drama.genre, drama.category]
.expand((value) => value.split(_tagSplitter))
.map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value))
.take(3)
.toList(growable: false);
String _historyTitle(AppLocalizations l10n, WatchRecord record) {
return record.displayTitle.isEmpty
? l10n.episodeLabel(record.episodeNumber)
: record.displayTitle;
}
String _historySubtitle(AppLocalizations l10n, WatchRecord record) {
final total =
record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0;
if (record.episodeNumber <= 0 || total <= 0) {
return l10n.watchedToEpisode(record.episodeNumber);
}
return l10n.favoriteEpisodeProgress(record.episodeNumber, total);
}
Future<void> _openHistory(
@@ -377,111 +418,3 @@ Future<bool> _confirmRemove(BuildContext context) async {
) ??
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.feedPageHorizontal,
AppDimens.spaceMd,
AppDimens.feedPageHorizontal,
AppDimens.spaceSm,
),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: AppDimens.homeGridGap,
mainAxisSpacing: AppDimens.homeGridRowGap,
childAspectRatio: AppDimens.followingGridAspectRatio,
),
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;
}
}
@@ -0,0 +1,152 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../l10n/app_localizations.dart';
class FollowingPagedGrid extends StatelessWidget {
const FollowingPagedGrid({
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.showInitialLoading = true,
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 bool showInitialLoading;
final Future<void> Function() onRefresh;
final Future<void> Function() onLoadMore;
@override
Widget build(BuildContext context) {
if (showInitialLoading && loading && !hasItems) {
return const Center(child: CircularProgressIndicator());
}
return LayoutBuilder(
builder: (context, constraints) {
final gridWidth =
constraints.maxWidth - AppDimens.feedPageHorizontal * 2;
final itemWidth = (gridWidth - AppDimens.followingGridGap * 2) / 3;
final itemExtent =
itemWidth / AppDimens.cardCoverAspectRatio +
AppDimens.followingCardTextExtent;
return NotificationListener<ScrollNotification>(
onNotification: _onScroll,
child: AppPullToRefresh(
onRefresh: onRefresh,
child: CustomScrollView(
key: ValueKey<String>('following-scroll-$runtimeType'),
controller: scrollController,
physics: AppPullToRefresh.physics,
slivers: [
if (itemCount == 0)
SliverFillRemaining(
hasScrollBody: false,
child: errorMessage == null
? empty
: EmptyState(
icon: Icons.cloud_off_rounded,
title: errorMessage!,
actionLabel: AppLocalizations.of(context)!.retry,
onAction: () => unawaited(onRefresh()),
),
)
else ...[
if (errorMessage != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.spaceSm,
AppDimens.feedPageHorizontal,
0,
),
child: Text(
errorMessage!,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont,
),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
0,
AppDimens.feedPageHorizontal,
AppDimens.spaceSm,
),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: AppDimens.followingGridGap,
mainAxisSpacing: AppDimens.followingGridRowGap,
mainAxisExtent: itemExtent,
),
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;
}
}
+59 -24
View File
@@ -40,18 +40,19 @@ void main() {
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-tab-reserve')), findsNothing);
expect(find.byKey(const Key('following-favorites-grid')), findsOneWidget);
expect(find.byKey(const Key('following-add-card')), findsOneWidget);
expect(find.byType(CustomScrollView), findsWidgets);
final tab = tester.widget<Text>(
final tab = tester.widget<AnimatedDefaultTextStyle>(
find.descendant(
of: find.byKey(const Key('following-tab-favorites')),
matching: find.text('Following'),
matching: find.byType(AnimatedDefaultTextStyle),
),
);
expect(tab.style?.fontSize, AppDimens.followingTabFont);
expect(tab.style?.fontWeight, FontWeight.w700);
expect(tab.style?.color, AppColors.textPrimary);
expect(tab.style.fontSize, AppDimens.followingTabFont);
expect(tab.style.fontWeight, FontWeight.w600);
expect(tab.style.color, AppColors.textPrimary);
final grid = tester.widget<SliverGrid>(
find.descendant(
of: find.byKey(const Key('following-favorites-grid')),
@@ -60,9 +61,10 @@ void main() {
);
final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisSpacing, AppDimens.homeGridGap);
expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap);
expect(delegate.childAspectRatio, AppDimens.followingGridAspectRatio);
expect(delegate.crossAxisCount, 3);
expect(delegate.crossAxisSpacing, AppDimens.followingGridGap);
expect(delegate.mainAxisSpacing, AppDimens.followingGridRowGap);
expect(delegate.mainAxisExtent, greaterThan(0));
final card = find.byKey(const Key('following-card-the-signal-demo'));
final coverFinder = find.byKey(
const Key('following-cover-the-signal-demo'),
@@ -77,7 +79,7 @@ void main() {
);
expect(
coverClip.borderRadius,
BorderRadius.circular(AppDimens.cardCoverRadius),
BorderRadius.circular(AppDimens.homeCardRadius),
);
final titleFinder = find.descendant(
of: card,
@@ -88,7 +90,10 @@ void main() {
expect(title.style?.fontWeight, FontWeight.w500);
expect(title.style?.height, AppDimens.cardTitleLineHeight);
final views = tester.widget<Text>(
find.byKey(const Key('following-views-the-signal-demo')),
find.descendant(
of: find.byKey(const Key('following-views-the-signal-demo')),
matching: find.byType(Text),
),
);
expect(views.style?.fontSize, AppDimens.cardPlayCountFont);
expect(views.style?.fontWeight, FontWeight.w400);
@@ -107,15 +112,10 @@ void main() {
),
findsOneWidget,
);
final tagsFinder = find.byKey(
const Key('following-tags-the-signal-demo'),
expect(
tester.getTopLeft(titleFinder).dx,
tester.getTopLeft(coverFinder).dx,
);
final tags = tester.widget<Text>(tagsFinder);
expect(tags.data, 'Suspense | Mystery');
expect(tags.style?.fontSize, AppDimens.cardTagFont);
expect(tags.style?.color, AppColors.textTertiary);
expect(tester.getTopLeft(titleFinder).dx, tester.getTopLeft(coverFinder).dx);
expect(tester.getTopLeft(tagsFinder).dx, tester.getTopLeft(coverFinder).dx);
await tester.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle();
@@ -148,9 +148,6 @@ void main() {
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 {
@@ -172,7 +169,8 @@ void main() {
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-favorites-empty')), findsOneWidget);
expect(find.byKey(const Key('following-add-card')), findsOneWidget);
expect(find.byKey(const Key('following-favorites-empty')), findsNothing);
await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
@@ -209,7 +207,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Server title'), findsOneWidget);
expect(find.text('Watched to Episode 3'), findsOneWidget);
expect(find.text('Episode 3 / 8 total'), findsOneWidget);
expect(
find.byKey(const Key('following-progress-the-signal-demo')),
findsOneWidget,
@@ -217,6 +215,43 @@ void main() {
expect(catalog.detailFetches, 0);
});
testWidgets('should_switch_following_pages_horizontally', (tester) async {
_signIn();
await tester.pumpWidget(_app(catalog: FakeCatalogRepository()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.drag(
find.byKey(const Key('following-tab-pages')),
const Offset(-500, 0),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
final historyTab = tester.widget<AnimatedDefaultTextStyle>(
find.descendant(
of: find.byKey(const Key('following-tab-history')),
matching: find.byType(AnimatedDefaultTextStyle),
),
);
expect(historyTab.style.fontWeight, FontWeight.w600);
expect(historyTab.style.color, AppColors.textPrimary);
});
testWidgets('should_open_home_from_add_card', (tester) async {
_signIn();
await tester.pumpWidget(_app(catalog: FakeCatalogRepository()));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('following-add-card')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('home-grid')), findsOneWidget);
});
testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async {
_signIn();
final catalog = FakeCatalogRepository();