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,改为追剧/播放历史双页横向切换,并保留各自滚动位置。
|
||||
- 追剧与历史统一三列卡片,新增固定加号卡片,历史进度与编辑删除能力保留。
|
||||
- 按最新口径调整列距 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 followingSubtitleGap = 4;
|
||||
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(
|
||||
milliseconds: 180,
|
||||
);
|
||||
|
||||
@@ -284,7 +284,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String watchedPercent(int percent) {
|
||||
return '已观看 $percent%';
|
||||
return '已看 $percent%';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@
|
||||
"count": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"watchedPercent": "已观看 {percent}%",
|
||||
"watchedPercent": "已看 {percent}%",
|
||||
"@watchedPercent": {
|
||||
"placeholders": {
|
||||
"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 '../player/open_player.dart';
|
||||
import 'following_grid_card.dart';
|
||||
import 'following_history_card.dart';
|
||||
import 'following_paged_grid.dart';
|
||||
|
||||
enum FollowingTab { favorites, history }
|
||||
@@ -94,7 +95,7 @@ class _FollowingPageState extends State<FollowingPage> {
|
||||
onGoHome: widget.onGoHome,
|
||||
onListEmptied: _exitEditingIfEmpty,
|
||||
),
|
||||
_HistoryGrid(
|
||||
_HistoryList(
|
||||
controller: _history,
|
||||
editing: _editing,
|
||||
scrollController: _historyScroll,
|
||||
@@ -301,8 +302,8 @@ class _FavoritesGrid extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryGrid extends StatelessWidget {
|
||||
const _HistoryGrid({
|
||||
class _HistoryList extends StatelessWidget {
|
||||
const _HistoryList({
|
||||
required this.controller,
|
||||
required this.editing,
|
||||
required this.scrollController,
|
||||
@@ -327,6 +328,7 @@ class _HistoryGrid extends StatelessWidget {
|
||||
loadingMore: controller.loadingMore,
|
||||
hasMore: controller.hasMore,
|
||||
hasItems: controller.records.isNotEmpty,
|
||||
singleColumn: true,
|
||||
errorMessage: controller.error == null
|
||||
? null
|
||||
: errorText(l10n, controller.error, l10n.progressError),
|
||||
@@ -345,15 +347,28 @@ class _HistoryGrid extends StatelessWidget {
|
||||
itemBuilder: (context, index) {
|
||||
final record = controller.records[index];
|
||||
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,
|
||||
title: _historyTitle(l10n, record),
|
||||
posterUrl: record.displayCover,
|
||||
viewCount: record.drama?.viewCount ?? record.progress.playCount,
|
||||
progress: progress > 0 ? progress : null,
|
||||
progress: hasProgress ? progress : null,
|
||||
progressLabel: hasProgress
|
||||
? l10n.watchedPercent(watchedPercent)
|
||||
: null,
|
||||
editing: editing,
|
||||
subtitle: _historySubtitle(l10n, record),
|
||||
onTap: () => unawaited(_openHistory(context, controller, record)),
|
||||
onTap: openRecord,
|
||||
onContinue: openRecord,
|
||||
onRemove: () async {
|
||||
if (await _confirmRemove(context) &&
|
||||
await controller.remove(record.dramaId) &&
|
||||
|
||||
@@ -25,6 +25,7 @@ class FollowingPagedGrid extends StatelessWidget {
|
||||
required this.onRefresh,
|
||||
required this.onLoadMore,
|
||||
this.showInitialLoading = true,
|
||||
this.singleColumn = false,
|
||||
this.errorMessage,
|
||||
this.loadMoreError,
|
||||
});
|
||||
@@ -42,6 +43,7 @@ class FollowingPagedGrid extends StatelessWidget {
|
||||
final String? errorMessage;
|
||||
final String? loadMoreError;
|
||||
final bool showInitialLoading;
|
||||
final bool singleColumn;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> Function() onLoadMore;
|
||||
|
||||
@@ -58,6 +60,18 @@ class FollowingPagedGrid extends StatelessWidget {
|
||||
final itemExtent =
|
||||
itemWidth / AppDimens.cardCoverAspectRatio +
|
||||
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>(
|
||||
onNotification: _onScroll,
|
||||
child: AppPullToRefresh(
|
||||
@@ -106,12 +120,7 @@ class FollowingPagedGrid extends StatelessWidget {
|
||||
AppDimens.spaceSm,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 3,
|
||||
crossAxisSpacing: AppDimens.followingGridGap,
|
||||
mainAxisSpacing: AppDimens.followingGridRowGap,
|
||||
mainAxisExtent: itemExtent,
|
||||
),
|
||||
gridDelegate: gridDelegate,
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
itemBuilder,
|
||||
childCount: itemCount,
|
||||
|
||||
@@ -208,11 +208,46 @@ void main() {
|
||||
|
||||
expect(find.text('Server title'), 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(
|
||||
find.byKey(const Key('following-progress-the-signal-demo')),
|
||||
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);
|
||||
|
||||
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 {
|
||||
|
||||
Reference in new issue
Block a user