fix(ui): 打磨推荐追剧我的设置页间距与图标
This commit is contained in:
1 parent
de7da64886
commit
5346be40a9
33 files changed
+1408
-811
No files matched your search
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user