merge: 追剧页双列表与三列卡片
This commit is contained in:
commit
b185200e8c
8 files changed
+435
-276
No files matched your search
@@ -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。
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -339,7 +339,7 @@
|
|||||||
"joinVip": "开通会员",
|
"joinVip": "开通会员",
|
||||||
"earnRewards": "赚奖励",
|
"earnRewards": "赚奖励",
|
||||||
"freeBonus": "领免费奖励",
|
"freeBonus": "领免费奖励",
|
||||||
"watchHistory": "观看历史",
|
"watchHistory": "播放历史",
|
||||||
"settings": "设置",
|
"settings": "设置",
|
||||||
"serverAddress": "服务器地址",
|
"serverAddress": "服务器地址",
|
||||||
"serverSettings": "服务器设置",
|
"serverSettings": "服务器设置",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in new issue
Block a user