feat: 调整播放历史为单列列表
This commit is contained in:
1 parent
b185200e8c
commit
c14d89ffce
8 files changed
+317
-14
No files matched your search
@@ -1235,3 +1235,9 @@ $ flutter test
|
|||||||
- 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。
|
- 追剧页移除预约 tab,改为追剧/播放历史双页横向切换,并保留各自滚动位置。
|
||||||
- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。
|
- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。
|
||||||
- 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。
|
- 按最新口径调整列距 6、行距 12、封面圆角 6;`flutter analyze`:No issues found。
|
||||||
|
|
||||||
|
## 2026-10-22 播放历史单列列表
|
||||||
|
|
||||||
|
- 追剧保持三列网格,播放历史改为左封面右信息的单列列表,行距 16。
|
||||||
|
- 历史卡片补充 96×128 封面、观看进度、已看百分比、继续观看和编辑删除。
|
||||||
|
- 刷新、分页与各页独立滚动位置保持;`flutter analyze`:No issues found。
|
||||||
@@ -158,6 +158,16 @@ abstract final class AppDimens {
|
|||||||
static const double followingSubtitleFont = 12;
|
static const double followingSubtitleFont = 12;
|
||||||
static const double followingSubtitleGap = 4;
|
static const double followingSubtitleGap = 4;
|
||||||
static const double followingProgressHeight = 2;
|
static const double followingProgressHeight = 2;
|
||||||
|
static const double followingHistoryCoverWidth = 96;
|
||||||
|
static const double followingHistoryCoverHeight = 128;
|
||||||
|
static const double followingHistoryCoverRadius = 6;
|
||||||
|
static const double followingHistoryContentGap = 12;
|
||||||
|
static const double followingHistoryRowGap = 16;
|
||||||
|
static const double followingHistoryProgressHeight = 3;
|
||||||
|
static const double followingHistoryProgressRadius = 2;
|
||||||
|
static const double followingHistoryProgressGap = 8;
|
||||||
|
static const double followingHistoryLabelGap = 4;
|
||||||
|
static const double followingHistoryActionHeight = 28;
|
||||||
static const Duration followingTabAnimationDuration = Duration(
|
static const Duration followingTabAnimationDuration = Duration(
|
||||||
milliseconds: 180,
|
milliseconds: 180,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String watchedPercent(int percent) {
|
String watchedPercent(int percent) {
|
||||||
return '已观看 $percent%';
|
return '已看 $percent%';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
+1
-1
@@ -121,7 +121,7 @@
|
|||||||
"count": {"type": "int"}
|
"count": {"type": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"watchedPercent": "已观看 {percent}%",
|
"watchedPercent": "已看 {percent}%",
|
||||||
"@watchedPercent": {
|
"@watchedPercent": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"percent": {
|
"percent": {
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/theme/app_colors.dart';
|
||||||
|
import '../../core/theme/app_dimens.dart';
|
||||||
|
import '../../core/widgets/cover_play_count.dart';
|
||||||
|
import '../../core/widgets/remote_image.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
class FollowingHistoryCard extends StatelessWidget {
|
||||||
|
const FollowingHistoryCard({
|
||||||
|
super.key,
|
||||||
|
required this.dramaId,
|
||||||
|
required this.title,
|
||||||
|
required this.posterUrl,
|
||||||
|
required this.viewCount,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onContinue,
|
||||||
|
this.progress,
|
||||||
|
this.progressLabel,
|
||||||
|
this.editing = false,
|
||||||
|
this.onRemove,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String dramaId;
|
||||||
|
final String title;
|
||||||
|
final String posterUrl;
|
||||||
|
final int viewCount;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onContinue;
|
||||||
|
final double? progress;
|
||||||
|
final String? progressLabel;
|
||||||
|
final bool editing;
|
||||||
|
final VoidCallback? onRemove;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return InkWell(
|
||||||
|
key: Key('following-history-card-$dramaId'),
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.followingHistoryCoverRadius,
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
child: SizedBox(
|
||||||
|
height: AppDimens.followingHistoryCoverHeight,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_cover(),
|
||||||
|
const SizedBox(width: AppDimens.followingHistoryContentGap),
|
||||||
|
Expanded(child: _details(l10n)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _cover() {
|
||||||
|
return SizedBox(
|
||||||
|
key: Key('following-history-cover-$dramaId'),
|
||||||
|
width: AppDimens.followingHistoryCoverWidth,
|
||||||
|
height: AppDimens.followingHistoryCoverHeight,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.followingHistoryCoverRadius,
|
||||||
|
),
|
||||||
|
child: RemoteImage(
|
||||||
|
posterUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
showFallbackIcon: true,
|
||||||
|
fallbackColor: AppColors.contentSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: CoverBottomScrim(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: AppDimens.cardPlayCountRight,
|
||||||
|
bottom: AppDimens.cardPlayCountBottom,
|
||||||
|
child: CoverPlayCount(
|
||||||
|
key: Key('following-history-views-$dramaId'),
|
||||||
|
count: viewCount,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _details(AppLocalizations l10n) {
|
||||||
|
final hasProgress = progress != null;
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(right: editing ? 28 : 0),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.left,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: AppDimens.cardTitleFont,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
height: AppDimens.cardTitleLineHeight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppDimens.followingSubtitleGap),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
fontSize: AppDimens.followingSubtitleFont,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!editing && hasProgress) ...[
|
||||||
|
const SizedBox(width: AppDimens.spaceSm),
|
||||||
|
_continueButton(l10n),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (hasProgress) ...[
|
||||||
|
const SizedBox(height: AppDimens.followingHistoryProgressGap),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppDimens.followingHistoryProgressRadius,
|
||||||
|
),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
key: Key('following-progress-$dramaId'),
|
||||||
|
value: progress!.clamp(0.0, 1.0).toDouble(),
|
||||||
|
minHeight: AppDimens.followingHistoryProgressHeight,
|
||||||
|
backgroundColor: AppColors.inactiveTrack,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (progressLabel != null) ...[
|
||||||
|
const SizedBox(height: AppDimens.followingHistoryLabelGap),
|
||||||
|
Text(
|
||||||
|
progressLabel!,
|
||||||
|
key: Key('following-history-percent-$dramaId'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textTertiary,
|
||||||
|
fontSize: AppDimens.followingSubtitleFont,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (editing)
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: Key('following-history-remove-$dramaId'),
|
||||||
|
onTap: onRemove,
|
||||||
|
child: const CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundColor: AppColors.contentChip,
|
||||||
|
child: Icon(
|
||||||
|
Icons.remove_rounded,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
size: 17,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _continueButton(AppLocalizations l10n) {
|
||||||
|
return SizedBox(
|
||||||
|
height: AppDimens.followingHistoryActionHeight,
|
||||||
|
child: OutlinedButton(
|
||||||
|
key: Key('following-history-continue-$dramaId'),
|
||||||
|
onPressed: onContinue,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
side: const BorderSide(color: AppColors.textPrimary),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: AppDimens.followingSubtitleFont,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(l10n.continueWatching),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import '../../core/widgets/empty_state.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_history_card.dart';
|
||||||
import 'following_paged_grid.dart';
|
import 'following_paged_grid.dart';
|
||||||
|
|
||||||
enum FollowingTab { favorites, history }
|
enum FollowingTab { favorites, history }
|
||||||
@@ -94,7 +95,7 @@ class _FollowingPageState extends State<FollowingPage> {
|
|||||||
onGoHome: widget.onGoHome,
|
onGoHome: widget.onGoHome,
|
||||||
onListEmptied: _exitEditingIfEmpty,
|
onListEmptied: _exitEditingIfEmpty,
|
||||||
),
|
),
|
||||||
_HistoryGrid(
|
_HistoryList(
|
||||||
controller: _history,
|
controller: _history,
|
||||||
editing: _editing,
|
editing: _editing,
|
||||||
scrollController: _historyScroll,
|
scrollController: _historyScroll,
|
||||||
@@ -301,8 +302,8 @@ class _FavoritesGrid extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HistoryGrid extends StatelessWidget {
|
class _HistoryList extends StatelessWidget {
|
||||||
const _HistoryGrid({
|
const _HistoryList({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.editing,
|
required this.editing,
|
||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
@@ -327,6 +328,7 @@ class _HistoryGrid extends StatelessWidget {
|
|||||||
loadingMore: controller.loadingMore,
|
loadingMore: controller.loadingMore,
|
||||||
hasMore: controller.hasMore,
|
hasMore: controller.hasMore,
|
||||||
hasItems: controller.records.isNotEmpty,
|
hasItems: controller.records.isNotEmpty,
|
||||||
|
singleColumn: true,
|
||||||
errorMessage: controller.error == null
|
errorMessage: controller.error == null
|
||||||
? null
|
? null
|
||||||
: errorText(l10n, controller.error, l10n.progressError),
|
: errorText(l10n, controller.error, l10n.progressError),
|
||||||
@@ -345,15 +347,28 @@ class _HistoryGrid extends StatelessWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final record = controller.records[index];
|
final record = controller.records[index];
|
||||||
final progress = record.progress.fraction;
|
final progress = record.progress.fraction;
|
||||||
return FollowingGridCard(
|
final hasProgress = record.progress.duration > Duration.zero;
|
||||||
|
final watchedPercent = (progress * 100)
|
||||||
|
.round()
|
||||||
|
.clamp(0, 100)
|
||||||
|
.toInt();
|
||||||
|
void openRecord() {
|
||||||
|
unawaited(_openHistory(context, controller, record));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FollowingHistoryCard(
|
||||||
dramaId: record.dramaId,
|
dramaId: record.dramaId,
|
||||||
title: _historyTitle(l10n, record),
|
title: _historyTitle(l10n, record),
|
||||||
posterUrl: record.displayCover,
|
posterUrl: record.displayCover,
|
||||||
viewCount: record.drama?.viewCount ?? record.progress.playCount,
|
viewCount: record.drama?.viewCount ?? record.progress.playCount,
|
||||||
progress: progress > 0 ? progress : null,
|
progress: hasProgress ? progress : null,
|
||||||
|
progressLabel: hasProgress
|
||||||
|
? l10n.watchedPercent(watchedPercent)
|
||||||
|
: null,
|
||||||
editing: editing,
|
editing: editing,
|
||||||
subtitle: _historySubtitle(l10n, record),
|
subtitle: _historySubtitle(l10n, record),
|
||||||
onTap: () => unawaited(_openHistory(context, controller, record)),
|
onTap: openRecord,
|
||||||
|
onContinue: openRecord,
|
||||||
onRemove: () async {
|
onRemove: () async {
|
||||||
if (await _confirmRemove(context) &&
|
if (await _confirmRemove(context) &&
|
||||||
await controller.remove(record.dramaId) &&
|
await controller.remove(record.dramaId) &&
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class FollowingPagedGrid extends StatelessWidget {
|
|||||||
required this.onRefresh,
|
required this.onRefresh,
|
||||||
required this.onLoadMore,
|
required this.onLoadMore,
|
||||||
this.showInitialLoading = true,
|
this.showInitialLoading = true,
|
||||||
|
this.singleColumn = false,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.loadMoreError,
|
this.loadMoreError,
|
||||||
});
|
});
|
||||||
@@ -42,6 +43,7 @@ class FollowingPagedGrid extends StatelessWidget {
|
|||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? loadMoreError;
|
final String? loadMoreError;
|
||||||
final bool showInitialLoading;
|
final bool showInitialLoading;
|
||||||
|
final bool singleColumn;
|
||||||
final Future<void> Function() onRefresh;
|
final Future<void> Function() onRefresh;
|
||||||
final Future<void> Function() onLoadMore;
|
final Future<void> Function() onLoadMore;
|
||||||
|
|
||||||
@@ -58,6 +60,18 @@ class FollowingPagedGrid extends StatelessWidget {
|
|||||||
final itemExtent =
|
final itemExtent =
|
||||||
itemWidth / AppDimens.cardCoverAspectRatio +
|
itemWidth / AppDimens.cardCoverAspectRatio +
|
||||||
AppDimens.followingCardTextExtent;
|
AppDimens.followingCardTextExtent;
|
||||||
|
final gridDelegate = singleColumn
|
||||||
|
? const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 1,
|
||||||
|
mainAxisSpacing: AppDimens.followingHistoryRowGap,
|
||||||
|
mainAxisExtent: AppDimens.followingHistoryCoverHeight,
|
||||||
|
)
|
||||||
|
: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: 3,
|
||||||
|
crossAxisSpacing: AppDimens.followingGridGap,
|
||||||
|
mainAxisSpacing: AppDimens.followingGridRowGap,
|
||||||
|
mainAxisExtent: itemExtent,
|
||||||
|
);
|
||||||
return NotificationListener<ScrollNotification>(
|
return NotificationListener<ScrollNotification>(
|
||||||
onNotification: _onScroll,
|
onNotification: _onScroll,
|
||||||
child: AppPullToRefresh(
|
child: AppPullToRefresh(
|
||||||
@@ -106,12 +120,7 @@ class FollowingPagedGrid extends StatelessWidget {
|
|||||||
AppDimens.spaceSm,
|
AppDimens.spaceSm,
|
||||||
),
|
),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: gridDelegate,
|
||||||
crossAxisCount: 3,
|
|
||||||
crossAxisSpacing: AppDimens.followingGridGap,
|
|
||||||
mainAxisSpacing: AppDimens.followingGridRowGap,
|
|
||||||
mainAxisExtent: itemExtent,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
itemBuilder,
|
itemBuilder,
|
||||||
childCount: itemCount,
|
childCount: itemCount,
|
||||||
|
|||||||
@@ -208,11 +208,46 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Server title'), findsOneWidget);
|
expect(find.text('Server title'), findsOneWidget);
|
||||||
expect(find.text('Episode 3 / 8 total'), findsOneWidget);
|
expect(find.text('Episode 3 / 8 total'), findsOneWidget);
|
||||||
|
expect(find.text('Watched 25%'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('following-history-card-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('following-history-continue-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.byKey(const Key('following-progress-the-signal-demo')),
|
find.byKey(const Key('following-progress-the-signal-demo')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
final cover = tester.widget<SizedBox>(
|
||||||
|
find.byKey(const Key('following-history-cover-the-signal-demo')),
|
||||||
|
);
|
||||||
|
expect(cover.width, AppDimens.followingHistoryCoverWidth);
|
||||||
|
expect(cover.height, AppDimens.followingHistoryCoverHeight);
|
||||||
|
final historyGrid = tester.widget<SliverGrid>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const Key('following-history-grid')),
|
||||||
|
matching: find.byType(SliverGrid),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final historyDelegate =
|
||||||
|
historyGrid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount;
|
||||||
|
expect(historyDelegate.crossAxisCount, 1);
|
||||||
|
expect(historyDelegate.mainAxisSpacing, AppDimens.followingHistoryRowGap);
|
||||||
expect(catalog.detailFetches, 0);
|
expect(catalog.detailFetches, 0);
|
||||||
|
|
||||||
|
await tester.tap(find.byKey(const Key('following-edit')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('following-history-remove-the-signal-demo')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
find.byKey(const Key('following-history-continue-the-signal-demo')),
|
||||||
|
findsNothing,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_switch_following_pages_horizontally', (tester) async {
|
testWidgets('should_switch_following_pages_horizontally', (tester) async {
|
||||||
|
|||||||
Reference in new issue
Block a user