feat(home): 首页按内容类型分频道 Feed 并支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:33:52 +08:00
1 parent 4d0be47ee3
commit ae61d9ec4c
22 files changed
+1001 -71

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
// Author: fruit.com.cn
import 'drama.dart';
/// `/app/feed` 的游标分页数据。
class FeedPage {
const FeedPage({
this.dramas = const [],
this.nextCursor,
this.hasMore = false,
});
final List<Drama> dramas;
final String? nextCursor;
final bool hasMore;
factory FeedPage.fromJson(Map<String, dynamic> json) {
final rawCursor = json['nextCursor'];
final cursor = rawCursor?.toString();
return FeedPage(
dramas: (json['list'] as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(Drama.fromCardJson)
.toList(),
nextCursor: cursor == null || cursor.isEmpty || cursor == 'null'
? null
: cursor,
hasMore: json['hasMore'] == true,
);
}
}
+21
View File
@@ -1,4 +1,5 @@
import '../models/drama.dart';
import '../models/feed_page.dart';
import '../models/home_feed.dart';
import '../models/search_result.dart';
import 'api_client.dart';
@@ -17,6 +18,26 @@ class CatalogApi {
return HomeFeed.fromJson(_asMap(await _client.get('/app/discover/feed')));
}
/// 内容类型推荐流;types 为空表示推荐全部,cursor 为上一页末条。
Future<FeedPage> feed({
List<String> types = const [],
String? cursor,
int size = 10,
}) async {
return FeedPage.fromJson(
_asMap(
await _client.get(
'/app/feed',
query: {
if (types.isNotEmpty) 'types': types.join(','),
if (cursor != null && cursor.isNotEmpty) 'cursor': cursor,
'size': '$size',
},
),
),
);
}
/// 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表。
Future<SearchResult> search(
String keyword, {
@@ -1,6 +1,7 @@
// Author: fruit.com.cn
import '../models/drama.dart';
import '../models/feed_page.dart';
import '../models/home_feed.dart';
import '../remote/catalog_api.dart';
@@ -13,6 +14,12 @@ abstract interface class CatalogRepository {
Future<HomeFeed> fetchDiscoverFeed();
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
});
Future<Drama> fetchDrama(String dramaId);
}
@@ -28,6 +35,12 @@ class HttpCatalogRepository implements CatalogRepository {
Future<HomeFeed> fetchDiscoverFeed() => _api.discoverFeed();
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
Future<FeedPage> fetchFeed({
List<String> types = const [],
String? cursor,
int size = 10,
}) => _api.feed(types: types, cursor: cursor, size: size);
@override
Future<Drama> fetchDrama(String dramaId) => _api.dramaDetail(dramaId);
}
@@ -0,0 +1,165 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../data/models/drama.dart';
import '../../data/repositories/catalog_repository.dart';
import '../../../pages/home/home_feed_channel.dart';
/// 首页内容类型频道状态:每频道独立缓存、刷新和游标分页。
class HomeFeedController extends ChangeNotifier {
HomeFeedController(this._repository)
: _states = {
for (final channel in HomeFeedChannel.values)
channel: _ChannelFeedState(),
};
static const int pageSize = 10;
final CatalogRepository _repository;
final Map<HomeFeedChannel, _ChannelFeedState> _states;
HomeFeedChannel _channel = HomeFeedChannel.recommend;
String? _lastError;
bool _disposed = false;
HomeFeedChannel get channel => _channel;
String? get lastError => _lastError;
List<Drama> get dramas => _state(_channel).dramas;
String? get cursor => _state(_channel).cursor;
bool get hasMore => _state(_channel).hasMore;
bool get loaded => _state(_channel).loaded;
bool get loadingMore => _state(_channel).loadingMore;
bool get loadingFirstPage => !loaded;
/// 用首页聚合接口打平的数据填充推荐首屏,不发起网络请求。
void seedRecommend(List<Drama> dramas) {
final state = _state(HomeFeedChannel.recommend);
state
..requestSeq = state.requestSeq + 1
..dramas = List.unmodifiable(dramas)
..cursor = null
..hasMore = false
..loaded = true
..loadingMore = false;
_lastError = null;
if (_channel == HomeFeedChannel.recommend) _notify();
}
/// 同步切到目标频道;有缓存先渲染,再在后台静默刷新。
Future<void> selectChannel(HomeFeedChannel next) async {
if (next == _channel) return;
_channel = next;
_lastError = null;
_notify();
final state = _state(next);
if (state.dramas.isNotEmpty) {
unawaited(_refresh(next, keepData: true));
} else {
await _loadFirst(next);
}
}
/// 刷新当前频道,失败时保留旧数据。
Future<bool> refreshCurrent() => _refresh(_channel, keepData: true);
/// 加载当前频道下一页并去重追加。
Future<void> loadMore() async {
final channel = _channel;
final state = _state(channel);
if (!state.hasMore || state.loadingMore || state.cursor == null) return;
final seq = ++state.requestSeq;
state.loadingMore = true;
_notify();
try {
final page = await _repository.fetchFeed(
types: channel.apiTypes,
cursor: state.cursor,
size: pageSize,
);
if (!_accept(channel, seq)) return;
final seen = state.dramas.map((drama) => drama.id).toSet();
final appended = page.dramas
.where((drama) => seen.add(drama.id))
.toList();
state
..dramas = List.unmodifiable([...state.dramas, ...appended])
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true;
_lastError = null;
} catch (error) {
if (_accept(channel, seq)) _lastError = error.toString();
} finally {
state.loadingMore = false;
if (_acceptChannel(channel)) _notify();
}
}
Future<void> _loadFirst(HomeFeedChannel channel) async {
final state = _state(channel);
state.loaded = false;
await _refresh(channel, keepData: state.dramas.isNotEmpty);
}
Future<bool> _refresh(
HomeFeedChannel channel, {
required bool keepData,
}) async {
final state = _state(channel);
final seq = ++state.requestSeq;
_lastError = null;
if (_acceptChannel(channel)) _notify();
try {
final page = await _repository.fetchFeed(
types: channel.apiTypes,
size: pageSize,
);
if (!_accept(channel, seq)) return false;
state
..dramas = List.unmodifiable(page.dramas)
..cursor = page.nextCursor
..hasMore = page.hasMore
..loaded = true
..loadingMore = false;
_lastError = null;
_notify();
return true;
} catch (error) {
if (!_accept(channel, seq)) return false;
if (!keepData) state.dramas = const [];
state.loaded = true;
_lastError = error.toString();
_notify();
return false;
}
}
_ChannelFeedState _state(HomeFeedChannel channel) => _states[channel]!;
bool _accept(HomeFeedChannel channel, int seq) =>
!_disposed && channel == _channel && _state(channel).requestSeq == seq;
bool _acceptChannel(HomeFeedChannel channel) =>
!_disposed && channel == _channel;
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
class _ChannelFeedState {
List<Drama> dramas = const [];
String? cursor;
bool hasMore = true;
bool loaded = false;
bool loadingMore = false;
int requestSeq = 0;
}
+22 -1
View File
@@ -105,6 +105,27 @@ class PlaybackSession extends ChangeNotifier {
return null;
}
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
void remapCurrentIndex(int index, {int? preloadIndex}) {
if (_closed || index < 0 || index >= episodes.length) return;
final activeSlot = _active;
if (activeSlot == null || activeSlot.index != _currentIndex) {
_currentIndex = index;
_nextIndex = preloadIndex;
_notify();
return;
}
_generation++;
activeSlot.index = index;
_currentIndex = index;
_nextIndex = preloadIndex;
final staleNext = _next;
_next = null;
_notify();
unawaited(_enqueue(() async => _retire(staleNext)));
if (_visible && !_overlayOpen) _schedulePreload();
}
Future<void> _enqueue(Future<void> Function() operation) {
final task = _operations.then((_) => operation());
// A plugin failure must not poison subsequent transitions or cleanup.
@@ -363,7 +384,7 @@ class PlaybackSession extends ChangeNotifier {
class _PlayerSlot {
_PlayerSlot(this.index, this.player);
final int index;
int index;
final PlayerPort player;
late final Future<void> ready;
bool prepared = false;
+14
View File
@@ -0,0 +1,14 @@
// Author: fruit.com.cn
/// 首页顶栏的三个内容频道。
enum HomeFeedChannel {
anime('ANIME'),
live('LIVE'),
recommend(null);
const HomeFeedChannel(this.apiType);
final String? apiType;
List<String> get apiTypes => apiType == null ? const [] : <String>[apiType!];
}
+95 -17
View File
@@ -8,7 +8,7 @@ import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/home_feed_controller.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/catalog_retry.dart';
import '../../core/data/services/category_controller.dart';
@@ -24,8 +24,10 @@ import '../discover/discover_types.dart';
import '../discover/discover_view.dart';
import '../create/create_view.dart';
import '../mine/mine_types.dart';
import '../player/widgets/home_feed_header.dart';
import '../player/player_view.dart';
import '../mine/mine_view.dart';
import 'home_feed_channel.dart';
import 'home_binding.dart';
import 'home_logic.dart';
@@ -43,6 +45,8 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
static const _defaultSplashSize = 384.0;
bool _resumeRetryInFlight = false;
HomeFeedController? _homeFeed;
bool _recommendSeeded = false;
@override
void initState() {
@@ -52,6 +56,7 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
@override
void dispose() {
_homeFeed?.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@@ -131,11 +136,19 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver {
);
}
return _HomeTabs(
key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas,
onRefreshFeed: catalog.refresh,
);
final homeFeed = _ensureHomeFeed(catalog);
if (!_recommendSeeded) {
_recommendSeeded = true;
homeFeed.seedRecommend(catalog.dramas);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(homeFeed.refreshCurrent());
});
}
return _HomeTabs(key: const Key('home-tabs'), feedController: homeFeed);
}
HomeFeedController _ensureHomeFeed(CatalogController catalog) {
return _homeFeed ??= HomeFeedController(catalog.repository);
}
Widget _startupSplash() {
@@ -199,14 +212,9 @@ class HomeFailureView extends StatelessWidget {
/// 四个根页面共享同一路由并保持各自状态。
class _HomeTabs extends StatefulWidget {
const _HomeTabs({
super.key,
required this.dramas,
required this.onRefreshFeed,
});
const _HomeTabs({super.key, required this.feedController});
final List<Drama> dramas;
final Future<bool> Function() onRefreshFeed;
final HomeFeedController feedController;
@override
State<_HomeTabs> createState() => _HomeTabsState();
@@ -218,15 +226,21 @@ class _HomeTabsState extends State<_HomeTabs> {
@override
void initState() {
super.initState();
logic = HomeBinding().create(widget.dramas);
logic = HomeBinding().create(widget.feedController.dramas);
widget.feedController.addListener(_onFeedChanged);
}
@override
void dispose() {
widget.feedController.removeListener(_onFeedChanged);
logic.dispose();
super.dispose();
}
void _onFeedChanged() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
@@ -277,16 +291,21 @@ class _HomeTabsState extends State<_HomeTabs> {
}
Widget _home(BuildContext context) {
final dramas = widget.feedController.dramas;
if (dramas.isEmpty) return _feedChannelPlaceholder(context);
return PlayerPage(
homeMode: true,
embedded: true,
active: logic.tab == 0,
drama: widget.dramas.first,
drama: dramas.first,
initialIndex: 0,
feedDramas: widget.dramas,
feedDramas: dramas,
feedChannel: widget.feedController.channel,
onOpenFullPlayer: (drama, episode) =>
logic.playDrama(context, drama, episodeId: episode.id),
onRefreshFeed: widget.onRefreshFeed,
onRefreshFeed: widget.feedController.refreshCurrent,
onLoadMore: widget.feedController.loadMore,
onChannelSelected: _selectFeedChannel,
onDramaChanged: logic.onDramaChanged,
onImmersiveChanged: logic.onImmersiveChanged,
onSearch: () => logic.select(1, search: true),
@@ -295,6 +314,65 @@ class _HomeTabsState extends State<_HomeTabs> {
);
}
Widget _feedChannelPlaceholder(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Stack(
fit: StackFit.expand,
children: [
const ColoredBox(color: Colors.black),
SafeArea(
bottom: false,
child: Column(
children: [
HomeFeedHeader(
l10n: l10n,
channel: widget.feedController.channel,
onChannelSelected: _selectFeedChannel,
onRefresh: () => _refreshPlaceholder(context),
onSearch: () => logic.select(1, search: true),
onMore: () {},
),
Expanded(
child: widget.feedController.loadingFirstPage
? const Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
key: Key('home-feed-channel-loading'),
strokeWidth: 2.4,
color: Colors.white70,
),
),
)
: EmptyState(
icon: Icons.movie_outlined,
title: l10n.noStories,
actionLabel: l10n.retry,
onAction: () =>
unawaited(widget.feedController.refreshCurrent()),
),
),
],
),
),
],
);
}
Future<bool> _refreshPlaceholder(BuildContext context) async {
final ok = await widget.feedController.refreshCurrent();
if (!ok && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context)!.catalogError)),
);
}
return ok;
}
void _selectFeedChannel(HomeFeedChannel channel) {
unawaited(widget.feedController.selectChannel(channel));
}
Widget _discover(BuildContext context) {
return DiscoverPage(
active: logic.tab == 1,
+87 -5
View File
@@ -100,6 +100,8 @@ class PlayerLogic extends ChangeNotifier {
ValueChanged<Drama>? onDramaChanged;
ValueChanged<bool>? onImmersiveChanged;
bool _disposed = false;
PlayerFactory? _sessionFactory;
PlaybackCoordinator? _sessionCoordinator;
/// [start] 是否已经跑过:没跑过时 session/pages 还是 late 未初始化,
/// dispose 不能直接碰它们(例如页面构建失败或纯逻辑测试)。
@@ -109,6 +111,14 @@ class PlayerLogic extends ChangeNotifier {
bool get chromeHidden => immersive;
/// 当前 PageView 对应用户正在观看的短剧 id,尚未起播时为 null。
String? get currentDramaId {
if (!_started || visibleIndices.isEmpty) return null;
final index = session.currentIndex;
if (index < 0 || index >= queue.length) return null;
return queue.entryAt(index).drama.id;
}
Episode displayEpisode(Drama drama, Episode fallback) {
final number = pickedEpisode[drama.id];
if (number == null) return fallback;
@@ -145,6 +155,8 @@ class PlayerLogic extends ChangeNotifier {
required PlaybackCoordinator coordinator,
}) {
_started = true;
_sessionFactory = factory;
_sessionCoordinator = coordinator;
pictureInPicture.setStateListener(onPipChanged);
queue = feedDramas == null
? PlaybackQueue.series(pageDrama)
@@ -153,7 +165,53 @@ class PlayerLogic extends ChangeNotifier {
pages = PageController(initialPage: initialIndex);
// 票据解析后要把新地址写回这个列表(PlaybackSession 的 canPlay 读的就是它)
sessionEpisodes = [for (final entry in queue.entries) entry.episode];
session = PlaybackSession(
session = _createSession(factory, coordinator);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
}
/// 原地替换首页 feed 队列;频道变化时重置到第 1 个,刷新时尽量保留当前剧。
void updateFeed(List<Drama> dramas, {String? keepDramaId}) {
final currentId = currentDramaId;
final nextQueue = PlaybackQueue.home(List<Drama>.unmodifiable(dramas));
final nextVisible = List<int>.generate(nextQueue.length, (index) => index);
final nextEpisodes = [for (final entry in nextQueue.entries) entry.episode];
feedDramas = List<Drama>.unmodifiable(dramas);
queue = nextQueue;
visibleIndices = nextVisible;
if (dramas.isNotEmpty) pageDrama = dramas.first;
if (!_started) return;
if (nextQueue.length == 0) {
session.setRouteVisible(false);
notify();
return;
}
final keepIndex = keepDramaId != null && keepDramaId == currentId
? nextQueue.entries.indexWhere((entry) => entry.drama.id == keepDramaId)
: -1;
if (keepIndex >= 0 && session.active != null) {
sessionEpisodes
..clear()
..addAll(nextEpisodes);
session.remapCurrentIndex(
keepIndex,
preloadIndex: nextVisibleIndex(keepIndex),
);
_jumpToFeedPage(keepIndex);
notify();
return;
}
_replaceSession(nextEpisodes, keepIndex >= 0 ? keepIndex : 0);
}
PlaybackSession _createSession(
PlayerFactory factory,
PlaybackCoordinator coordinator,
) {
return PlaybackSession(
episodes: sessionEpisodes,
factory: factory,
// 地址只由后端下发(票据或详情里的免费集签名地址),拿不到地址即视为锁定
@@ -162,11 +220,35 @@ class PlayerLogic extends ChangeNotifier {
activate: activatePlayer,
coordinator: coordinator,
);
if (!active || visibleIndices.isEmpty) {
session.setRouteVisible(false);
}
void _replaceSession(List<Episode> nextEpisodes, int targetIndex) {
final factory = _sessionFactory;
final coordinator = _sessionCoordinator;
if (factory == null || coordinator == null) return;
unawaited(saveCurrentProgress());
final previous = session;
sessionEpisodes = List<Episode>.of(nextEpisodes);
trackedPlayer = null;
trackedEpisode = null;
trackedDrama = null;
session = _createSession(factory, coordinator);
session.setRouteVisible(active && visibleIndices.isNotEmpty);
unawaited(previous.close());
_jumpToFeedPage(targetIndex);
unawaited(selectEpisode(targetIndex));
notify();
}
void _jumpToFeedPage(int index) {
if (!_started) return;
if (pages.hasClients) {
pages.jumpToPage(index);
return;
}
unawaited(loadResumePoint());
unawaited(selectEpisode(initialIndex));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (pages.hasClients) pages.jumpToPage(index);
});
}
/// 服务端续看位置:进播放页时取一次,用于"定位到上次那一集的那一秒"。
+25
View File
@@ -2,6 +2,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
@@ -22,6 +23,7 @@ import '../../core/theme/app_theme.dart';
import '../../core/widgets/auth_gate.dart';
import '../../core/widgets/download_action.dart';
import '../../l10n/app_localizations.dart';
import '../home/home_feed_channel.dart';
import 'comments_auth.dart';
import 'player_binding.dart';
import 'player_logic.dart';
@@ -57,10 +59,13 @@ class PlayerPage extends StatefulWidget {
this.onSearch,
this.onMenu,
this.feedDramas,
this.feedChannel,
this.onDramaChanged,
this.onImmersiveChanged,
this.onOpenFullPlayer,
this.onRefreshFeed,
this.onLoadMore,
this.onChannelSelected,
this.wallet,
this.downloads,
});
@@ -77,10 +82,13 @@ class PlayerPage extends StatefulWidget {
final VoidCallback? onSearch;
final VoidCallback? onMenu;
final List<Drama>? feedDramas;
final HomeFeedChannel? feedChannel;
final ValueChanged<Drama>? onDramaChanged;
final ValueChanged<bool>? onImmersiveChanged;
final Future<void> Function(Drama drama, Episode episode)? onOpenFullPlayer;
final Future<bool> Function()? onRefreshFeed;
final Future<void> Function()? onLoadMore;
final ValueChanged<HomeFeedChannel>? onChannelSelected;
final WalletController? wallet;
final DownloadController? downloads;
@@ -123,6 +131,17 @@ class _PlayerPageState extends State<PlayerPage>
void didUpdateWidget(covariant PlayerPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.attach(widget, isMounted: () => mounted);
if (widget.homeMode &&
widget.feedDramas != null &&
(!listEquals(oldWidget.feedDramas, widget.feedDramas) ||
oldWidget.feedChannel != widget.feedChannel)) {
logic.updateFeed(
widget.feedDramas!,
keepDramaId: oldWidget.feedChannel == widget.feedChannel
? logic.currentDramaId
: null,
);
}
logic.onPageUpdated(oldWidget);
}
@@ -250,6 +269,10 @@ class _PlayerPageState extends State<PlayerPage>
final index = indices[position];
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
unawaited(logic.selectEpisode(index));
if (position >= indices.length - 3) {
final loadMore = widget.onLoadMore;
if (loadMore != null) unawaited(loadMore());
}
},
itemBuilder: (context, position) =>
_buildEpisode(context, indices[position], interactions),
@@ -268,6 +291,8 @@ class _PlayerPageState extends State<PlayerPage>
bottom: false,
child: HomeFeedHeader(
l10n: l10n,
channel: widget.feedChannel ?? HomeFeedChannel.recommend,
onChannelSelected: widget.onChannelSelected ?? (_) {},
onRefresh: () async {
final ok = await widget.onRefreshFeed?.call() ?? true;
if (context.mounted && !ok) {
+29 -44
View File
@@ -2,6 +2,7 @@
import 'package:flutter/material.dart';
import '../../home/home_feed_channel.dart';
import '../../../l10n/app_localizations.dart';
const double _topIconSize = 36;
@@ -16,12 +17,16 @@ class HomeFeedHeader extends StatefulWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.channel,
required this.onChannelSelected,
required this.onRefresh,
required this.onSearch,
required this.onMore,
});
final AppLocalizations l10n;
final HomeFeedChannel channel;
final ValueChanged<HomeFeedChannel> onChannelSelected;
final Future<bool> Function() onRefresh;
final VoidCallback onSearch;
final VoidCallback onMore;
@@ -31,7 +36,6 @@ class HomeFeedHeader extends StatefulWidget {
}
class _HomeFeedHeaderState extends State<HomeFeedHeader> {
_FeedTab _tab = _FeedTab.recommend;
bool _refreshing = false;
@override
@@ -54,8 +58,12 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < _FeedTab.values.length; index++)
_tabSlot(_FeedTab.values[index], index),
for (
var index = 0;
index < HomeFeedChannel.values.length;
index++
)
_tabSlot(HomeFeedChannel.values[index], index),
],
),
),
@@ -74,15 +82,15 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
);
}
Widget _tabSlot(_FeedTab tab, int index) {
Widget _tabSlot(HomeFeedChannel channel, int index) {
final item = _FeedTabItem(
tab: tab,
label: tab.label(widget.l10n),
selected: _tab == tab,
channel: channel,
label: _label(channel),
selected: widget.channel == channel,
fontSize: _tabFontSize,
underlineWidth: _underlineWidth,
underlineHeight: _underlineHeight,
onTap: () => _selectTab(tab),
onTap: () => widget.onChannelSelected(channel),
);
if (index == 0) return item;
return Padding(
@@ -91,14 +99,6 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
);
}
void _selectTab(_FeedTab tab) {
if (tab.comingSoon) {
_showComingSoon();
return;
}
setState(() => _tab = tab);
}
Future<void> _refresh() async {
if (_refreshing) return;
setState(() => _refreshing = true);
@@ -106,31 +106,10 @@ class _HomeFeedHeaderState extends State<HomeFeedHeader> {
if (mounted) setState(() => _refreshing = false);
}
void _showComingSoon() {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(widget.l10n.comingSoon)));
}
}
/// 顶部频道 tab:漫剧点击提示即将上线。
enum _FeedTab {
anime,
real,
recommend;
bool get comingSoon => this == _FeedTab.anime;
String get keyName => switch (this) {
_FeedTab.anime => 'anime',
_FeedTab.real => 'real',
_FeedTab.recommend => 'recommend',
};
String label(AppLocalizations l10n) => switch (this) {
_FeedTab.anime => l10n.feedTabAnime,
_FeedTab.real => l10n.feedTabReal,
_FeedTab.recommend => l10n.feedTabForYou,
String _label(HomeFeedChannel channel) => switch (channel) {
HomeFeedChannel.anime => widget.l10n.feedTabAnime,
HomeFeedChannel.live => widget.l10n.feedTabReal,
HomeFeedChannel.recommend => widget.l10n.feedTabForYou,
};
}
@@ -223,7 +202,7 @@ class _RefreshButton extends StatelessWidget {
class _FeedTabItem extends StatelessWidget {
const _FeedTabItem({
required this.tab,
required this.channel,
required this.label,
required this.selected,
required this.fontSize,
@@ -232,7 +211,7 @@ class _FeedTabItem extends StatelessWidget {
required this.onTap,
});
final _FeedTab tab;
final HomeFeedChannel channel;
final String label;
final bool selected;
final double fontSize;
@@ -243,7 +222,7 @@ class _FeedTabItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GestureDetector(
key: Key('home-feed-tab-${tab.keyName}'),
key: Key('home-feed-tab-${_keyName(channel)}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
@@ -272,4 +251,10 @@ class _FeedTabItem extends StatelessWidget {
),
);
}
String _keyName(HomeFeedChannel channel) => switch (channel) {
HomeFeedChannel.anime => 'anime',
HomeFeedChannel.live => 'real',
HomeFeedChannel.recommend => 'recommend',
};
}