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",
|
"romance": "Romance",
|
||||||
"actionGenre": "Action",
|
"actionGenre": "Action",
|
||||||
"filter": "Filter",
|
"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",
|
"reserve": "Reserve",
|
||||||
"heatLabel": "{count} heat",
|
"heatLabel": "{count} heat",
|
||||||
"@heatLabel": {
|
"@heatLabel": {
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
"romance": "Romantis",
|
"romance": "Romantis",
|
||||||
"actionGenre": "Aksi",
|
"actionGenre": "Aksi",
|
||||||
"filter": "Filter",
|
"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",
|
"reserve": "Reservasi",
|
||||||
"heatLabel": "{count} panas",
|
"heatLabel": "{count} panas",
|
||||||
"topCharts": "Peringkat Teratas",
|
"topCharts": "Peringkat Teratas",
|
||||||
|
|||||||
@@ -274,6 +274,60 @@ abstract class AppLocalizations {
|
|||||||
/// **'Filter'**
|
/// **'Filter'**
|
||||||
String get 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.
|
/// No description provided for @reserve.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -2686,12 +2740,6 @@ abstract class AppLocalizations {
|
|||||||
/// **'Clear-screen playback'**
|
/// **'Clear-screen playback'**
|
||||||
String get clearPlayback;
|
String get clearPlayback;
|
||||||
|
|
||||||
/// No description provided for @danmaku.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Danmaku'**
|
|
||||||
String get danmaku;
|
|
||||||
|
|
||||||
/// No description provided for @danmakuUnavailable.
|
/// No description provided for @danmakuUnavailable.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get filter => 'Filter';
|
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
|
@override
|
||||||
String get reserve => 'Reserve';
|
String get reserve => 'Reserve';
|
||||||
|
|
||||||
@@ -1396,9 +1427,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearPlayback => 'Clear-screen playback';
|
String get clearPlayback => 'Clear-screen playback';
|
||||||
|
|
||||||
@override
|
|
||||||
String get danmaku => 'Danmaku';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get danmakuUnavailable => 'Coming soon';
|
String get danmakuUnavailable => 'Coming soon';
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get filter => 'Filter';
|
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
|
@override
|
||||||
String get reserve => 'Reservasi';
|
String get reserve => 'Reservasi';
|
||||||
|
|
||||||
@@ -1396,9 +1427,6 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearPlayback => 'Putar tanpa kontrol';
|
String get clearPlayback => 'Putar tanpa kontrol';
|
||||||
|
|
||||||
@override
|
|
||||||
String get danmaku => 'Komentar layar';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get danmakuUnavailable => 'Belum tersedia';
|
String get danmakuUnavailable => 'Belum tersedia';
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,37 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get filter => '筛选';
|
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
|
@override
|
||||||
String get reserve => '预约';
|
String get reserve => '预约';
|
||||||
|
|
||||||
@@ -1372,9 +1403,6 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get clearPlayback => '清屏播放';
|
String get clearPlayback => '清屏播放';
|
||||||
|
|
||||||
@override
|
|
||||||
String get danmaku => '弹幕';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get danmakuUnavailable => '暂未开放';
|
String get danmakuUnavailable => '暂未开放';
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,15 @@
|
|||||||
"romance": "都市爱情",
|
"romance": "都市爱情",
|
||||||
"actionGenre": "动作",
|
"actionGenre": "动作",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
|
"danmaku": "弹幕",
|
||||||
|
"watchFullscreen": "全屏观看",
|
||||||
|
"seriesMeta": "系列剧 · 共{episodes}集 | 共{viewers}人在追",
|
||||||
|
"watchFullSeries": "观看完整剧集 · 全{episodes}集",
|
||||||
|
"feedTabFollowing": "关注",
|
||||||
|
"feedTabAnime": "漫剧",
|
||||||
|
"feedTabReal": "真人剧",
|
||||||
|
"feedTabForYou": "推荐",
|
||||||
|
"authorNoteLabel": "作者声明",
|
||||||
"reserve": "预约",
|
"reserve": "预约",
|
||||||
"heatLabel": "{count}热度",
|
"heatLabel": "{count}热度",
|
||||||
"topCharts": "排行榜",
|
"topCharts": "排行榜",
|
||||||
|
|||||||
@@ -367,8 +367,14 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
l10n: l10n,
|
l10n: l10n,
|
||||||
drama: drama,
|
drama: drama,
|
||||||
episode: episode,
|
episode: episode,
|
||||||
|
controller: ready ? videoPort!.controller : null,
|
||||||
liked: interactions.isLiked(drama.id),
|
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),
|
saved: context.watch<FavoriteController>().isSaved(drama.id),
|
||||||
onLike: () async {
|
onLike: () async {
|
||||||
if (await ensureSignedIn(context)) {
|
if (await ensureSignedIn(context)) {
|
||||||
@@ -404,6 +410,9 @@ class _PlayerPageState extends State<PlayerPage> with WidgetsBindingObserver {
|
|||||||
onComments: () => logic.showComments(context, drama),
|
onComments: () => logic.showComments(context, drama),
|
||||||
onShare: () => logic.shareDrama(context, drama),
|
onShare: () => logic.shareDrama(context, drama),
|
||||||
onEpisodes: () => logic.showEpisodes(context),
|
onEpisodes: () => logic.showEpisodes(context),
|
||||||
|
onDetail: () => unawaited(logic.openDetail(context)),
|
||||||
|
onFullscreen: () =>
|
||||||
|
unawaited(_openFullscreen(context, drama, episode)),
|
||||||
onDownload: () => requestEpisodeDownload(context, episode.id),
|
onDownload: () => requestEpisodeDownload(context, episode.id),
|
||||||
onClear: () => setState(() => _chromeVisible = false),
|
onClear: () => setState(() => _chromeVisible = false),
|
||||||
onSearch:
|
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(
|
Future<void> _openUnlockSheet(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
int index,
|
int index,
|
||||||
|
|||||||
@@ -1,422 +1,479 @@
|
|||||||
// Author: fruit.com.cn
|
// Author: fruit.com.cn
|
||||||
|
|
||||||
import 'dart:async';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../../core/data/models/drama.dart';
|
import '../../../core/data/models/drama.dart';
|
||||||
|
import '../../../core/formatting/count_format.dart';
|
||||||
import '../../../core/formatting/localized_content.dart';
|
import '../../../core/formatting/localized_content.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_dimens.dart';
|
|
||||||
import '../../../core/widgets/glass_panel.dart';
|
|
||||||
import '../../../l10n/app_localizations.dart';
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
|
||||||
/// Home feed layer from `docs/移动端设计预览.html` screen 01.
|
part 'home_feed_overlay_parts.dart';
|
||||||
class HomeFeedOverlay extends StatelessWidget {
|
|
||||||
|
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({
|
const HomeFeedOverlay({
|
||||||
super.key,
|
super.key,
|
||||||
required this.l10n,
|
required this.l10n,
|
||||||
required this.drama,
|
required this.drama,
|
||||||
required this.episode,
|
required this.episode,
|
||||||
|
required this.controller,
|
||||||
required this.liked,
|
required this.liked,
|
||||||
this.likeCount,
|
|
||||||
required this.saved,
|
required this.saved,
|
||||||
required this.onLike,
|
required this.onLike,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onComments,
|
required this.onComments,
|
||||||
required this.onShare,
|
required this.onShare,
|
||||||
required this.onEpisodes,
|
required this.onEpisodes,
|
||||||
|
required this.onDetail,
|
||||||
|
required this.onFullscreen,
|
||||||
required this.onDownload,
|
required this.onDownload,
|
||||||
required this.onClear,
|
required this.onClear,
|
||||||
required this.onSearch,
|
required this.onSearch,
|
||||||
|
this.likeCount,
|
||||||
|
this.favoriteCount,
|
||||||
|
this.commentCount,
|
||||||
|
this.authorNote = '',
|
||||||
this.onPip,
|
this.onPip,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppLocalizations l10n;
|
final AppLocalizations l10n;
|
||||||
final Drama drama;
|
final Drama drama;
|
||||||
final Episode episode;
|
final Episode episode;
|
||||||
|
final ValueListenable<VideoPlayerValue>? controller;
|
||||||
final bool liked;
|
final bool liked;
|
||||||
final int? likeCount;
|
final int? likeCount;
|
||||||
|
final int? favoriteCount;
|
||||||
|
final int? commentCount;
|
||||||
final bool saved;
|
final bool saved;
|
||||||
|
final String authorNote;
|
||||||
final VoidCallback onLike;
|
final VoidCallback onLike;
|
||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
final VoidCallback onComments;
|
final VoidCallback onComments;
|
||||||
final VoidCallback onShare;
|
final VoidCallback onShare;
|
||||||
final VoidCallback onEpisodes;
|
final VoidCallback onEpisodes;
|
||||||
|
final VoidCallback onDetail;
|
||||||
|
final VoidCallback onFullscreen;
|
||||||
final VoidCallback onDownload;
|
final VoidCallback onDownload;
|
||||||
final VoidCallback onClear;
|
final VoidCallback onClear;
|
||||||
final VoidCallback onSearch;
|
final VoidCallback onSearch;
|
||||||
final VoidCallback? onPip;
|
final VoidCallback? onPip;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HomeFeedOverlay> createState() => _HomeFeedOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeFeedOverlayState extends State<HomeFeedOverlay> {
|
||||||
|
_FeedTab _tab = _FeedTab.recommend;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
bottom: false,
|
bottom: false,
|
||||||
child: Column(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
_header(context),
|
Positioned(
|
||||||
Expanded(
|
top: 0,
|
||||||
child: Align(
|
left: 0,
|
||||||
alignment: const Alignment(1.0, 0.45),
|
right: 0,
|
||||||
child: Padding(
|
child: IgnorePointer(child: _gradient(top: true)),
|
||||||
padding: const EdgeInsets.fromLTRB(0, 0, 12, 16),
|
|
||||||
child: _rail(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Padding(
|
Positioned(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
left: 0,
|
||||||
child: _infoCard(),
|
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) {
|
Widget _header(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 6, 10, 4),
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 4),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
_TopIconButton(
|
||||||
|
key: const Key('home-feed-menu'),
|
||||||
|
tooltip: widget.l10n.more,
|
||||||
|
icon: Icons.menu_rounded,
|
||||||
|
dimension: _topIconSize,
|
||||||
|
glyphSize: _topIconGlyph,
|
||||||
|
onPressed: () => _openMore(context),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Center(
|
||||||
localizedDramaTitle(l10n, drama),
|
child: SingleChildScrollView(
|
||||||
key: const Key('home-feed-title'),
|
scrollDirection: Axis.horizontal,
|
||||||
maxLines: 1,
|
child: Row(
|
||||||
overflow: TextOverflow.ellipsis,
|
children: [
|
||||||
style: const TextStyle(
|
for (var index = 0; index < _FeedTab.values.length; index++)
|
||||||
fontFamily: 'Outfit',
|
_tabSlot(_FeedTab.values[index], index),
|
||||||
color: AppColors.onSurface,
|
],
|
||||||
fontSize: 13,
|
),
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
shadows: [Shadow(blurRadius: 8, color: Colors.black)],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_RoundIcon(
|
_TopIconButton(
|
||||||
key: const Key('home-feed-search'),
|
key: const Key('home-feed-search'),
|
||||||
tooltip: l10n.search,
|
tooltip: widget.l10n.search,
|
||||||
icon: Icons.search_rounded,
|
icon: Icons.search_rounded,
|
||||||
onPressed: onSearch,
|
dimension: _topIconSize,
|
||||||
),
|
glyphSize: _topIconGlyph,
|
||||||
const SizedBox(width: 8),
|
onPressed: widget.onSearch,
|
||||||
_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),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
Widget _rail() {
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_RailItem(
|
_FeedRailItem(
|
||||||
key: const Key('rail-like'),
|
key: const Key('rail-save'),
|
||||||
icon: liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
icon: widget.saved ? Icons.star_rounded : Icons.star_border_rounded,
|
||||||
label: likeCount?.toString() ?? l10n.like,
|
count: widget.favoriteCount,
|
||||||
countKey: likeCount == null ? null : const Key('rail-like-count'),
|
countKey: const Key('rail-save-count'),
|
||||||
selected: liked,
|
label: widget.l10n.favorite,
|
||||||
onTap: onLike,
|
selected: widget.saved,
|
||||||
|
iconSize: _railIconSize,
|
||||||
|
onTap: widget.onSave,
|
||||||
),
|
),
|
||||||
_RailItem(
|
_FeedRailItem(
|
||||||
key: const Key('rail-comments'),
|
key: const Key('rail-comments'),
|
||||||
icon: Icons.chat_bubble_outline_rounded,
|
icon: Icons.chat_bubble_outline_rounded,
|
||||||
label: l10n.comments,
|
count: widget.commentCount,
|
||||||
onTap: onComments,
|
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'),
|
key: const Key('rail-share'),
|
||||||
icon: Icons.ios_share_rounded,
|
icon: Icons.ios_share_rounded,
|
||||||
label: l10n.share,
|
label: widget.l10n.share,
|
||||||
onTap: onShare,
|
iconSize: _railIconSize,
|
||||||
),
|
onTap: widget.onShare,
|
||||||
_RailItem(
|
|
||||||
key: const Key('rail-save'),
|
|
||||||
icon: saved ? Icons.star_rounded : Icons.star_border_rounded,
|
|
||||||
label: l10n.favorite,
|
|
||||||
selected: saved,
|
|
||||||
onTap: onSave,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _infoCard() {
|
Widget _bottomInfo() {
|
||||||
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
|
return Padding(
|
||||||
return GlassPanel(
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 8),
|
||||||
key: const Key('home-feed-info-card'),
|
|
||||||
radius: 14,
|
|
||||||
blur: 6,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
borderColor: AppColors.border,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Wrap(
|
Row(
|
||||||
spacing: 6,
|
|
||||||
runSpacing: 4,
|
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
if (genre.isNotEmpty)
|
Flexible(
|
||||||
_tag(
|
child: _DanmakuPill(
|
||||||
genre,
|
l10n: widget.l10n,
|
||||||
background: AppColors.blue,
|
height: _pillHeight,
|
||||||
foreground: AppColors.onPrimary,
|
background: _overlayPill,
|
||||||
|
onTap: _showComingSoon,
|
||||||
),
|
),
|
||||||
if (drama.category.isNotEmpty && drama.category != genre)
|
),
|
||||||
_tag(
|
const SizedBox(width: 12),
|
||||||
drama.category,
|
Flexible(
|
||||||
background: AppColors.raised,
|
child: _FullscreenPill(
|
||||||
foreground: AppColors.onSurface,
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
_seriesMetaRow(),
|
||||||
episode.title.trim().isEmpty
|
const SizedBox(height: 2),
|
||||||
? localizedDramaTitle(l10n, drama)
|
_titleRow(),
|
||||||
: episode.title,
|
const SizedBox(height: 6),
|
||||||
key: const Key('home-feed-episode-title'),
|
_tagsRow(),
|
||||||
maxLines: 1,
|
if (widget.authorNote.isNotEmpty) ...[
|
||||||
overflow: TextOverflow.ellipsis,
|
const SizedBox(height: 6),
|
||||||
style: const TextStyle(
|
Text(
|
||||||
color: AppColors.onSurface,
|
'${widget.l10n.authorNoteLabel}: ${widget.authorNote}',
|
||||||
fontSize: 13,
|
key: const Key('home-feed-author-note'),
|
||||||
fontWeight: FontWeight.w800,
|
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),
|
const Icon(
|
||||||
Row(
|
Icons.chevron_right_rounded,
|
||||||
children: [
|
color: Colors.white70,
|
||||||
Expanded(
|
size: 16,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _tag(
|
Widget _titleRow() {
|
||||||
String label, {
|
return GestureDetector(
|
||||||
required Color background,
|
onTap: widget.onDetail,
|
||||||
required Color foreground,
|
child: Row(
|
||||||
}) {
|
children: [
|
||||||
return Container(
|
Flexible(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
child: Text(
|
||||||
decoration: BoxDecoration(
|
localizedDramaTitle(widget.l10n, widget.drama),
|
||||||
color: background,
|
key: const Key('home-feed-title'),
|
||||||
borderRadius: BorderRadius.circular(6),
|
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(
|
Widget _tagsRow() {
|
||||||
color: foreground,
|
final tags = [
|
||||||
fontSize: 8,
|
widget.l10n.episodeLabel(widget.episode.number),
|
||||||
fontWeight: FontWeight.w800,
|
..._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 {
|
Future<void> _openMore(BuildContext context) async {
|
||||||
await showModalBottomSheet<void>(
|
await showModalBottomSheet<void>(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: AppColors.card,
|
backgroundColor: AppColors.card,
|
||||||
showDragHandle: true,
|
showDragHandle: true,
|
||||||
builder: (sheetContext) => SafeArea(
|
builder: (sheetContext) => _FeedMoreSheet(
|
||||||
child: Column(
|
l10n: widget.l10n,
|
||||||
mainAxisSize: MainAxisSize.min,
|
onDownload: widget.onDownload,
|
||||||
children: [
|
onPip: widget.onPip,
|
||||||
ListTile(
|
onClear: widget.onClear,
|
||||||
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)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
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.byType(PageView), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-browse-page')), findsNothing);
|
expect(find.byKey(const Key('home-browse-page')), findsNothing);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('should_use_episode_title_then_drama_title_as_fallback', (
|
testWidgets('should_render_new_feed_structure_with_formatted_counts', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
Future<void> pumpEpisode(Episode episode) async {
|
const drama = Drama(
|
||||||
await tester.pumpWidget(
|
id: 'feed-drama',
|
||||||
MaterialApp(
|
title: 'Feed Drama',
|
||||||
theme: AppTheme.light(),
|
synopsis: 'synopsis',
|
||||||
locale: const Locale('en'),
|
posterUrl: '',
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
genre: 'Romance',
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
category: 'Urban',
|
||||||
home: Builder(
|
totalEpisodes: 8,
|
||||||
builder: (context) => Scaffold(
|
freeEpisodes: 8,
|
||||||
body: HomeFeedOverlay(
|
viewCount: 419000,
|
||||||
l10n: AppLocalizations.of(context)!,
|
likeCount: 1006,
|
||||||
drama: _drama,
|
favoriteCount: 32000,
|
||||||
episode: episode,
|
commentCount: 12,
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
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(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
@@ -139,21 +93,26 @@ void main() {
|
|||||||
builder: (context) => Scaffold(
|
builder: (context) => Scaffold(
|
||||||
body: HomeFeedOverlay(
|
body: HomeFeedOverlay(
|
||||||
l10n: AppLocalizations.of(context)!,
|
l10n: AppLocalizations.of(context)!,
|
||||||
drama: _drama,
|
drama: drama,
|
||||||
episode: const Episode(
|
episode: const Episode(
|
||||||
id: 'count-episode',
|
id: 'feed-episode',
|
||||||
number: 1,
|
number: 2,
|
||||||
title: 'Count Episode',
|
title: 'Ignored Episode Title',
|
||||||
isFree: true,
|
isFree: true,
|
||||||
),
|
),
|
||||||
|
controller: null,
|
||||||
liked: true,
|
liked: true,
|
||||||
likeCount: 128,
|
|
||||||
saved: false,
|
saved: false,
|
||||||
|
likeCount: 1006,
|
||||||
|
favoriteCount: 32000,
|
||||||
|
commentCount: 12,
|
||||||
onLike: () {},
|
onLike: () {},
|
||||||
onSave: () {},
|
onSave: () {},
|
||||||
onComments: () {},
|
onComments: () {},
|
||||||
onShare: () {},
|
onShare: () {},
|
||||||
onEpisodes: () {},
|
onEpisodes: () {},
|
||||||
|
onDetail: () {},
|
||||||
|
onFullscreen: () {},
|
||||||
onDownload: () {},
|
onDownload: () {},
|
||||||
onClear: () {},
|
onClear: () {},
|
||||||
onSearch: () {},
|
onSearch: () {},
|
||||||
@@ -164,15 +123,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byKey(const Key('rail-like-count')), findsOneWidget);
|
expect(
|
||||||
expect(find.text('128'), findsOneWidget);
|
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);
|
await _pumpApp(tester);
|
||||||
|
|
||||||
expect(find.byKey(const Key('home-feed-title')), findsOneWidget);
|
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.byType(PageView), findsOneWidget);
|
||||||
expect(find.byKey(const Key('home-browse-page')), findsNothing);
|
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);
|
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 {
|
testWidgets('should_switch_feed_drama_when_swiped_up', (tester) async {
|
||||||
await _pumpApp(tester);
|
await _pumpApp(tester);
|
||||||
final before = tester
|
final before = tester
|
||||||
|
|||||||
@@ -44,7 +44,16 @@ class FakeCatalogRepository implements CatalogRepository, SearchRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final List<Drama> demoDramas = [
|
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(
|
_drama(
|
||||||
'city-lights-demo',
|
'city-lights-demo',
|
||||||
'City Lights & Velvet Nights',
|
'City Lights & Velvet Nights',
|
||||||
@@ -65,6 +74,7 @@ Drama _drama(
|
|||||||
int? likeCount,
|
int? likeCount,
|
||||||
int? favoriteCount,
|
int? favoriteCount,
|
||||||
int? commentCount,
|
int? commentCount,
|
||||||
|
int viewCount = 0,
|
||||||
}) => Drama(
|
}) => Drama(
|
||||||
id: id,
|
id: id,
|
||||||
title: title,
|
title: title,
|
||||||
@@ -73,6 +83,7 @@ Drama _drama(
|
|||||||
genre: genre,
|
genre: genre,
|
||||||
language: 'id',
|
language: 'id',
|
||||||
detailLoaded: true,
|
detailLoaded: true,
|
||||||
|
viewCount: viewCount,
|
||||||
likeCount: likeCount,
|
likeCount: likeCount,
|
||||||
favoriteCount: favoriteCount,
|
favoriteCount: favoriteCount,
|
||||||
commentCount: commentCount,
|
commentCount: commentCount,
|
||||||
|
|||||||
Reference in new issue
Block a user