chore(cleanup): 删除无引用代码与无用资源
This commit is contained in:
1 parent
d2174c5b74
commit
3fefd544fb
16 files changed
-1544
No files matched your search
@@ -1,74 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models/drama.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../theme/app_dimens.dart';
|
||||
import 'remote_image.dart';
|
||||
|
||||
/// 收藏等账号列表复用的剧集卡片,视觉与"我的"页列表保持一致。
|
||||
class LibraryDramaTile extends StatelessWidget {
|
||||
const LibraryDramaTile({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final VoidCallback? onTap;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppDimens.innerPadding),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppDimens.innerRadius),
|
||||
child: RemoteImage(drama.posterUrl, width: 74, height: 103),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppDimens.listTitle,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
drama.genre.isEmpty ? drama.category : drama.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: AppDimens.caption,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import 'auth_logic.dart';
|
||||
|
||||
/// 与其它页保持一致的三层写法:binding 负责装配,logic 放状态与逻辑,view 只渲染。
|
||||
class AuthBinding {
|
||||
AuthLogic create({bool signUpMode = false}) => AuthLogic(signUpMode: signUpMode);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../core/widgets/empty_state.dart';
|
||||
import '../../core/widgets/plain_page_scaffold.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../drama_detail/drama_detail_view.dart';
|
||||
import 'widgets/drama_grid.dart';
|
||||
|
||||
/// 从剧场分类九宫格进入的分类剧目列表。
|
||||
class CategoryDramasPage extends StatelessWidget {
|
||||
const CategoryDramasPage({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.dramas,
|
||||
this.onOpen,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Drama> dramas;
|
||||
final ValueChanged<Drama>? onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Theme(
|
||||
data: AppTheme.light(),
|
||||
child: PlainPageScaffold(
|
||||
pageKey: const Key('category-dramas-page'),
|
||||
backKey: const Key('category-dramas-back'),
|
||||
title: title,
|
||||
child: dramas.isEmpty
|
||||
? EmptyState(
|
||||
key: const Key('category-dramas-empty'),
|
||||
icon: Icons.movie_filter_outlined,
|
||||
title: l10n.noCategoryDramas,
|
||||
subtitle: l10n.emptyHint,
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(top: 8, bottom: 28),
|
||||
child: DramaGrid(
|
||||
l10n: l10n,
|
||||
dramas: dramas,
|
||||
onOpen: (drama) => _open(context, drama),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _open(BuildContext context, Drama drama) {
|
||||
final callback = onOpen;
|
||||
if (callback != null) {
|
||||
callback(drama);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/content_category.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
|
||||
const double _gridPadding = 14;
|
||||
const double _tileSpacing = 8;
|
||||
const double _tileAspectRatio = 1.1;
|
||||
const int _columns = 3;
|
||||
const List<IconData> _icons = [
|
||||
Icons.favorite_rounded,
|
||||
Icons.bolt_rounded,
|
||||
Icons.theater_comedy_rounded,
|
||||
Icons.auto_awesome_rounded,
|
||||
Icons.psychology_alt_rounded,
|
||||
Icons.local_fire_department_rounded,
|
||||
Icons.family_restroom_rounded,
|
||||
Icons.work_history_rounded,
|
||||
];
|
||||
|
||||
/// 剧场顶部分类九宫格。
|
||||
class CategoryGrid extends StatelessWidget {
|
||||
const CategoryGrid({
|
||||
super.key,
|
||||
required this.categories,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<ContentCategory> categories;
|
||||
final ValueChanged<ContentCategory> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (categories.isEmpty) return const SizedBox.shrink();
|
||||
return GridView.builder(
|
||||
key: const Key('discover-category-grid'),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: _gridPadding),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
mainAxisSpacing: _tileSpacing,
|
||||
crossAxisSpacing: _tileSpacing,
|
||||
childAspectRatio: _tileAspectRatio,
|
||||
),
|
||||
itemCount: categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = categories[index];
|
||||
return _CategoryTile(
|
||||
category: category,
|
||||
icon: _icons[index % _icons.length],
|
||||
onTap: () => onTap(category),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryTile extends StatelessWidget {
|
||||
const _CategoryTile({
|
||||
required this.category,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final ContentCategory category;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
key: Key('discover-category-${category.id}'),
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blueSoft.withValues(alpha: 0.16),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: AppColors.blueSoft, size: 16),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
category.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/home_feed.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/empty_state.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../ranking_logic.dart';
|
||||
|
||||
const int _topLimit = 5;
|
||||
const double _rowHeight = 68;
|
||||
const double _posterWidth = 40;
|
||||
const double _posterHeight = 54;
|
||||
const double _badgeSize = 24;
|
||||
|
||||
/// 发现页内嵌榜单区块:热播/新剧切换 + 前 5 名。
|
||||
class RankingSection extends StatefulWidget {
|
||||
const RankingSection({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.fallback,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
final List<FeedSection> sections;
|
||||
final List<Drama> fallback;
|
||||
final ValueChanged<Drama> onOpen;
|
||||
|
||||
@override
|
||||
State<RankingSection> createState() => _RankingSectionState();
|
||||
}
|
||||
|
||||
class _RankingSectionState extends State<RankingSection> {
|
||||
late final RankingLogic logic = RankingLogic(
|
||||
sections: widget.sections,
|
||||
fallback: widget.fallback,
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant RankingSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.sections, widget.sections) ||
|
||||
!identical(oldWidget.fallback, widget.fallback)) {
|
||||
logic.updateSources(sections: widget.sections, fallback: widget.fallback);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
logic.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: logic,
|
||||
builder: (context, _) => _content(context),
|
||||
);
|
||||
|
||||
Widget _content(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final entries = logic.entries.take(_topLimit).toList();
|
||||
return Container(
|
||||
key: const Key('discover-ranking-section'),
|
||||
margin: const EdgeInsets.fromLTRB(14, 16, 14, 8),
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_header(l10n),
|
||||
const SizedBox(height: 8),
|
||||
if (entries.isEmpty)
|
||||
EmptyState(
|
||||
key: const Key('ranking-empty'),
|
||||
icon: Icons.leaderboard_outlined,
|
||||
title: l10n.rankingEmptyTitle,
|
||||
subtitle: l10n.rankingEmptyBody,
|
||||
compact: true,
|
||||
)
|
||||
else
|
||||
for (final entry in entries) _row(entry, l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_fire_department_rounded,
|
||||
color: AppColors.blue,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.rankingTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
_boardChip(
|
||||
key: const Key('ranking-tab-hot'),
|
||||
label: l10n.rankingHot,
|
||||
board: RankingBoard.hot,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_boardChip(
|
||||
key: const Key('ranking-tab-new'),
|
||||
label: l10n.rankingNew,
|
||||
board: RankingBoard.newReleases,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _boardChip({
|
||||
required Key key,
|
||||
required String label,
|
||||
required RankingBoard board,
|
||||
}) {
|
||||
final selected = logic.board == board;
|
||||
return Material(
|
||||
key: key,
|
||||
color: selected ? AppColors.blue : AppColors.card,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => logic.select(board),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? AppColors.onPrimary : AppColors.onSurface,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(RankingEntry entry, AppLocalizations l10n) {
|
||||
return _RankRow(
|
||||
entry: entry,
|
||||
subtitle: _subtitle(entry.drama, l10n),
|
||||
onTap: () => widget.onOpen(entry.drama),
|
||||
);
|
||||
}
|
||||
|
||||
String _subtitle(Drama drama, AppLocalizations l10n) {
|
||||
final genre = drama.genre.isNotEmpty ? drama.genre : drama.category;
|
||||
final count = l10n.episodeCount(drama.episodeCount);
|
||||
return genre.isEmpty ? count : '$genre · $count';
|
||||
}
|
||||
}
|
||||
|
||||
class _RankRow extends StatelessWidget {
|
||||
const _RankRow({
|
||||
required this.entry,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final RankingEntry entry;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final drama = entry.drama;
|
||||
return Material(
|
||||
key: Key('ranking-entry-${drama.id}'),
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(height: _rowHeight, child: _content(drama)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _content(Drama drama) {
|
||||
return Row(
|
||||
children: [
|
||||
_rankBadge(entry.rank),
|
||||
const SizedBox(width: 8),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: RemoteImage(
|
||||
drama.posterUrl,
|
||||
width: _posterWidth,
|
||||
height: _posterHeight,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _text(drama)),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.subtle,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _text(Drama drama) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.subtle, fontSize: 9),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _rankBadge(int rank) {
|
||||
final top = rank <= 3;
|
||||
return Container(
|
||||
width: _badgeSize,
|
||||
height: _badgeSize,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: top ? AppColors.gold.withValues(alpha: 0.16) : AppColors.raised,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
color: top ? AppColors.gold : AppColors.muted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/data/models/drama.dart';
|
||||
import '../../../core/data/models/watch_progress.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
|
||||
/// 「我的」页唯一保留的续看入口。
|
||||
class ContinueWatchingCard extends StatelessWidget {
|
||||
const ContinueWatchingCard({
|
||||
super.key,
|
||||
required this.drama,
|
||||
required this.record,
|
||||
required this.onResume,
|
||||
this.cardKey = const Key('mine-continue-watching'),
|
||||
this.playButtonKey = const Key('mine-continue-play'),
|
||||
this.progressKey,
|
||||
this.showRemaining = false,
|
||||
});
|
||||
|
||||
final Drama drama;
|
||||
final WatchProgress record;
|
||||
final VoidCallback onResume;
|
||||
final Key cardKey;
|
||||
final Key playButtonKey;
|
||||
final Key? progressKey;
|
||||
final bool showRemaining;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
key: cardKey,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 14),
|
||||
padding: const EdgeInsets.all(9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
child: RemoteImage(drama.posterUrl, width: 50, height: 64),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(child: _info(l10n)),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
height: 28,
|
||||
child: FilledButton(
|
||||
key: playButtonKey,
|
||||
onPressed: onResume,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.blue,
|
||||
foregroundColor: AppColors.onPrimary,
|
||||
padding: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
textStyle: const TextStyle(fontSize: 10),
|
||||
),
|
||||
child: Text(l10n.resume),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _info(AppLocalizations l10n) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatching,
|
||||
style: const TextStyle(
|
||||
color: AppColors.accent,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
drama.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_progressText(l10n),
|
||||
key: progressKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 9),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _progressText(AppLocalizations l10n) {
|
||||
final episode = drama.episodes.where((item) => item.id == record.episodeId);
|
||||
if (showRemaining) {
|
||||
final number = episode.isEmpty ? 1 : episode.first.number;
|
||||
final remaining = record.duration - record.position;
|
||||
final minutes = remaining.inSeconds <= 0
|
||||
? 0
|
||||
: (remaining.inSeconds / Duration.secondsPerMinute).ceil();
|
||||
return l10n.episodeRemaining(number, minutes);
|
||||
}
|
||||
final label = episode.isEmpty
|
||||
? l10n.episodeCount(drama.episodeCount)
|
||||
: l10n.episodeOfTotal(episode.first.number, drama.episodeCount);
|
||||
return '$label · ${_clock(record.position)} / ${_clock(record.duration)}';
|
||||
}
|
||||
}
|
||||
|
||||
String _clock(Duration value) {
|
||||
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
Reference in new issue
Block a user