feat(flutter): 按参考图重做首页 feed 浮层
This commit is contained in:
1 parent
d6b055dc62
commit
ee6f0d7e72
14 files changed
+1062
-398
No files matched your search
@@ -29,6 +29,26 @@
|
||||
"romance": "Romance",
|
||||
"actionGenre": "Action",
|
||||
"filter": "Filter",
|
||||
"danmaku": "Danmaku",
|
||||
"watchFullscreen": "Full screen",
|
||||
"seriesMeta": "Series · {episodes} eps | {viewers} watching",
|
||||
"@seriesMeta": {
|
||||
"placeholders": {
|
||||
"episodes": {"type": "String"},
|
||||
"viewers": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"watchFullSeries": "Watch full series · {episodes} eps",
|
||||
"@watchFullSeries": {
|
||||
"placeholders": {
|
||||
"episodes": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"feedTabFollowing": "Following",
|
||||
"feedTabAnime": "Anime",
|
||||
"feedTabReal": "Live-action",
|
||||
"feedTabForYou": "For you",
|
||||
"authorNoteLabel": "Author note",
|
||||
"reserve": "Reserve",
|
||||
"heatLabel": "{count} heat",
|
||||
"@heatLabel": {
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
"romance": "Romantis",
|
||||
"actionGenre": "Aksi",
|
||||
"filter": "Filter",
|
||||
"danmaku": "Danmaku",
|
||||
"watchFullscreen": "Layar penuh",
|
||||
"seriesMeta": "Serial · {episodes} eps | {viewers} menonton",
|
||||
"watchFullSeries": "Tonton serial lengkap · {episodes} eps",
|
||||
"feedTabFollowing": "Mengikuti",
|
||||
"feedTabAnime": "Anime",
|
||||
"feedTabReal": "Live-action",
|
||||
"feedTabForYou": "Untukmu",
|
||||
"authorNoteLabel": "Catatan penulis",
|
||||
"reserve": "Reservasi",
|
||||
"heatLabel": "{count} panas",
|
||||
"topCharts": "Peringkat Teratas",
|
||||
|
||||
@@ -274,6 +274,60 @@ abstract class AppLocalizations {
|
||||
/// **'Filter'**
|
||||
String get filter;
|
||||
|
||||
/// No description provided for @danmaku.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Danmaku'**
|
||||
String get danmaku;
|
||||
|
||||
/// No description provided for @watchFullscreen.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Full screen'**
|
||||
String get watchFullscreen;
|
||||
|
||||
/// No description provided for @seriesMeta.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Series · {episodes} eps | {viewers} watching'**
|
||||
String seriesMeta(String episodes, String viewers);
|
||||
|
||||
/// No description provided for @watchFullSeries.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Watch full series · {episodes} eps'**
|
||||
String watchFullSeries(String episodes);
|
||||
|
||||
/// No description provided for @feedTabFollowing.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Following'**
|
||||
String get feedTabFollowing;
|
||||
|
||||
/// No description provided for @feedTabAnime.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Anime'**
|
||||
String get feedTabAnime;
|
||||
|
||||
/// No description provided for @feedTabReal.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Live-action'**
|
||||
String get feedTabReal;
|
||||
|
||||
/// No description provided for @feedTabForYou.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'For you'**
|
||||
String get feedTabForYou;
|
||||
|
||||
/// No description provided for @authorNoteLabel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Author note'**
|
||||
String get authorNoteLabel;
|
||||
|
||||
/// No description provided for @reserve.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
@@ -2686,12 +2740,6 @@ abstract class AppLocalizations {
|
||||
/// **'Clear-screen playback'**
|
||||
String get clearPlayback;
|
||||
|
||||
/// No description provided for @danmaku.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Danmaku'**
|
||||
String get danmaku;
|
||||
|
||||
/// No description provided for @danmakuUnavailable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -98,6 +98,37 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get danmaku => 'Danmaku';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => 'Full screen';
|
||||
|
||||
@override
|
||||
String seriesMeta(String episodes, String viewers) {
|
||||
return 'Series · $episodes eps | $viewers watching';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchFullSeries(String episodes) {
|
||||
return 'Watch full series · $episodes eps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedTabFollowing => 'Following';
|
||||
|
||||
@override
|
||||
String get feedTabAnime => 'Anime';
|
||||
|
||||
@override
|
||||
String get feedTabReal => 'Live-action';
|
||||
|
||||
@override
|
||||
String get feedTabForYou => 'For you';
|
||||
|
||||
@override
|
||||
String get authorNoteLabel => 'Author note';
|
||||
|
||||
@override
|
||||
String get reserve => 'Reserve';
|
||||
|
||||
@@ -1396,9 +1427,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get clearPlayback => 'Clear-screen playback';
|
||||
|
||||
@override
|
||||
String get danmaku => 'Danmaku';
|
||||
|
||||
@override
|
||||
String get danmakuUnavailable => 'Coming soon';
|
||||
|
||||
|
||||
@@ -98,6 +98,37 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get filter => 'Filter';
|
||||
|
||||
@override
|
||||
String get danmaku => 'Komentar layar';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => 'Layar penuh';
|
||||
|
||||
@override
|
||||
String seriesMeta(String episodes, String viewers) {
|
||||
return 'Serial · $episodes eps | $viewers menonton';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchFullSeries(String episodes) {
|
||||
return 'Tonton serial lengkap · $episodes eps';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedTabFollowing => 'Mengikuti';
|
||||
|
||||
@override
|
||||
String get feedTabAnime => 'Anime';
|
||||
|
||||
@override
|
||||
String get feedTabReal => 'Live-action';
|
||||
|
||||
@override
|
||||
String get feedTabForYou => 'Untukmu';
|
||||
|
||||
@override
|
||||
String get authorNoteLabel => 'Catatan penulis';
|
||||
|
||||
@override
|
||||
String get reserve => 'Reservasi';
|
||||
|
||||
@@ -1396,9 +1427,6 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
@override
|
||||
String get clearPlayback => 'Putar tanpa kontrol';
|
||||
|
||||
@override
|
||||
String get danmaku => 'Komentar layar';
|
||||
|
||||
@override
|
||||
String get danmakuUnavailable => 'Belum tersedia';
|
||||
|
||||
|
||||
@@ -95,6 +95,37 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get filter => '筛选';
|
||||
|
||||
@override
|
||||
String get danmaku => '弹幕';
|
||||
|
||||
@override
|
||||
String get watchFullscreen => '全屏观看';
|
||||
|
||||
@override
|
||||
String seriesMeta(String episodes, String viewers) {
|
||||
return '系列剧 · 共$episodes集 | 共$viewers人在追';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchFullSeries(String episodes) {
|
||||
return '观看完整剧集 · 全$episodes集';
|
||||
}
|
||||
|
||||
@override
|
||||
String get feedTabFollowing => '关注';
|
||||
|
||||
@override
|
||||
String get feedTabAnime => '漫剧';
|
||||
|
||||
@override
|
||||
String get feedTabReal => '真人剧';
|
||||
|
||||
@override
|
||||
String get feedTabForYou => '推荐';
|
||||
|
||||
@override
|
||||
String get authorNoteLabel => '作者声明';
|
||||
|
||||
@override
|
||||
String get reserve => '预约';
|
||||
|
||||
@@ -1372,9 +1403,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get clearPlayback => '清屏播放';
|
||||
|
||||
@override
|
||||
String get danmaku => '弹幕';
|
||||
|
||||
@override
|
||||
String get danmakuUnavailable => '暂未开放';
|
||||
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
"romance": "都市爱情",
|
||||
"actionGenre": "动作",
|
||||
"filter": "筛选",
|
||||
"danmaku": "弹幕",
|
||||
"watchFullscreen": "全屏观看",
|
||||
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
|
||||
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
|
||||
"feedTabFollowing": "关注",
|
||||
"feedTabAnime": "漫剧",
|
||||
"feedTabReal": "真人剧",
|
||||
"feedTabForYou": "推荐",
|
||||
"authorNoteLabel": "作者声明",
|
||||
"reserve": "预约",
|
||||
"heatLabel": "{count}热度",
|
||||
"topCharts": "排行榜",
|
||||
|
||||
@@ -367,8 +367,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
l10n: l10n,
|
||||
drama: drama,
|
||||
episode: episode,
|
||||
controller: ready ? videoPort!.controller : null,
|
||||
liked: interactions.isLiked(drama.id),
|
||||
likeCount: interactions.likeCount(drama.id),
|
||||
likeCount:
|
||||
interactions.likeCount(drama.id) ?? drama.likeCount,
|
||||
favoriteCount: drama.favoriteCount == null
|
||||
? null
|
||||
: drama.favoriteCount! + (_favoriteDelta[drama.id] ?? 0),
|
||||
commentCount: drama.commentCount,
|
||||
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||
onLike: () async {
|
||||
if (await ensureSignedIn(context)) {
|
||||
@@ -404,6 +410,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
||||
onComments: () => logic.showComments(context, drama),
|
||||
onShare: () => logic.shareDrama(context, drama),
|
||||
onEpisodes: () => logic.showEpisodes(context),
|
||||
onDetail: () => unawaited(logic.openDetail(context)),
|
||||
onFullscreen: () =>
|
||||
unawaited(_openFullscreen(context, drama, episode)),
|
||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||
onClear: () => setState(() => _chromeVisible = false),
|
||||
onSearch:
|
||||
|
||||
@@ -43,6 +43,25 @@ extension _PlayerViewActions on _PlayerPageState {
|
||||
);
|
||||
}
|
||||
|
||||
/// 首页 feed 的「全屏观看」:以当前剧集为起点打开全屏播放页。
|
||||
Future<void> _openFullscreen(
|
||||
BuildContext context,
|
||||
Drama drama,
|
||||
Episode episode,
|
||||
) async {
|
||||
final index = drama.episodes.indexWhere((item) => item.id == episode.id);
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlayerPage(
|
||||
drama: drama,
|
||||
initialIndex: index < 0 ? 0 : index,
|
||||
wallet: widget.wallet,
|
||||
downloads: widget.downloads,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openUnlockSheet(
|
||||
BuildContext context,
|
||||
int index,
|
||||
|
||||
@@ -1,422 +1,479 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/formatting/count_format.dart';
|
||||
import '../../../core/formatting/localized_content.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/widgets/glass_panel.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
|
||||
class HomeFeedOverlay extends StatelessWidget {
|
||||
part 'home_feed_overlay_parts.dart';
|
||||
|
||||
const Color _overlayPill = Color(0x66000000);
|
||||
const Color _fullBarColor = Color(0xCC000000);
|
||||
const double _topIconSize = 36;
|
||||
const double _topIconGlyph = 22;
|
||||
const double _pillHeight = 34;
|
||||
const double _tabGap = 22;
|
||||
const double _tabFontSize = 15;
|
||||
const double _underlineWidth = 18;
|
||||
const double _underlineHeight = 2;
|
||||
const double _titleFontSize = 17;
|
||||
const double _metaFontSize = 12;
|
||||
const double _tagFontSize = 11;
|
||||
const double _railIconSize = 22;
|
||||
const double _fullBarHeight = 48;
|
||||
const double _progressHeight = 2;
|
||||
const int _maxTags = 4;
|
||||
final RegExp _tagSplitter = RegExp(r'[,/、·]');
|
||||
|
||||
/// 首页沉浸式 feed 浮层:顶部频道 tab、右侧互动栏、底部信息与整集进度条。
|
||||
class HomeFeedOverlay extends StatefulWidget {
|
||||
const HomeFeedOverlay({
|
||||
super.key,
|
||||
required this.l10n,
|
||||
required this.drama,
|
||||
required this.episode,
|
||||
required this.controller,
|
||||
required this.liked,
|
||||
this.likeCount,
|
||||
required this.saved,
|
||||
required this.onLike,
|
||||
required this.onSave,
|
||||
required this.onComments,
|
||||
required this.onShare,
|
||||
required this.onEpisodes,
|
||||
required this.onDetail,
|
||||
required this.onFullscreen,
|
||||
required this.onDownload,
|
||||
required this.onClear,
|
||||
required this.onSearch,
|
||||
this.likeCount,
|
||||
this.favoriteCount,
|
||||
this.commentCount,
|
||||
this.authorNote = '',
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final Drama drama;
|
||||
final Episode episode;
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
final bool liked;
|
||||
final int? likeCount;
|
||||
final int? favoriteCount;
|
||||
final int? commentCount;
|
||||
final bool saved;
|
||||
final String authorNote;
|
||||
final VoidCallback onLike;
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onComments;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onEpisodes;
|
||||
final VoidCallback onDetail;
|
||||
final VoidCallback onFullscreen;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback onSearch;
|
||||
final VoidCallback? onPip;
|
||||
|
||||
@override
|
||||
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||
}
|
||||
|
||||
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||
_FeedTab _tab = _FeedTab.recommend;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
_header(context),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: const Alignment(1.0, 0.45),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 16),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(child: _gradient(top: true)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||
child: _infoCard(),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(child: _gradient(top: false)),
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
_header(context),
|
||||
Expanded(
|
||||
child: Align(
|
||||
alignment: const Alignment(1.0, 0.45),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 0, 10, 16),
|
||||
child: _rail(),
|
||||
),
|
||||
),
|
||||
),
|
||||
_bottomInfo(),
|
||||
_fullSeriesBar(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _gradient({required bool top}) {
|
||||
return Container(
|
||||
height: top ? 140 : 360,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: top ? Alignment.topCenter : Alignment.bottomCenter,
|
||||
end: top ? Alignment.bottomCenter : Alignment.topCenter,
|
||||
colors: top
|
||||
? const [Color(0xB3000000), Color(0x00000000)]
|
||||
: const [Color(0xE6000000), Color(0x00000000)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_TopIconButton(
|
||||
key: const Key('home-feed-menu'),
|
||||
tooltip: widget.l10n.more,
|
||||
icon: Icons.menu_rounded,
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
onPressed: () => _openMore(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
localizedDramaTitle(l10n, drama),
|
||||
key: const Key('home-feed-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < _FeedTab.values.length; index++)
|
||||
_tabSlot(_FeedTab.values[index], index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_RoundIcon(
|
||||
_TopIconButton(
|
||||
key: const Key('home-feed-search'),
|
||||
tooltip: l10n.search,
|
||||
tooltip: widget.l10n.search,
|
||||
icon: Icons.search_rounded,
|
||||
onPressed: onSearch,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-clear-screen'),
|
||||
tooltip: l10n.clearScreen,
|
||||
icon: Icons.fullscreen_rounded,
|
||||
onPressed: onClear,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_RoundIcon(
|
||||
key: const Key('home-feed-more'),
|
||||
tooltip: l10n.more,
|
||||
icon: Icons.menu_rounded,
|
||||
onPressed: () => _openMore(context),
|
||||
dimension: _topIconSize,
|
||||
glyphSize: _topIconGlyph,
|
||||
onPressed: widget.onSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabSlot(_FeedTab tab, int index) {
|
||||
final item = _FeedTabItem(
|
||||
tab: tab,
|
||||
label: tab.label(widget.l10n),
|
||||
selected: _tab == tab,
|
||||
fontSize: _tabFontSize,
|
||||
underlineWidth: _underlineWidth,
|
||||
underlineHeight: _underlineHeight,
|
||||
onTap: () => _selectTab(tab),
|
||||
);
|
||||
if (index == 0) return item;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: _tabGap),
|
||||
child: item,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectTab(_FeedTab tab) {
|
||||
if (tab.comingSoon) {
|
||||
_showComingSoon();
|
||||
return;
|
||||
}
|
||||
setState(() => _tab = tab);
|
||||
}
|
||||
|
||||
Widget _rail() {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RailItem(
|
||||
key: const Key('rail-like'),
|
||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
label: likeCount?.toString() ?? l10n.like,
|
||||
countKey: likeCount == null ? null : const Key('rail-like-count'),
|
||||
selected: liked,
|
||||
onTap: onLike,
|
||||
_FeedRailItem(
|
||||
key: const Key('rail-save'),
|
||||
icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
count: widget.favoriteCount,
|
||||
countKey: const Key('rail-save-count'),
|
||||
label: widget.l10n.favorite,
|
||||
selected: widget.saved,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onSave,
|
||||
),
|
||||
_RailItem(
|
||||
_FeedRailItem(
|
||||
key: const Key('rail-comments'),
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.comments,
|
||||
onTap: onComments,
|
||||
count: widget.commentCount,
|
||||
countKey: const Key('rail-comments-count'),
|
||||
label: widget.l10n.comments,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onComments,
|
||||
),
|
||||
_RailItem(
|
||||
_FeedRailItem(
|
||||
key: const Key('rail-like'),
|
||||
icon: widget.liked
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_border_rounded,
|
||||
count: widget.likeCount,
|
||||
countKey: const Key('rail-like-count'),
|
||||
label: widget.l10n.like,
|
||||
selected: widget.liked,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onLike,
|
||||
),
|
||||
_FeedRailItem(
|
||||
key: const Key('rail-share'),
|
||||
icon: Icons.ios_share_rounded,
|
||||
label: l10n.share,
|
||||
onTap: onShare,
|
||||
),
|
||||
_RailItem(
|
||||
key: const Key('rail-save'),
|
||||
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||
label: l10n.favorite,
|
||||
selected: saved,
|
||||
onTap: onSave,
|
||||
label: widget.l10n.share,
|
||||
iconSize: _railIconSize,
|
||||
onTap: widget.onShare,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoCard() {
|
||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
||||
return GlassPanel(
|
||||
key: const Key('home-feed-info-card'),
|
||||
radius: 14,
|
||||
blur: 6,
|
||||
padding: const EdgeInsets.all(12),
|
||||
borderColor: AppColors.border,
|
||||
Widget _bottomInfo() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
Row(
|
||||
children: [
|
||||
if (genre.isNotEmpty)
|
||||
_tag(
|
||||
genre,
|
||||
background: AppColors.blue,
|
||||
foreground: AppColors.onPrimary,
|
||||
Flexible(
|
||||
child: _DanmakuPill(
|
||||
l10n: widget.l10n,
|
||||
height: _pillHeight,
|
||||
background: _overlayPill,
|
||||
onTap: _showComingSoon,
|
||||
),
|
||||
if (drama.category.isNotEmpty && drama.category != genre)
|
||||
_tag(
|
||||
drama.category,
|
||||
background: AppColors.raised,
|
||||
foreground: AppColors.onSurface,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: _FullscreenPill(
|
||||
l10n: widget.l10n,
|
||||
height: _pillHeight,
|
||||
background: _overlayPill,
|
||||
onTap: widget.onFullscreen,
|
||||
),
|
||||
Text(
|
||||
l10n.episodeOfTotal(episode.number, drama.episodeCount),
|
||||
key: const Key('home-feed-episode'),
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
episode.title.trim().isEmpty
|
||||
? localizedDramaTitle(l10n, drama)
|
||||
: episode.title,
|
||||
key: const Key('home-feed-episode-title'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
_seriesMetaRow(),
|
||||
const SizedBox(height: 2),
|
||||
_titleRow(),
|
||||
const SizedBox(height: 6),
|
||||
_tagsRow(),
|
||||
if (widget.authorNote.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${widget.l10n.authorNoteLabel}: ${widget.authorNote}',
|
||||
key: const Key('home-feed-author-note'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _seriesMetaRow() {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-series-meta'),
|
||||
onTap: widget.onDetail,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.l10n.seriesMeta(
|
||||
'${widget.drama.episodeCount}',
|
||||
formatInteractionCount(widget.drama.viewCount),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: _metaFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton.icon(
|
||||
key: const Key('home-feed-follow-series'),
|
||||
onPressed: onSave,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 16),
|
||||
label: Text(
|
||||
saved ? l10n.followingTab : l10n.followSeries,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 34,
|
||||
child: FilledButton(
|
||||
key: const Key('home-feed-episodes'),
|
||||
onPressed: onEpisodes,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.raised,
|
||||
foregroundColor: AppColors.onSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppDimens.buttonRadius,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
l10n.episodes,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white70,
|
||||
size: 16,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tag(
|
||||
String label, {
|
||||
required Color background,
|
||||
required Color foreground,
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
Widget _titleRow() {
|
||||
return GestureDetector(
|
||||
onTap: widget.onDetail,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
localizedDramaTitle(widget.l10n, widget.drama),
|
||||
key: const Key('home-feed-title'),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Outfit',
|
||||
color: Colors.white,
|
||||
fontSize: _titleFontSize,
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w800,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tagsRow() {
|
||||
final tags = [
|
||||
widget.l10n.episodeLabel(widget.episode.number),
|
||||
..._tagValues(),
|
||||
].take(_maxTags).toList();
|
||||
return Wrap(
|
||||
key: const Key('home-feed-tags'),
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final tag in tags)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _overlayPill,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: _tagFontSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _tagValues() {
|
||||
final result = <String>[];
|
||||
for (final value in [widget.drama.genre, widget.drama.category]) {
|
||||
for (final part in value.split(_tagSplitter)) {
|
||||
final tag = part.trim();
|
||||
if (tag.isEmpty || result.contains(tag)) continue;
|
||||
result.add(tag);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _fullSeriesBar() {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-full-series'),
|
||||
onTap: widget.onEpisodes,
|
||||
child: Container(
|
||||
height: _fullBarHeight,
|
||||
color: _fullBarColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 14,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.l10n.watchFullSeries(
|
||||
'${widget.drama.episodeCount}',
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: _FeedProgressTrack(
|
||||
controller: widget.controller,
|
||||
height: _progressHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showComingSoon() {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
|
||||
}
|
||||
|
||||
Future<void> _openMore(BuildContext context) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.card,
|
||||
showDragHandle: true,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('home-feed-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onDownload();
|
||||
},
|
||||
),
|
||||
if (onPip != null)
|
||||
ListTile(
|
||||
key: const Key('home-feed-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
onPip!();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundIcon extends StatelessWidget {
|
||||
const _RoundIcon({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.card.withValues(alpha: 0.94),
|
||||
foregroundColor: AppColors.onSurface,
|
||||
),
|
||||
icon: Icon(icon, size: 18),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RailItem extends StatelessWidget {
|
||||
const _RailItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
this.countKey,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
final Key? countKey;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: SizedBox(
|
||||
width: 44,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 22,
|
||||
color: selected ? AppColors.blue : AppColors.onSurface,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
key: countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.cream,
|
||||
fontSize: 9,
|
||||
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
builder: (sheetContext) => _FeedMoreSheet(
|
||||
l10n: widget.l10n,
|
||||
onDownload: widget.onDownload,
|
||||
onPip: widget.onPip,
|
||||
onClear: widget.onClear,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
part of 'home_feed_overlay.dart';
|
||||
|
||||
/// 顶部频道 tab:关注/漫剧点击提示即将上线。
|
||||
enum _FeedTab {
|
||||
following,
|
||||
anime,
|
||||
real,
|
||||
recommend;
|
||||
|
||||
bool get comingSoon => this == _FeedTab.following || this == _FeedTab.anime;
|
||||
|
||||
String get keyName => switch (this) {
|
||||
_FeedTab.following => 'follow',
|
||||
_FeedTab.anime => 'anime',
|
||||
_FeedTab.real => 'real',
|
||||
_FeedTab.recommend => 'recommend',
|
||||
};
|
||||
|
||||
String label(AppLocalizations l10n) => switch (this) {
|
||||
_FeedTab.following => l10n.feedTabFollowing,
|
||||
_FeedTab.anime => l10n.feedTabAnime,
|
||||
_FeedTab.real => l10n.feedTabReal,
|
||||
_FeedTab.recommend => l10n.feedTabForYou,
|
||||
};
|
||||
}
|
||||
|
||||
class _TopIconButton extends StatelessWidget {
|
||||
const _TopIconButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.dimension,
|
||||
required this.glyphSize,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final double dimension;
|
||||
final double glyphSize;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: dimension,
|
||||
child: IconButton(
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
padding: EdgeInsets.zero,
|
||||
icon: Icon(
|
||||
icon,
|
||||
size: glyphSize,
|
||||
color: Colors.white,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedTabItem extends StatelessWidget {
|
||||
const _FeedTabItem({
|
||||
required this.tab,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.fontSize,
|
||||
required this.underlineWidth,
|
||||
required this.underlineHeight,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final _FeedTab tab;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final double fontSize;
|
||||
final double underlineWidth;
|
||||
final double underlineHeight;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: Key('home-feed-tab-${tab.keyName}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : Colors.white70,
|
||||
fontSize: fontSize,
|
||||
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: underlineWidth,
|
||||
height: underlineHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedRailItem extends StatelessWidget {
|
||||
const _FeedRailItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.iconSize,
|
||||
required this.onTap,
|
||||
this.count,
|
||||
this.countKey,
|
||||
this.selected = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final double iconSize;
|
||||
final VoidCallback onTap;
|
||||
final int? count;
|
||||
final Key? countKey;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: label,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: selected ? AppColors.blue : Colors.white,
|
||||
shadows: const [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
count == null ? label : formatInteractionCount(count!),
|
||||
key: countKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
shadows: [Shadow(blurRadius: 6, color: Colors.black)],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedPill extends StatelessWidget {
|
||||
const _FeedPill({
|
||||
required this.height,
|
||||
required this.background,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final double height;
|
||||
final Color background;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Center(child: child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DanmakuPill extends StatelessWidget {
|
||||
const _DanmakuPill({
|
||||
required this.l10n,
|
||||
required this.height,
|
||||
required this.background,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final double height;
|
||||
final Color background;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-danmaku'),
|
||||
onTap: onTap,
|
||||
child: _FeedPill(
|
||||
height: height,
|
||||
background: background,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'弹',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.danmaku,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FullscreenPill extends StatelessWidget {
|
||||
const _FullscreenPill({
|
||||
required this.l10n,
|
||||
required this.height,
|
||||
required this.background,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final double height;
|
||||
final Color background;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: const Key('home-feed-fullscreen'),
|
||||
onTap: onTap,
|
||||
child: _FeedPill(
|
||||
height: height,
|
||||
background: background,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.fullscreen_rounded, color: Colors.white, size: 14),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
l10n.watchFullscreen,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedProgressTrack extends StatelessWidget {
|
||||
const _FeedProgressTrack({required this.controller, required this.height});
|
||||
|
||||
final ValueListenable<VideoPlayerValue>? controller;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final source = controller;
|
||||
if (source == null) return _track(0);
|
||||
return ValueListenableBuilder<VideoPlayerValue>(
|
||||
valueListenable: source,
|
||||
builder: (context, value, _) {
|
||||
final total = value.duration.inMilliseconds;
|
||||
final fraction = total <= 0
|
||||
? 0.0
|
||||
: (value.position.inMilliseconds / total).clamp(0.0, 1.0);
|
||||
return _track(fraction.toDouble());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _track(double fraction) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SizedBox(
|
||||
height: height,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: constraints.maxWidth,
|
||||
height: height,
|
||||
color: Colors.white24,
|
||||
),
|
||||
Container(
|
||||
width: constraints.maxWidth * fraction,
|
||||
height: height,
|
||||
color: AppColors.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeedMoreSheet extends StatelessWidget {
|
||||
const _FeedMoreSheet({
|
||||
required this.l10n,
|
||||
required this.onDownload,
|
||||
required this.onClear,
|
||||
this.onPip,
|
||||
});
|
||||
|
||||
final AppLocalizations l10n;
|
||||
final VoidCallback onDownload;
|
||||
final VoidCallback onClear;
|
||||
final VoidCallback? onPip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
key: const Key('home-feed-download'),
|
||||
leading: const Icon(
|
||||
Icons.download_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.downloads),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onDownload();
|
||||
},
|
||||
),
|
||||
if (onPip != null)
|
||||
ListTile(
|
||||
key: const Key('home-feed-pip'),
|
||||
leading: const Icon(
|
||||
Icons.picture_in_picture_alt_outlined,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.pictureInPicture),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onPip!();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
key: const Key('home-feed-clear'),
|
||||
leading: const Icon(
|
||||
Icons.fullscreen_rounded,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
title: Text(l10n.clearScreen),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onClear();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,75 +60,29 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||
expect(find.byType(PageView), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-browse-page')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('should_use_episode_title_then_drama_title_as_fallback', (
|
||||
testWidgets('should_render_new_feed_structure_with_formatted_counts', (
|
||||
tester,
|
||||
) async {
|
||||
Future<void> pumpEpisode(Episode episode) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
locale: const Locale('en'),
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: HomeFeedOverlay(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
drama: _drama,
|
||||
episode: episode,
|
||||
liked: false,
|
||||
saved: false,
|
||||
onLike: () {},
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await pumpEpisode(
|
||||
const Episode(
|
||||
id: 'featured',
|
||||
number: 2,
|
||||
title: 'Midnight Contract',
|
||||
isFree: true,
|
||||
),
|
||||
const drama = Drama(
|
||||
id: 'feed-drama',
|
||||
title: 'Feed Drama',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
genre: 'Romance',
|
||||
category: 'Urban',
|
||||
totalEpisodes: 8,
|
||||
freeEpisodes: 8,
|
||||
viewCount: 419000,
|
||||
likeCount: 1006,
|
||||
favoriteCount: 32000,
|
||||
commentCount: 12,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(find.byKey(const Key('home-feed-episode-title')))
|
||||
.data,
|
||||
'Midnight Contract',
|
||||
);
|
||||
|
||||
await pumpEpisode(
|
||||
const Episode(id: 'fallback', number: 2, title: '', isFree: true),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(find.byKey(const Key('home-feed-episode-title')))
|
||||
.data,
|
||||
'Fallback Drama',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_show_server_like_count_when_count_is_available', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
@@ -139,21 +93,26 @@ void main() {
|
||||
builder: (context) => Scaffold(
|
||||
body: HomeFeedOverlay(
|
||||
l10n: AppLocalizations.of(context)!,
|
||||
drama: _drama,
|
||||
drama: drama,
|
||||
episode: const Episode(
|
||||
id: 'count-episode',
|
||||
number: 1,
|
||||
title: 'Count Episode',
|
||||
id: 'feed-episode',
|
||||
number: 2,
|
||||
title: 'Ignored Episode Title',
|
||||
isFree: true,
|
||||
),
|
||||
controller: null,
|
||||
liked: true,
|
||||
likeCount: 128,
|
||||
saved: false,
|
||||
likeCount: 1006,
|
||||
favoriteCount: 32000,
|
||||
commentCount: 12,
|
||||
onLike: () {},
|
||||
onSave: () {},
|
||||
onComments: () {},
|
||||
onShare: () {},
|
||||
onEpisodes: () {},
|
||||
onDetail: () {},
|
||||
onFullscreen: () {},
|
||||
onDownload: () {},
|
||||
onClear: () {},
|
||||
onSearch: () {},
|
||||
@@ -164,15 +123,26 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('rail-like-count')), findsOneWidget);
|
||||
expect(find.text('128'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('home-feed-title'))).data,
|
||||
'Feed Drama',
|
||||
);
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsNothing);
|
||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-tags')), findsOneWidget);
|
||||
expect(find.text('Episode 2'), findsOneWidget);
|
||||
expect(find.textContaining('41.9万'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
|
||||
'1006',
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-save-count'))).data,
|
||||
'3.2万',
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
|
||||
'12',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const _drama = Drama(
|
||||
id: 'featured-drama',
|
||||
title: 'Fallback Drama',
|
||||
synopsis: 'synopsis',
|
||||
posterUrl: '',
|
||||
totalEpisodes: 8,
|
||||
);
|
||||
@@ -50,12 +50,47 @@ void main() {
|
||||
await _pumpApp(tester);
|
||||
|
||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-info-card')), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-feed-full-series')), findsOneWidget);
|
||||
expect(find.byType(PageView), findsOneWidget);
|
||||
expect(find.byKey(const Key('home-browse-page')), findsNothing);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-like-count'))).data,
|
||||
'41.9万',
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-save-count'))).data,
|
||||
'1006',
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.byKey(const Key('rail-comments-count'))).data,
|
||||
'3.2万',
|
||||
);
|
||||
expect(tester.widget<PlayerPage>(find.byType(PlayerPage)).homeMode, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('should_open_episode_sheet_from_full_series_bar', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-full-series')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('player-episodes-sheet')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_show_coming_soon_for_danmaku_and_following_tab', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-danmaku')));
|
||||
await tester.pump();
|
||||
expect(find.text('Coming soon'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('home-feed-tab-follow')));
|
||||
await tester.pump();
|
||||
expect(find.text('Coming soon'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async {
|
||||
await _pumpApp(tester);
|
||||
final before = tester
|
||||
|
||||
@@ -44,7 +44,16 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository {
|
||||
}
|
||||
|
||||
final List<Drama> demoDramas = [
|
||||
_drama('the-signal-demo', 'The Signal', 'Suspense · Mystery', format: 1),
|
||||
_drama(
|
||||
'the-signal-demo',
|
||||
'The Signal',
|
||||
'Suspense · Mystery',
|
||||
format: 1,
|
||||
viewCount: 419000,
|
||||
likeCount: 419000,
|
||||
favoriteCount: 1006,
|
||||
commentCount: 32000,
|
||||
),
|
||||
_drama(
|
||||
'city-lights-demo',
|
||||
'City Lights & Velvet Nights',
|
||||
@@ -65,6 +74,7 @@ Drama _drama(
|
||||
int? likeCount,
|
||||
int? favoriteCount,
|
||||
int? commentCount,
|
||||
int viewCount = 0,
|
||||
}) => Drama(
|
||||
id: id,
|
||||
title: title,
|
||||
@@ -73,6 +83,7 @@ Drama _drama(
|
||||
genre: genre,
|
||||
language: 'id',
|
||||
detailLoaded: true,
|
||||
viewCount: viewCount,
|
||||
likeCount: likeCount,
|
||||
favoriteCount: favoriteCount,
|
||||
commentCount: commentCount,
|
||||
|
||||
Reference in new issue
Block a user