fix(ui): 打磨推荐追剧我的设置页间距与图标

This commit is contained in:
Vicky authored and trae-solo-agent committed 2026-10-28 16:36:51 +08:00
1 parent de7da64886
commit 5346be40a9
33 files changed
+1408 -811

No files matched your search

+4 -86
View File
@@ -25,87 +25,6 @@ const double _barHorizontal = 16;
const double _barFontSize = 13;
const double _barButtonHeight = 30;
const double _removeDialogButtonHeight = 32;
const double _removeDialogRadius = 6;
const double _removeDialogBottomInset = 8;
const double _cancelFollowingDialogPadding = 20;
/// 播放历史移除确认。只把移除按钮压矮。
Future<bool> confirmHistoryRemove(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
actionsPadding: const EdgeInsets.fromLTRB(
24,
0,
16,
_removeDialogBottomInset,
),
content: Text(l10n.confirmRemove),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_removeDialogRadius),
),
),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.remove),
),
],
),
) ??
false;
}
/// 确认后返回是否取消已选追剧。
Future<bool> confirmCancelFollowing(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
return await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
contentPadding: const EdgeInsets.all(_cancelFollowingDialogPadding),
actionsPadding: const EdgeInsets.fromLTRB(
_cancelFollowingDialogPadding,
0,
_cancelFollowingDialogPadding,
_cancelFollowingDialogPadding,
),
content: Text(l10n.confirmCancelFollowing),
actions: [
TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
visualDensity: VisualDensity.standard,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
),
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.cancelFollowing),
),
],
),
) ??
false;
}
/// 只在追剧编辑中显示底栏。
class FollowingEditSlot extends StatelessWidget {
@@ -195,6 +114,7 @@ class FollowingEditBar extends StatelessWidget {
TextButton(
key: const Key('following-select-all'),
style: TextButton.styleFrom(
foregroundColor: AppColors.brandRed,
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _barButtonHeight),
@@ -214,16 +134,14 @@ class FollowingEditBar extends StatelessWidget {
),
),
const Spacer(),
FilledButton(
TextButton(
key: const Key('following-cancel-follow'),
style: FilledButton.styleFrom(
style: TextButton.styleFrom(
foregroundColor: AppColors.brandRed,
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(0, _removeDialogButtonHeight),
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(_removeDialogRadius),
),
textStyle: const TextStyle(
fontSize: _barFontSize,
fontWeight: FontWeight.w600,
@@ -5,12 +5,18 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../core/data/models/watch_record.dart';
import '../../core/data/services/favorite_controller.dart';
import '../../core/data/services/watch_records_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../player/open_player.dart';
import 'following_grid_card.dart';
import 'following_paged_grid.dart';
import 'widgets/continue_watching_banner.dart';
/// 追剧编辑中的选中状态。
class FollowingFavoriteSelection {
@@ -30,15 +36,19 @@ class FollowingFavoritesGrid extends StatelessWidget {
const FollowingFavoritesGrid({
super.key,
required this.controller,
required this.history,
required this.selection,
required this.scrollController,
required this.onGoHome,
required this.onContinueRecord,
});
final FavoriteController controller;
final WatchRecordsController history;
final FollowingFavoriteSelection selection;
final ScrollController scrollController;
final VoidCallback onGoHome;
final ValueChanged<WatchRecord> onContinueRecord;
@override
Widget build(BuildContext context) {
@@ -49,7 +59,7 @@ class FollowingFavoritesGrid extends StatelessWidget {
valueListenable: selection.selected,
builder: (context, picked, _) {
return ListenableBuilder(
listenable: controller,
listenable: Listenable.merge([controller, history]),
builder: (context, _) => _grid(context, isEditing, picked),
);
},
@@ -60,14 +70,15 @@ class FollowingFavoritesGrid extends StatelessWidget {
Widget _grid(BuildContext context, bool isEditing, Set<String> picked) {
final l10n = AppLocalizations.of(context)!;
final extra = isEditing ? 0 : 1;
final hasFavorites = controller.cards.isNotEmpty;
final extra = !isEditing && hasFavorites ? 1 : 0;
return FollowingPagedGrid(
key: const Key('following-favorites-grid'),
scrollController: scrollController,
loading: controller.loading,
loadingMore: controller.loadingMore,
hasMore: controller.hasMore,
hasItems: controller.cards.isNotEmpty,
hasItems: hasFavorites,
showInitialLoading: false,
errorMessage: controller.error == null
? null
@@ -78,23 +89,96 @@ class FollowingFavoritesGrid extends StatelessWidget {
onRefresh: controller.load,
onLoadMore: controller.loadMore,
itemCount: controller.cards.length + extra,
empty: const SizedBox.shrink(),
topSlivers: _topSlivers(context),
empty: EmptyState(
key: const Key('following-favorites-empty'),
icon: Icons.bookmark_border_rounded,
title: l10n.noFavoritesYet,
),
itemBuilder: (context, index) => _item(context, index, isEditing, picked),
);
}
List<Widget> _topSlivers(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final continueRecord = _continueRecord();
return [
if (continueRecord != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
4,
AppDimens.feedPageHorizontal,
0,
),
child: ContinueWatchingBanner(
record: continueRecord,
languageCode: Localizations.localeOf(context).languageCode,
onTap: () => onContinueRecord(continueRecord),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.followingSectionTopGap,
AppDimens.feedPageHorizontal,
AppDimens.followingSectionBottomGap,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
l10n.favoriteDramas,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingSectionTitleFont,
fontWeight: FontWeight.w400,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
l10n.totalDramasCount(controller.total),
style: const TextStyle(
color: AppColors.textTabInactive,
fontSize: AppDimens.followingSectionCountFont,
),
),
),
],
),
),
),
];
}
/// 最近一条观看记录(records 已按最近观看排序,与观看历史页首条一致)。
WatchRecord? _continueRecord() {
return history.records.isEmpty ? null : history.records.first;
}
Widget _item(
BuildContext context,
int index,
bool isEditing,
Set<String> picked,
) {
final l10n = AppLocalizations.of(context)!;
if (index == controller.cards.length) {
return FollowingAddCard(onTap: onGoHome);
}
final l10n = AppLocalizations.of(context)!;
final drama = controller.cards[index];
final record = history.recordForDrama(drama.id);
final episode = drama.episodes.isEmpty ? 1 : drama.episodes.first.number;
final watchedEpisode = record?.episodeNumber ?? 0;
final total = drama.episodeCount;
final subtitle = watchedEpisode > 0 && total > 0
? l10n.watchedEpisodeTotal(watchedEpisode, total)
: l10n.favoriteEpisodeProgress(episode, total);
return FollowingGridCard(
dramaId: drama.id,
title: drama.localizedTitle(Localizations.localeOf(context).languageCode),
@@ -102,7 +186,10 @@ class FollowingFavoritesGrid extends StatelessWidget {
viewCount: drama.viewCount,
editing: isEditing,
selected: isEditing ? picked.contains(drama.id) : null,
subtitle: l10n.favoriteEpisodeProgress(episode, drama.episodeCount),
subtitle: subtitle,
// TODO(backend): Drama 增加 hasUpdate / latestEpisodeNumber / isFinished
// 字段后,在此传 updateBadge(l10n.hasUpdateBadge)、红色更新副标题
// (l10n.updatedToEpisode)或灰色完结副标题(l10n.finishedAllEpisodes)。
onTap: () => _openOrToggle(context, drama.id, isEditing),
);
}
+44 -9
View File
@@ -25,6 +25,8 @@ class FollowingGridCard extends StatelessWidget {
this.editing = false,
this.selected,
this.onRemove,
this.updateBadge,
this.subtitleColor,
});
final String dramaId;
@@ -38,6 +40,13 @@ class FollowingGridCard extends StatelessWidget {
final bool? selected;
final VoidCallback? onRemove;
/// 左上角「有更新」角标文案;null 不显示。
/// 待后端在 Drama 增加更新状态字段后由收藏网格传入。
final String? updateBadge;
/// 副标题颜色;更新中用强调红,完结用默认灰。
final Color? subtitleColor;
@override
Widget build(BuildContext context) {
return InkWell(
@@ -109,6 +118,32 @@ class FollowingGridCard extends StatelessWidget {
count: viewCount,
),
),
if (updateBadge != null && selected == null)
Positioned(
left: AppDimens.followingBadgeInset,
top: AppDimens.followingBadgeInset,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.followingBadgePaddingH,
vertical: AppDimens.followingBadgePaddingV,
),
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingBadgeRadius,
),
),
child: Text(
updateBadge!,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: AppDimens.followingBadgeFont,
fontWeight: FontWeight.w400,
height: 1,
),
),
),
),
if (progress != null)
Positioned(
left: 0,
@@ -120,7 +155,7 @@ class FollowingGridCard extends StatelessWidget {
minHeight: AppDimens.followingProgressHeight,
backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
AppColors.brandRed,
),
),
),
@@ -130,13 +165,13 @@ class FollowingGridCard extends StatelessWidget {
const SizedBox(height: AppDimens.cardCoverTitleGap),
Text(
title,
maxLines: 2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.cardTitleFont,
fontWeight: FontWeight.w500,
fontSize: AppDimens.followingCardTitleFontNew,
fontWeight: FontWeight.w400,
height: AppDimens.cardTitleLineHeight,
),
),
@@ -146,9 +181,9 @@ class FollowingGridCard extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
color: AppColors.textTertiary,
fontSize: AppDimens.followingSubtitleFont,
style: TextStyle(
color: subtitleColor ?? AppColors.textTertiary,
fontSize: AppDimens.followingCardSubtitleFontNew,
),
),
],
@@ -169,9 +204,9 @@ class _SelectMark extends StatelessWidget {
height: _selectMarkSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? AppColors.blue : AppColors.transparent,
color: selected ? AppColors.brandRed : AppColors.transparent,
border: Border.all(
color: selected ? AppColors.blue : AppColors.textPrimary,
color: selected ? AppColors.brandRed : AppColors.textPrimary,
width: _selectBorderWidth,
),
),
@@ -158,7 +158,7 @@ class FollowingHistoryCard extends StatelessWidget {
minHeight: AppDimens.followingHistoryProgressHeight,
backgroundColor: AppColors.inactiveTrack,
valueColor: const AlwaysStoppedAnimation<Color>(
AppColors.primary,
AppColors.brandRed,
),
),
),
+161 -97
View File
@@ -17,28 +17,31 @@ import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/search_page.dart';
import '../player/open_player.dart';
import '../recommend/widgets/search_gift_glyphs.dart';
import 'following_edit_bar.dart';
import 'following_favorites_grid.dart';
import 'following_history_card.dart';
import 'following_keep_alive_tab.dart';
import 'following_paged_grid.dart';
const double _doneFontSize = 14;
const double _doneTextGap = 12;
class FollowingPage extends StatefulWidget {
const FollowingPage({
super.key,
required this.onGoHome,
required this.favoriteController,
this.historyRequest = 0,
this.visible = true,
});
final VoidCallback onGoHome;
final FavoriteController favoriteController;
final int historyRequest;
/// 当前是否为首页选中的 tab;重新可见时刷新观看历史,保证横幅是最新进度。
final bool visible;
@override
State<FollowingPage> createState() => _FollowingPageState();
}
@@ -102,6 +105,9 @@ class _FollowingPageState extends State<FollowingPage> {
@override
void didUpdateWidget(covariant FollowingPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (!oldWidget.visible && widget.visible) {
unawaited(_history.load());
}
if (oldWidget.historyRequest == widget.historyRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _selectTab(FollowingTab.history);
@@ -111,9 +117,21 @@ class _FollowingPageState extends State<FollowingPage> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return ColoredBox(
return DecoratedBox(
key: const Key('following-page'),
color: AppColors.appBackground,
// 纯黑底 + 顶部暗红光晕,与设计稿一致。
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.followingGlow,
AppColors.followingGlowMid,
AppColors.appBackground,
],
stops: [0, 0.14, 0.3],
),
),
child: SafeArea(
bottom: false,
child: Column(
@@ -128,6 +146,7 @@ class _FollowingPageState extends State<FollowingPage> {
FollowingKeepAliveTab(
child: FollowingFavoritesGrid(
controller: widget.favoriteController,
history: _history,
selection: FollowingFavoriteSelection(
editing: _editing,
selected: _selected,
@@ -135,6 +154,9 @@ class _FollowingPageState extends State<FollowingPage> {
),
scrollController: _favoriteScroll,
onGoHome: widget.onGoHome,
onContinueRecord: (record) => unawaited(
_openHistory(context, _history, record),
),
),
),
FollowingKeepAliveTab(
@@ -165,119 +187,165 @@ class _FollowingPageState extends State<FollowingPage> {
}
Widget _header(AppLocalizations l10n) {
return SizedBox(
return Padding(
key: const Key('following-header'),
height: AppDimens.followingHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.feedPageHorizontal,
),
child: ValueListenableBuilder<FollowingTab>(
valueListenable: _tab,
builder: (context, selectedTab, _) {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
padding: const EdgeInsets.fromLTRB(
AppDimens.feedPageHorizontal,
AppDimens.followingTitleTopGap,
AppDimens.feedPageHorizontal,
0,
),
child: Column(
children: [
SizedBox(
height: AppDimens.followingTitleRowHeight,
child: Row(
children: [
Expanded(
child: Text(
l10n.followingTab,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingTitleFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
_searchButton(l10n),
const SizedBox(width: AppDimens.followingHeaderActionGap),
_manageButton(l10n),
],
),
),
SizedBox(
height: AppDimens.followingSegmentRowHeight,
child: ValueListenableBuilder<FollowingTab>(
valueListenable: _tab,
builder: (context, selectedTab, _) {
return Row(
children: [
Expanded(
child: Row(
children: [
Flexible(
fit: FlexFit.loose,
child: _tabButton(
FollowingTab.favorites,
l10n.followingTab,
'favorites',
selectedTab == FollowingTab.favorites,
),
ListenableBuilder(
listenable: widget.favoriteController,
builder: (context, _) {
return _segment(
FollowingTab.favorites,
Text(
'${l10n.myFavorites} ${widget.favoriteController.total}',
maxLines: 1,
),
const SizedBox(width: AppDimens.followingTabGap),
Flexible(
fit: FlexFit.loose,
child: _tabButton(
FollowingTab.history,
l10n.watchHistory,
'history',
selectedTab == FollowingTab.history,
),
),
],
),
'favorites',
selectedTab == FollowingTab.favorites,
);
},
),
const SizedBox(width: AppDimens.followingSegmentGap),
_segment(
FollowingTab.history,
Text(l10n.watchHistory, maxLines: 1),
'history',
selectedTab == FollowingTab.history,
),
if (editing)
InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.only(left: _doneTextGap),
child: Text(
l10n.done,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: _doneFontSize,
fontWeight: FontWeight.w500,
),
),
),
)
else
Semantics(
button: true,
label: l10n.edit,
child: SizedBox(
width: AppDimens.followingEditHitSize,
height: AppDimens.followingEditHitSize,
child: InkWell(
key: const Key('following-edit'),
onTap: _toggleEditing,
child: const Align(
alignment: Alignment.centerRight,
child: Icon(
Icons.edit_outlined,
size: AppDimens.followingEditIconSize,
color: AppColors.textTabInactive,
),
),
),
),
),
],
);
},
);
},
),
),
],
),
);
}
Widget _searchButton(AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.search,
child: InkWell(
key: const Key('following-search'),
borderRadius: BorderRadius.circular(8),
onTap: () {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => const SearchPage()),
);
},
child: Padding(
padding: EdgeInsets.all(4),
child: SearchGlyph(
size: AppDimens.followingSearchIcon,
color: AppColors.textPrimary,
),
),
),
);
}
Widget _tabButton(
Widget _manageButton(AppLocalizations l10n) {
return ValueListenableBuilder<bool>(
valueListenable: _editing,
builder: (context, editing, _) {
return InkWell(
key: const Key('following-edit'),
borderRadius: BorderRadius.circular(8),
onTap: _toggleEditing,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 8),
child: Text(
editing ? l10n.done : l10n.manage,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: AppDimens.followingManageFont,
fontWeight: FontWeight.w400,
height: 1.0,
),
),
),
);
},
);
}
Widget _segment(
FollowingTab tab,
String label,
Widget label,
String keyName,
bool selected,
) {
return InkWell(
key: Key('following-tab-$keyName'),
onTap: () => _selectTab(tab),
child: SizedBox(
height: AppDimens.followingHeaderHeight,
child: Align(
alignment: Alignment.centerLeft,
widthFactor: 1,
child: AnimatedDefaultTextStyle(
borderRadius: BorderRadius.circular(8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedDefaultTextStyle(
duration: AppDimens.followingTabAnimationDuration,
style: TextStyle(
color: selected
? AppColors.textPrimary
: AppColors.textTabInactive,
fontSize: AppDimens.followingTabFont,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
fontSize: AppDimens.followingSegmentFont,
fontWeight: FontWeight.w400,
),
child: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
child: label,
),
),
const SizedBox(height: AppDimens.followingSegmentIndicatorTopGap),
AnimatedContainer(
duration: AppDimens.followingTabAnimationDuration,
curve: Curves.easeOut,
width: selected
? AppDimens.followingSegmentIndicatorWidth
: 0,
height: AppDimens.followingSegmentIndicatorHeight,
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingSegmentIndicatorRadius,
),
),
),
],
),
);
}
@@ -329,10 +397,7 @@ class _FollowingPageState extends State<FollowingPage> {
Future<void> _cancelSelected() async {
final ids = _selected.value.toList();
if (ids.isEmpty || !mounted || !await confirmCancelFollowing(context)) {
return;
}
if (!mounted) return;
if (ids.isEmpty || !mounted) return;
final removed = await widget.favoriteController.removeMany(ids);
if (!removed || !mounted) return;
_selected.value = {};
@@ -424,8 +489,7 @@ class _HistoryList extends StatelessWidget {
onTap: openRecord,
onContinue: openRecord,
onRemove: () async {
if (await confirmHistoryRemove(context) &&
await controller.remove(record.dramaId) &&
if (await controller.remove(record.dramaId) &&
controller.records.isEmpty) {
onListEmptied();
}
@@ -28,6 +28,7 @@ class FollowingPagedGrid extends StatelessWidget {
this.singleColumn = false,
this.errorMessage,
this.loadMoreError,
this.topSlivers = const [],
});
static const double _loadMoreThreshold = 240;
@@ -44,6 +45,10 @@ class FollowingPagedGrid extends StatelessWidget {
final String? loadMoreError;
final bool showInitialLoading;
final bool singleColumn;
/// 列表顶部的非分页内容(继续观看横幅、区块标题等)。
/// 必须是 sliver;空列表时也会保留,因此可携带空态上方的固定区块。
final List<Widget> topSlivers;
final Future<void> Function() onRefresh;
final Future<void> Function() onLoadMore;
@@ -81,6 +86,7 @@ class FollowingPagedGrid extends StatelessWidget {
controller: scrollController,
physics: AppPullToRefresh.physics,
slivers: [
...topSlivers,
if (itemCount == 0)
SliverFillRemaining(
hasScrollBody: false,
@@ -0,0 +1,207 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/watch_record.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 追剧页「继续观看」大横幅:取最近一条观看记录,点击续播。
class ContinueWatchingBanner extends StatelessWidget {
const ContinueWatchingBanner({
super.key,
required this.record,
required this.languageCode,
required this.onTap,
});
final WatchRecord record;
final String languageCode;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final episode = record.episodeNumber;
final total =
record.progress.totalEpisodes ?? record.drama?.episodeCount ?? 0;
final title = record.localizedDisplayTitle(languageCode);
final showMeta = episode > 0 && total > 0;
return SizedBox(
height: AppDimens.followingBannerHeight,
child: ClipRRect(
borderRadius:
BorderRadius.circular(AppDimens.followingBannerRadius),
child: Stack(
fit: StackFit.expand,
children: [
RemoteImage(
record.displayCover,
fit: BoxFit.cover,
showFallbackIcon: true,
fallbackColor: AppColors.contentSurface,
),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Color(0xCC000000),
Color(0x99000000),
Color(0x00000000),
],
stops: [0, 0.45, 1],
),
),
),
Material(
type: MaterialType.transparency,
child: InkWell(
key: const Key('following-continue-banner'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(
AppDimens.followingBannerInnerGap,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title.isNotEmpty)
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.bannerTitleWarm,
fontSize:
AppDimens.followingBannerTitleFont,
fontWeight: FontWeight.w400,
),
),
if (showMeta) ...[
const SizedBox(
height: AppDimens.followingBannerMetaGap,
),
Text(
l10n.continueWatchingSubtitle(episode, total),
maxLines: 1,
style: const TextStyle(
color: Color(0xB3F5EFE4),
fontSize:
AppDimens.followingBannerSubtitleFont,
),
),
],
const SizedBox(
height: AppDimens.followingBannerButtonTopGap,
),
_ContinueButton(label: l10n.continueWatching),
],
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: _ProgressBar(fraction: record.progress.fraction),
),
],
),
),
),
),
],
),
),
);
}
}
class _ContinueButton extends StatelessWidget {
const _ContinueButton({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.brandRed,
borderRadius: BorderRadius.circular(
AppDimens.followingBannerButtonRadius,
),
),
child: SizedBox(
height: AppDimens.followingBannerButtonHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal:
AppDimens.followingBannerButtonHorizontalPadding,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.play_arrow_rounded,
size: AppDimens.followingBannerButtonIcon,
color: AppColors.onPrimary,
),
const SizedBox(width: AppDimens.followingBannerButtonGap),
Text(
label,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: AppDimens.followingBannerButtonFont,
fontWeight: FontWeight.w400,
),
),
],
),
),
),
);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({required this.fraction});
final double fraction;
@override
Widget build(BuildContext context) {
// SizedBox 给紧约束让 DecoratedBox 轨道撑满整宽;
// 不能用无子 ColoredBox 直接放 Stack(会缩成 0×0 隐形)。
return ClipRRect(
borderRadius: BorderRadius.circular(
AppDimens.followingBannerProgressHeight,
),
child: SizedBox(
height: AppDimens.followingBannerProgressHeight,
width: double.infinity,
child: DecoratedBox(
decoration: const BoxDecoration(
color: Color(0x66FFFFFF),
),
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: fraction.clamp(0.0, 1.0).toDouble(),
// 无 child 的 ColoredBox 在松约束下会缩成 0×0,高度必须显式占满。
heightFactor: 1.0,
child: const ColoredBox(color: AppColors.brandRed),
),
),
),
),
);
}
}