chore(cleanup): 删除无引用代码与无用资源

This commit is contained in:
Zhou Weidong committed 2026-10-20 20:06:17 +08:00
1 parent d2174c5b74
commit 3fefd544fb
16 files changed
-1544

No files matched your search

-70
View File
@@ -1,70 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
/// 首页频道,只依赖目录中已有数据排序或过滤。
enum HomeChannel { hot, free, newReleases, ranking }
/// 浏览型首页的本地 UI 状态。
class HomeBrowseLogic extends ChangeNotifier {
HomeBrowseLogic({required this.history}) {
unawaited(refreshRecent());
}
static const int recentLimit = 20;
final WatchHistoryRepository history;
HomeChannel channel = HomeChannel.hot;
List<WatchProgress> recent = const [];
bool loadingRecent = false;
bool _disposed = false;
/// 第一条未完成且存在真实播放位置的记录。
WatchProgress? get primaryRecord {
for (final record in recent) {
if (!record.finished && record.hasPosition) return record;
}
return null;
}
/// 切换频道,不触发额外网络请求。
void selectChannel(HomeChannel value) {
if (channel == value) return;
channel = value;
_notify();
}
/// 重新加载续看数据;未登录时返回空列表。
Future<void> refreshRecent() async {
loadingRecent = true;
_notify();
try {
recent = await history.recent(limit: recentLimit);
} catch (_) {
recent = const [];
} finally {
loadingRecent = false;
_notify();
}
}
/// 首页重新可见时刷新续看数据。
void syncPage({required bool active, required bool wasActive}) {
if (active && !wasActive) unawaited(refreshRecent());
}
void _notify() {
if (!_disposed) notifyListeners();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}
-451
View File
@@ -1,451 +0,0 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_dimens.dart';
import '../../core/widgets/app_pull_to_refresh.dart';
import '../../core/widgets/empty_state.dart';
import '../../l10n/app_localizations.dart';
import '../discover/widgets/feed_banner_carousel.dart';
import '../mine/widgets/continue_watching_card.dart';
import 'home_browse_logic.dart';
import 'widgets/home_browse_header.dart';
import 'widgets/home_drama_grid.dart';
const double _channelRowHeight = 44;
const double _channelGap = 20;
const double _channelUnderlineWidth = 18;
const double _channelUnderlineHeight = 2;
const double _channelUnderlineGap = 4;
const double _channelFontSize = 15;
const List<Shadow> _channelTextShadows = [
Shadow(color: Colors.black54, blurRadius: 5, offset: Offset(0, 1)),
];
/// 浏览型首页:频道、Hero、继续观看与剧目目录。
class HomeBrowseView extends StatefulWidget {
const HomeBrowseView({
super.key,
required this.active,
required this.onSearch,
required this.onProfile,
required this.onTheater,
required this.onHistory,
required this.onOpenDrama,
required this.onOpenContinue,
});
final bool active;
final VoidCallback onSearch;
final VoidCallback onProfile;
final VoidCallback onTheater;
final VoidCallback onHistory;
final Future<void> Function(Drama drama) onOpenDrama;
final Future<void> Function(Drama drama, WatchProgress record) onOpenContinue;
@override
State<HomeBrowseView> createState() => _HomeBrowseViewState();
}
class _HomeBrowseViewState extends State<HomeBrowseView> {
late final HomeBrowseLogic logic;
final ScrollController _scroll = ScrollController();
bool _channelsPinned = false;
@override
void initState() {
super.initState();
logic = HomeBrowseLogic(history: context.read<WatchHistoryRepository>());
_scroll.addListener(_onScroll);
}
@override
void didUpdateWidget(covariant HomeBrowseView oldWidget) {
super.didUpdateWidget(oldWidget);
logic.syncPage(active: widget.active, wasActive: oldWidget.active);
}
@override
void dispose() {
_scroll.removeListener(_onScroll);
_scroll.dispose();
logic.dispose();
super.dispose();
}
/// 滚过 hero 后频道行切成吸顶态,未过阈值时仍叠在轮播图上。
void _onScroll() {
if (!_scroll.hasClients) return;
final pinned =
_scroll.offset >= AppDimens.heroBannerHeight - _channelRowHeight;
if (pinned != _channelsPinned) {
setState(() => _channelsPinned = pinned);
}
}
/// 频道行只横向滚动,纵向拖动转交给页面列表,避免挡住上滑。
void _scrollPageBy(DragUpdateDetails details) {
if (!_scroll.hasClients) return;
final position = _scroll.position;
_scroll.jumpTo(
(_scroll.offset - details.delta.dy)
.clamp(position.minScrollExtent, position.maxScrollExtent)
.toDouble(),
);
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: logic,
builder: (context, _) => _page(context),
);
}
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final catalog = context.watch<CatalogController>();
return SafeArea(
bottom: false,
child: Column(
children: [
HomeBrowseHeader(
onSearch: widget.onSearch,
onProfile: widget.onProfile,
),
Expanded(child: _body(l10n, catalog)),
],
),
);
}
Widget _body(AppLocalizations l10n, CatalogController catalog) {
final source = catalog.dramas;
final banners = catalog.banners.isEmpty
? _fallbackBanners(source)
: catalog.banners;
final hasBanner = banners.isNotEmpty;
final pinned = !hasBanner || _channelsPinned;
return Stack(
children: [
Positioned.fill(
child: AppPullToRefresh(
key: const Key('home-refresh'),
onRefresh: () async {
await Future.wait([catalog.load(), logic.refreshRecent()]);
},
child: ListView(
key: const Key('home-browse-page'),
controller: _scroll,
physics: AppPullToRefresh.physics,
padding: const EdgeInsets.only(bottom: AppDimens.spaceXl),
children: _sections(l10n, catalog),
),
),
),
Positioned(
top: 0,
left: 0,
right: 0,
child: _channelBar(
l10n,
pinned: pinned,
overImage: hasBanner && !pinned,
),
),
],
);
}
Widget _channelBar(
AppLocalizations l10n, {
required bool pinned,
required bool overImage,
}) {
final content = GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragUpdate: _scrollPageBy,
child: _channels(l10n, overImage: overImage),
);
if (!pinned) return content;
return DecoratedBox(
key: const Key('home-channels-pinned'),
decoration: const BoxDecoration(
color: AppColors.ink,
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: content,
);
}
List<Widget> _sections(AppLocalizations l10n, CatalogController catalog) {
final source = catalog.dramas;
final dramas = _channelDramas(source, logic.channel);
final continueItem = _continueItem(logic.recent, catalog.dramas);
final banners = catalog.banners.isEmpty
? _fallbackBanners(source)
: catalog.banners;
return [
if (banners.isNotEmpty) _hero(banners, source),
if (continueItem != null) ...[
_continueHeader(l10n),
ContinueWatchingCard(
cardKey: const Key('home-continue-watching'),
playButtonKey: Key('home-continue-play-${continueItem.drama.id}'),
progressKey: Key('home-continue-progress-${continueItem.drama.id}'),
showRemaining: true,
drama: continueItem.drama,
record: continueItem.record,
onResume: () => unawaited(
widget.onOpenContinue(continueItem.drama, continueItem.record),
),
),
],
_channelHeader(l10n),
if (dramas.isEmpty)
_empty()
else
HomeDramaGrid(
l10n: l10n,
dramas: dramas,
onOpen: (drama) => unawaited(widget.onOpenDrama(drama)),
),
];
}
List<FeedBanner> _fallbackBanners(List<Drama> dramas) {
if (dramas.isEmpty) return const [];
final drama = dramas.first;
return [
FeedBanner(
id: 'home-fallback-${drama.id}',
imageUrl: drama.posterUrl,
linkType: 'DRAMA',
linkValue: drama.id,
),
];
}
Widget _continueHeader(AppLocalizations l10n) => _sectionHeader(
l10n.continueWatching,
actionKey: const Key('home-continue-all'),
actionLabel: l10n.seeAll,
onAction: widget.onHistory,
);
Widget _channelHeader(AppLocalizations l10n) => _sectionHeader(
_channelTitle(l10n),
actionKey: const Key('home-popular-more'),
actionLabel: l10n.seeAll,
onAction: widget.onTheater,
);
Widget _empty() => Padding(
padding: const EdgeInsets.symmetric(vertical: AppDimens.spaceXl),
child: EmptyState(
key: const Key('home-browse-empty'),
icon: Icons.movie_filter_outlined,
title: AppLocalizations.of(context)!.noStories,
subtitle: AppLocalizations.of(context)!.emptyHint,
compact: true,
),
);
Widget _channels(AppLocalizations l10n, {bool overImage = false}) {
final entries = [
(HomeChannel.hot, l10n.hot),
(HomeChannel.free, l10n.free),
(HomeChannel.newReleases, l10n.newDramas),
(HomeChannel.ranking, l10n.rankingTab),
];
return SizedBox(
key: const Key('home-channels'),
height: _channelRowHeight,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
itemCount: entries.length,
separatorBuilder: (_, _) => const SizedBox(width: _channelGap),
itemBuilder: (context, index) {
final entry = entries[index];
final selected = logic.channel == entry.$1;
return _channelItem(
entry.$1,
entry.$2,
selected: selected,
overImage: overImage,
);
},
),
);
}
Widget _channelItem(
HomeChannel channel,
String label, {
required bool selected,
required bool overImage,
}) {
final color = selected
? AppColors.blue
: (overImage ? Colors.white70 : AppColors.muted);
final underlineColor = selected ? AppColors.blue : Colors.transparent;
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
key: Key('home-channel-${channel.name}'),
behavior: HitTestBehavior.opaque,
onTap: () => logic.selectChannel(channel),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
label,
maxLines: 1,
style: TextStyle(
color: color,
fontSize: _channelFontSize,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
shadows: overImage ? _channelTextShadows : null,
),
),
const SizedBox(height: _channelUnderlineGap),
Container(
key: Key('home-channel-underline-${channel.name}'),
width: _channelUnderlineWidth,
height: _channelUnderlineHeight,
decoration: BoxDecoration(
color: underlineColor,
borderRadius: BorderRadius.circular(
_channelUnderlineHeight / 2,
),
),
),
],
),
),
);
}
Widget _hero(List<FeedBanner> banners, List<Drama> dramas) {
return FeedBannerCarousel(
key: const Key('home-hero-banner'),
banners: banners,
active: widget.active,
flush: true,
dramaFor: (banner) => _dramaForBanner(banner, dramas),
onOpen: (banner) {
final drama = _dramaForBanner(banner, dramas);
if (drama != null) unawaited(widget.onOpenDrama(drama));
},
);
}
Widget _sectionHeader(
String title, {
required Key actionKey,
required String actionLabel,
required VoidCallback onAction,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(
AppDimens.pageHorizontal,
AppDimens.spaceLg,
AppDimens.spaceSm,
AppDimens.spaceSm,
),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: AppDimens.sectionTitle,
fontWeight: FontWeight.w800,
),
),
),
TextButton(
key: actionKey,
onPressed: onAction,
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
minimumSize: const Size(AppDimens.tapTarget, AppDimens.tapTarget),
),
child: Text(actionLabel),
),
],
),
);
}
String _channelTitle(AppLocalizations l10n) {
return switch (logic.channel) {
HomeChannel.hot => l10n.trendingMicroDramas,
HomeChannel.free => l10n.free,
HomeChannel.newReleases => l10n.newDramas,
HomeChannel.ranking => l10n.rankingTitle,
};
}
List<Drama> _channelDramas(List<Drama> source, HomeChannel channel) {
final dramas = List<Drama>.of(source);
switch (channel) {
case HomeChannel.hot:
dramas.sort((a, b) {
final hot = (b.isHot ? 1 : 0).compareTo(a.isHot ? 1 : 0);
return hot != 0 ? hot : b.viewCount.compareTo(a.viewCount);
});
case HomeChannel.free:
return dramas.where(_isFree).toList();
case HomeChannel.newReleases:
return dramas;
case HomeChannel.ranking:
dramas.sort((a, b) => b.viewCount.compareTo(a.viewCount));
}
return dramas;
}
bool _isFree(Drama drama) {
return drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
}
Drama? _dramaForBanner(FeedBanner banner, List<Drama> dramas) {
final id = banner.dramaId;
if (id == null) return null;
for (final drama in dramas) {
if (drama.id == id) return drama;
}
return null;
}
({Drama drama, WatchProgress record})? _continueItem(
List<WatchProgress> records,
List<Drama> dramas,
) {
for (final record in records) {
if (record.finished || !record.hasPosition) continue;
for (final drama in dramas) {
if (drama.id == record.dramaId) {
return (drama: drama, record: record);
}
}
}
return null;
}
}
@@ -1,137 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../l10n/app_localizations.dart';
import '../../notices/notices_page.dart';
const double _searchHeight = 36;
/// 首页固定顶栏:搜索、通知与头像入口。
class HomeBrowseHeader extends StatelessWidget {
const HomeBrowseHeader({
super.key,
required this.onSearch,
required this.onProfile,
});
final VoidCallback onSearch;
final VoidCallback onProfile;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return SizedBox(
height: AppDimens.contentHeaderHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppDimens.pageHorizontal,
),
child: Row(
children: [
Expanded(child: _search(l10n, onSearch)),
const SizedBox(width: AppDimens.spaceSm),
_notifications(context, l10n),
const SizedBox(width: AppDimens.spaceSm),
_profile(l10n),
],
),
),
);
}
Widget _search(AppLocalizations l10n, VoidCallback onTap) {
return Semantics(
button: true,
label: l10n.search,
child: InkWell(
key: const Key('home-search'),
onTap: onTap,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
child: Container(
height: _searchHeight,
padding: const EdgeInsets.symmetric(horizontal: AppDimens.spaceMd),
decoration: BoxDecoration(
color: AppColors.input,
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
),
child: Row(
children: [
const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
const SizedBox(width: AppDimens.spaceSm),
Expanded(
child: Text(
l10n.searchDiscover,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.subtle,
fontSize: AppDimens.caption,
),
),
),
],
),
),
),
);
}
Widget _notifications(BuildContext context, AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.notices,
child: InkWell(
key: const Key('home-notifications'),
customBorder: const CircleBorder(),
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: (_) => const NoticesPage())),
child: Container(
width: AppDimens.tapTarget,
height: AppDimens.tapTarget,
decoration: const BoxDecoration(
color: AppColors.card,
shape: BoxShape.circle,
),
child: const Icon(
Icons.notifications_none_rounded,
color: AppColors.onSurface,
size: AppDimens.accountIcon,
),
),
),
);
}
Widget _profile(AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.myTab,
child: InkWell(
key: const Key('home-profile'),
customBorder: const CircleBorder(),
onTap: onProfile,
child: Container(
width: AppDimens.tapTarget,
height: AppDimens.tapTarget,
decoration: const BoxDecoration(
color: AppColors.card,
shape: BoxShape.circle,
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: AppDimens.accountIcon,
),
),
),
);
}
}
-228
View File
@@ -1,228 +0,0 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/widgets/remote_image.dart';
import '../../../l10n/app_localizations.dart';
/// 播放页快捷菜单:当前剧、发现、追剧与历史。
class HomeMenuSheet extends StatelessWidget {
const HomeMenuSheet({
super.key,
required this.drama,
required this.onOpenSeries,
required this.onDiscover,
required this.onHistory,
required this.onWatchlist,
});
final Drama drama;
final VoidCallback onOpenSeries;
final VoidCallback onDiscover;
final VoidCallback onHistory;
final VoidCallback onWatchlist;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
void closeThen(VoidCallback action) {
Navigator.pop(context);
action();
}
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_DramaSummary(
drama: drama,
l10n: l10n,
onOpenSeries: () => closeThen(onOpenSeries),
),
const SizedBox(height: 14),
_quickAccess(l10n, closeThen),
],
),
),
);
}
Widget _quickAccess(
AppLocalizations l10n,
void Function(VoidCallback) closeThen,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.quickAccess,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Row(
children: [
_item(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => closeThen(onDiscover),
),
_item(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: () => closeThen(onWatchlist),
),
_item(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: () => closeThen(onHistory),
),
],
),
],
);
}
Widget _item({
required Key key,
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: _MenuTile(key: key, icon: icon, label: label, onTap: onTap),
),
);
}
}
class _DramaSummary extends StatelessWidget {
const _DramaSummary({
required this.drama,
required this.l10n,
required this.onOpenSeries,
});
final Drama drama;
final AppLocalizations l10n;
final VoidCallback onOpenSeries;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: RemoteImage(drama.posterUrl, width: 64, height: 86),
),
const SizedBox(width: 10),
Expanded(child: _details()),
],
),
);
}
Widget _details() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(
l10n.episodeCount(drama.episodeCount),
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
const SizedBox(height: 8),
_button(),
],
);
}
Widget _button() {
return SizedBox(
height: 34,
child: FilledButton.icon(
key: const Key('drawer-open-series'),
onPressed: onOpenSeries,
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(9)),
textStyle: const TextStyle(fontSize: 11),
),
icon: const Icon(Icons.play_arrow_rounded, size: 15),
label: Text(l10n.exploreSeries),
),
);
}
}
class _MenuTile extends StatelessWidget {
const _MenuTile({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: AppColors.quick,
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: SizedBox(
height: 58,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: AppColors.secondary, size: 16),
const SizedBox(height: 4),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: AppColors.muted, fontSize: 9),
),
],
),
),
),
);
}
}