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;全部短剧页筛选按钮保持不变。 - 删除不再使用的筛选回调、尺寸 token、颜色和 CustomPainter;全部短剧页筛选按钮保持不变。
- `flutter analyze`:No issues found。 - `flutter analyze`:No issues found。
- 按后续更正恢复首页透明三横线按钮(26、无背景、`#7A7A7E`),删除全部短剧第 4 行圆形按钮及回调链路。 - 按后续更正恢复首页透明三横线按钮(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 allDramasGridGap = 6;
static const double allDramasGridRowGap = 12; static const double allDramasGridRowGap = 12;
static const double allDramasFilterToGridGap = 12; static const double allDramasFilterToGridGap = 12;
static const double followingTabFont = 16; static const double followingHeaderHeight = 52;
static const double followingGridAspectRatio = 0.5; 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; static const double mineCardRadius = 16;
+1 -1
View File
@@ -851,7 +851,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get freeBonus => '领免费奖励'; String get freeBonus => '领免费奖励';
@override @override
String get watchHistory => '观看历史'; String get watchHistory => '播放历史';
@override @override
String get settings => '设置'; String get settings => '设置';
+1 -1
View File
@@ -339,7 +339,7 @@
"joinVip": "开通会员", "joinVip": "开通会员",
"earnRewards": "赚奖励", "earnRewards": "赚奖励",
"freeBonus": "领免费奖励", "freeBonus": "领免费奖励",
"watchHistory": "观看历史", "watchHistory": "播放历史",
"settings": "设置", "settings": "设置",
"serverAddress": "服务器地址", "serverAddress": "服务器地址",
"serverSettings": "服务器设置", "serverSettings": "服务器设置",
+61 -43
View File
@@ -14,7 +14,6 @@ class FollowingGridCard extends StatelessWidget {
required this.title, required this.title,
required this.posterUrl, required this.posterUrl,
required this.viewCount, required this.viewCount,
required this.tags,
required this.subtitle, required this.subtitle,
required this.onTap, required this.onTap,
this.progress, this.progress,
@@ -26,7 +25,6 @@ class FollowingGridCard extends StatelessWidget {
final String title; final String title;
final String posterUrl; final String posterUrl;
final int viewCount; final int viewCount;
final List<String> tags;
final String subtitle; final String subtitle;
final VoidCallback onTap; final VoidCallback onTap;
final double? progress; final double? progress;
@@ -37,22 +35,21 @@ class FollowingGridCard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
key: Key('following-card-$dramaId'), key: Key('following-card-$dramaId'),
borderRadius: BorderRadius.circular(AppDimens.cardCoverRadius), borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
onTap: onTap, onTap: onTap,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
AspectRatio( AspectRatio(
key: Key('following-cover-$dramaId'), key: Key('following-cover-$dramaId'),
aspectRatio: AppDimens.cardCoverAspectRatio, aspectRatio: AppDimens.cardCoverAspectRatio,
child: Stack( child: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.hardEdge,
children: [ children: [
Positioned.fill( Positioned.fill(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppDimens.cardCoverRadius, AppDimens.homeCardRadius,
), ),
child: RemoteImage( child: RemoteImage(
posterUrl, posterUrl,
@@ -64,18 +61,18 @@ class FollowingGridCard extends StatelessWidget {
), ),
if (editing) if (editing)
Positioned( Positioned(
top: -6, top: -2,
right: -6, right: -2,
child: GestureDetector( child: GestureDetector(
key: Key('following-remove-$dramaId'), key: Key('following-remove-$dramaId'),
onTap: onRemove, onTap: onRemove,
child: const CircleAvatar( child: const CircleAvatar(
radius: 13, radius: 12,
backgroundColor: AppColors.contentChip, backgroundColor: AppColors.contentChip,
child: Icon( child: Icon(
Icons.remove_rounded, Icons.remove_rounded,
color: AppColors.textPrimary, color: AppColors.textPrimary,
size: 18, size: 17,
), ),
), ),
), ),
@@ -88,12 +85,29 @@ class FollowingGridCard extends StatelessWidget {
), ),
Positioned( Positioned(
right: AppDimens.cardPlayCountRight, right: AppDimens.cardPlayCountRight,
bottom: AppDimens.cardPlayCountBottom, bottom: progress == null
? AppDimens.cardPlayCountBottom
: AppDimens.followingProgressHeight + 4,
child: CoverPlayCount( child: CoverPlayCount(
key: Key('following-views-$dramaId'), key: Key('following-views-$dramaId'),
count: viewCount, 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, height: AppDimens.cardTitleLineHeight,
), ),
), ),
if (tags.isNotEmpty) ...[ const SizedBox(height: AppDimens.followingSubtitleGap),
const SizedBox(height: AppDimens.cardTitleTagGap),
_tags(),
],
const SizedBox(height: AppDimens.spaceXs),
Text( Text(
subtitle, subtitle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle( style: const TextStyle(
color: AppColors.textTertiary, 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() { class FollowingAddCard extends StatelessWidget {
final visible = tags.take(3).toList(growable: false); const FollowingAddCard({super.key, required this.onTap});
return Text(
visible.join(' | '), final VoidCallback onTap;
key: Key('following-tags-$dramaId'),
maxLines: 1, @override
overflow: TextOverflow.ellipsis, Widget build(BuildContext context) {
textAlign: TextAlign.left, return InkWell(
style: const TextStyle( key: const Key('following-add-card'),
color: AppColors.textTertiary, borderRadius: BorderRadius.circular(AppDimens.homeCardRadius),
fontSize: AppDimens.cardTagFont, 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:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_record.dart'; import '../../core/data/models/watch_record.dart';
import '../../core/data/repositories/catalog_repository.dart'; import '../../core/data/repositories/catalog_repository.dart';
import '../../core/data/repositories/watch_history_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/formatting/error_text.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart'; import '../../core/theme/app_dimens.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/auth_gate.dart'; import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/empty_state.dart'; import '../../core/widgets/empty_state.dart';
import '../../core/widgets/paged_list_footer.dart';
import '../../l10n/app_localizations.dart'; import '../../l10n/app_localizations.dart';
import '../player/open_player.dart'; import '../player/open_player.dart';
import 'following_grid_card.dart'; import 'following_grid_card.dart';
import 'following_paged_grid.dart';
final RegExp _tagSplitter = RegExp(r'[,/、·]'); enum FollowingTab { favorites, history }
enum FollowingTab { favorites, history, reserve }
class FollowingPage extends StatefulWidget { class FollowingPage extends StatefulWidget {
const FollowingPage({ const FollowingPage({
@@ -42,17 +38,16 @@ class FollowingPage extends StatefulWidget {
} }
class _FollowingPageState extends State<FollowingPage> { class _FollowingPageState extends State<FollowingPage> {
final PageController _pageController = PageController();
final ScrollController _favoriteScroll = ScrollController();
final ScrollController _historyScroll = ScrollController();
late final WatchRecordsController _history; late final WatchRecordsController _history;
late final ScrollController _favoriteScroll;
late final ScrollController _historyScroll;
FollowingTab _tab = FollowingTab.favorites; FollowingTab _tab = FollowingTab.favorites;
bool _editing = false; bool _editing = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_favoriteScroll = ScrollController();
_historyScroll = ScrollController();
_history = WatchRecordsController( _history = WatchRecordsController(
history: context.read<WatchHistoryRepository>(), history: context.read<WatchHistoryRepository>(),
catalog: context.read<CatalogRepository>(), catalog: context.read<CatalogRepository>(),
@@ -65,6 +60,7 @@ class _FollowingPageState extends State<FollowingPage> {
@override @override
void dispose() { void dispose() {
_pageController.dispose();
_favoriteScroll.dispose(); _favoriteScroll.dispose();
_historyScroll.dispose(); _historyScroll.dispose();
_history.dispose(); _history.dispose();
@@ -86,13 +82,16 @@ class _FollowingPageState extends State<FollowingPage> {
Expanded( Expanded(
child: !signedIn child: !signedIn
? const AuthGatePanel() ? const AuthGatePanel()
: IndexedStack( : PageView(
index: _tab.index, key: const Key('following-tab-pages'),
controller: _pageController,
onPageChanged: _onPageChanged,
children: [ children: [
_FavoritesGrid( _FavoritesGrid(
controller: widget.favoriteController, controller: widget.favoriteController,
editing: _editing, editing: _editing,
scrollController: _favoriteScroll, scrollController: _favoriteScroll,
onGoHome: widget.onGoHome,
onListEmptied: _exitEditingIfEmpty, onListEmptied: _exitEditingIfEmpty,
), ),
_HistoryGrid( _HistoryGrid(
@@ -101,7 +100,6 @@ class _FollowingPageState extends State<FollowingPage> {
scrollController: _historyScroll, scrollController: _historyScroll,
onListEmptied: _exitEditingIfEmpty, onListEmptied: _exitEditingIfEmpty,
), ),
_reserveEmpty(l10n),
], ],
), ),
), ),
@@ -112,79 +110,119 @@ class _FollowingPageState extends State<FollowingPage> {
} }
Widget _header(AppLocalizations l10n) { Widget _header(AppLocalizations l10n) {
final tabs = [
(FollowingTab.favorites, l10n.followingTab, 'favorites'),
(FollowingTab.history, l10n.watchHistory, 'history'),
(FollowingTab.reserve, l10n.reserveTitle, 'reserve'),
];
return SizedBox( return SizedBox(
key: const Key('following-header'), key: const Key('following-header'),
height: 52, height: AppDimens.followingHeaderHeight,
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal, horizontal: AppDimens.feedPageHorizontal,
), ),
child: Row( child: Row(
children: [ children: [
for (final tab in tabs) _tabButton(FollowingTab.favorites, l10n.followingTab, 'favorites'),
Expanded( const SizedBox(width: AppDimens.followingTabGap),
_tabButton(FollowingTab.history, l10n.watchHistory, 'history'),
const Spacer(),
if (_editing)
SizedBox(
width: 44,
height: 44,
child: InkWell( child: InkWell(
key: Key('following-tab-${tab.$3}'), key: const Key('following-edit'),
onTap: () => setState(() { onTap: _toggleEditing,
_tab = tab.$1; child: Align(
_editing = false; alignment: Alignment.centerRight,
}), child: Text(
child: Column( l10n.done,
mainAxisAlignment: MainAxisAlignment.center, style: const TextStyle(
children: [ color: AppColors.textPrimary,
Text( fontSize: AppDimens.followingTabFont,
tab.$2, fontWeight: FontWeight.w600,
maxLines: 1, ),
style: TextStyle( ),
color: _tab == tab.$1 ),
? AppColors.textPrimary ),
: AppColors.textTertiary, )
fontSize: AppDimens.followingTabFont, else
fontWeight: _tab == tab.$1 Semantics(
? FontWeight.w700 button: true,
: FontWeight.w400, 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) { Widget _tabButton(FollowingTab tab, String label, String keyName) {
return EmptyState( final selected = _tab == tab;
key: const Key('following-reserve-empty'), return InkWell(
icon: Icons.notifications_none_rounded, key: Key('following-tab-$keyName'),
title: l10n.reserveEmptyTitle, onTap: () => _selectTab(tab),
subtitle: l10n.reserveEmptyHint, child: SizedBox(
actionLabel: l10n.homeTab, height: AppDimens.followingHeaderHeight,
onAction: widget.onGoHome, 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() { void _exitEditingIfEmpty() {
if (_editing && mounted) setState(() => _editing = false); if (_editing && mounted) setState(() => _editing = false);
} }
@@ -195,12 +233,14 @@ class _FavoritesGrid extends StatelessWidget {
required this.controller, required this.controller,
required this.editing, required this.editing,
required this.scrollController, required this.scrollController,
required this.onGoHome,
required this.onListEmptied, required this.onListEmptied,
}); });
final FavoriteController controller; final FavoriteController controller;
final bool editing; final bool editing;
final ScrollController scrollController; final ScrollController scrollController;
final VoidCallback onGoHome;
final VoidCallback onListEmptied; final VoidCallback onListEmptied;
@override @override
@@ -209,13 +249,14 @@ class _FavoritesGrid extends StatelessWidget {
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return _PagedGrid( return FollowingPagedGrid(
key: const Key('following-favorites-grid'), key: const Key('following-favorites-grid'),
scrollController: scrollController, scrollController: scrollController,
loading: controller.loading, loading: controller.loading,
loadingMore: controller.loadingMore, loadingMore: controller.loadingMore,
hasMore: controller.hasMore, hasMore: controller.hasMore,
hasItems: controller.cards.isNotEmpty, hasItems: controller.cards.isNotEmpty,
showInitialLoading: false,
errorMessage: controller.error == null errorMessage: controller.error == null
? null ? null
: errorText(l10n, controller.error, l10n.favoritesError), : errorText(l10n, controller.error, l10n.favoritesError),
@@ -224,15 +265,13 @@ class _FavoritesGrid extends StatelessWidget {
: errorText(l10n, controller.loadMoreError, l10n.favoritesError), : errorText(l10n, controller.loadMoreError, l10n.favoritesError),
onRefresh: controller.load, onRefresh: controller.load,
onLoadMore: controller.loadMore, onLoadMore: controller.loadMore,
itemCount: controller.cards.length, itemCount: controller.cards.length + 1,
empty: EmptyState( empty: const SizedBox.shrink(),
key: const Key('following-favorites-empty'),
icon: Icons.bookmark_border_rounded,
title: l10n.noFavorites,
subtitle: l10n.emptyHint,
),
itemBuilder: (context, index) { 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 final episode = drama.episodes.isEmpty
? 1 ? 1
: drama.episodes.first.number; : drama.episodes.first.number;
@@ -241,7 +280,6 @@ class _FavoritesGrid extends StatelessWidget {
title: drama.title, title: drama.title,
posterUrl: drama.posterUrl, posterUrl: drama.posterUrl,
viewCount: drama.viewCount, viewCount: drama.viewCount,
tags: _dramaTags(drama),
editing: editing, editing: editing,
subtitle: l10n.favoriteEpisodeProgress( subtitle: l10n.favoriteEpisodeProgress(
episode, episode,
@@ -249,9 +287,10 @@ class _FavoritesGrid extends StatelessWidget {
), ),
onTap: () => unawaited(playDramaFromCatalog(context, drama)), onTap: () => unawaited(playDramaFromCatalog(context, drama)),
onRemove: () async { onRemove: () async {
if (await _confirmRemove(context)) { if (await _confirmRemove(context) &&
await controller.remove(drama.id); await controller.remove(drama.id) &&
if (controller.cards.isEmpty) onListEmptied(); controller.cards.isEmpty) {
onListEmptied();
} }
}, },
); );
@@ -281,7 +320,7 @@ class _HistoryGrid extends StatelessWidget {
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return _PagedGrid( return FollowingPagedGrid(
key: const Key('following-history-grid'), key: const Key('following-history-grid'),
scrollController: scrollController, scrollController: scrollController,
loading: controller.loading, loading: controller.loading,
@@ -305,23 +344,21 @@ class _HistoryGrid extends StatelessWidget {
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final record = controller.records[index]; final record = controller.records[index];
final title = record.displayTitle.isEmpty final progress = record.progress.fraction;
? l10n.episodeLabel(record.episodeNumber)
: record.displayTitle;
return FollowingGridCard( return FollowingGridCard(
dramaId: record.dramaId, dramaId: record.dramaId,
title: title, title: _historyTitle(l10n, record),
posterUrl: record.displayCover, posterUrl: record.displayCover,
viewCount: record.drama?.viewCount ?? 0, viewCount: record.drama?.viewCount ?? record.progress.playCount,
tags: _dramaTags(record.drama), progress: progress > 0 ? progress : null,
progress: record.progress.fraction,
editing: editing, editing: editing,
subtitle: l10n.watchedToEpisode(record.episodeNumber), subtitle: _historySubtitle(l10n, record),
onTap: () => unawaited(_openHistory(context, controller, record)), onTap: () => unawaited(_openHistory(context, controller, record)),
onRemove: () async { onRemove: () async {
if (await _confirmRemove(context)) { if (await _confirmRemove(context) &&
await controller.remove(record.dramaId); await controller.remove(record.dramaId) &&
if (controller.records.isEmpty) onListEmptied(); controller.records.isEmpty) {
onListEmptied();
} }
}, },
); );
@@ -332,15 +369,19 @@ class _HistoryGrid extends StatelessWidget {
} }
} }
List<String> _dramaTags(Drama? drama) { String _historyTitle(AppLocalizations l10n, WatchRecord record) {
if (drama == null) return const []; return record.displayTitle.isEmpty
final seen = <String>{}; ? l10n.episodeLabel(record.episodeNumber)
return [drama.genre, drama.category] : record.displayTitle;
.expand((value) => value.split(_tagSplitter)) }
.map((value) => value.trim())
.where((value) => value.isNotEmpty && seen.add(value)) String _historySubtitle(AppLocalizations l10n, WatchRecord record) {
.take(3) final total =
.toList(growable: false); 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( Future<void> _openHistory(
@@ -377,111 +418,3 @@ Future<bool> _confirmRemove(BuildContext context) async {
) ?? ) ??
false; 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-page')), findsOneWidget);
expect(find.byKey(const Key('following-tab-favorites')), 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-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-favorites-grid')), findsOneWidget);
expect(find.byKey(const Key('following-add-card')), findsOneWidget);
expect(find.byType(CustomScrollView), findsWidgets); expect(find.byType(CustomScrollView), findsWidgets);
final tab = tester.widget<Text>( final tab = tester.widget<AnimatedDefaultTextStyle>(
find.descendant( find.descendant(
of: find.byKey(const Key('following-tab-favorites')), 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.fontSize, AppDimens.followingTabFont);
expect(tab.style?.fontWeight, FontWeight.w700); expect(tab.style.fontWeight, FontWeight.w600);
expect(tab.style?.color, AppColors.textPrimary); expect(tab.style.color, AppColors.textPrimary);
final grid = tester.widget<SliverGrid>( final grid = tester.widget<SliverGrid>(
find.descendant( find.descendant(
of: find.byKey(const Key('following-favorites-grid')), of: find.byKey(const Key('following-favorites-grid')),
@@ -60,9 +61,10 @@ void main() {
); );
final delegate = final delegate =
grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
expect(delegate.crossAxisSpacing, AppDimens.homeGridGap); expect(delegate.crossAxisCount, 3);
expect(delegate.mainAxisSpacing, AppDimens.homeGridRowGap); expect(delegate.crossAxisSpacing, AppDimens.followingGridGap);
expect(delegate.childAspectRatio, AppDimens.followingGridAspectRatio); expect(delegate.mainAxisSpacing, AppDimens.followingGridRowGap);
expect(delegate.mainAxisExtent, greaterThan(0));
final card = find.byKey(const Key('following-card-the-signal-demo')); final card = find.byKey(const Key('following-card-the-signal-demo'));
final coverFinder = find.byKey( final coverFinder = find.byKey(
const Key('following-cover-the-signal-demo'), const Key('following-cover-the-signal-demo'),
@@ -77,7 +79,7 @@ void main() {
); );
expect( expect(
coverClip.borderRadius, coverClip.borderRadius,
BorderRadius.circular(AppDimens.cardCoverRadius), BorderRadius.circular(AppDimens.homeCardRadius),
); );
final titleFinder = find.descendant( final titleFinder = find.descendant(
of: card, of: card,
@@ -88,7 +90,10 @@ void main() {
expect(title.style?.fontWeight, FontWeight.w500); expect(title.style?.fontWeight, FontWeight.w500);
expect(title.style?.height, AppDimens.cardTitleLineHeight); expect(title.style?.height, AppDimens.cardTitleLineHeight);
final views = tester.widget<Text>( 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?.fontSize, AppDimens.cardPlayCountFont);
expect(views.style?.fontWeight, FontWeight.w400); expect(views.style?.fontWeight, FontWeight.w400);
@@ -107,15 +112,10 @@ void main() {
), ),
findsOneWidget, findsOneWidget,
); );
final tagsFinder = find.byKey( expect(
const Key('following-tags-the-signal-demo'), 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.tap(find.byKey(const Key('following-edit')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -148,9 +148,6 @@ void main() {
await tester.tap(find.byKey(const Key('following-tab-history'))); await tester.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget); 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 { 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.tap(find.byKey(const Key('tab-follow')));
await tester.pumpAndSettle(); 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.tap(find.byKey(const Key('following-tab-history')));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('following-history-empty')), findsOneWidget); expect(find.byKey(const Key('following-history-empty')), findsOneWidget);
@@ -209,7 +207,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('Server title'), findsOneWidget); expect(find.text('Server title'), findsOneWidget);
expect(find.text('Watched to Episode 3'), findsOneWidget); expect(find.text('Episode 3 / 8 total'), findsOneWidget);
expect( expect(
find.byKey(const Key('following-progress-the-signal-demo')), find.byKey(const Key('following-progress-the-signal-demo')),
findsOneWidget, findsOneWidget,
@@ -217,6 +215,43 @@ void main() {
expect(catalog.detailFetches, 0); 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 { testWidgets('should_reset_edit_state_when_switching_tabs', (tester) async {
_signIn(); _signIn();
final catalog = FakeCatalogRepository(); final catalog = FakeCatalogRepository();