215 lines
6.8 KiB
Dart
215 lines
6.8 KiB
Dart
// 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/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 '../../core/widgets/paged_local_list.dart';
|
|
import '../../core/widgets/plain_page_scaffold.dart';
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../player/open_player.dart';
|
|
import 'widgets/ranking_drama_row.dart';
|
|
|
|
const int _topCount = 50;
|
|
const double _loadMoreThreshold = 200;
|
|
|
|
/// 剧场榜单页:统一行布局,名次角标压在封面左上角,右侧字段无数据不展示。
|
|
class RankingPage extends StatefulWidget {
|
|
const RankingPage({super.key, this.embedded = false});
|
|
|
|
final bool embedded;
|
|
|
|
@override
|
|
State<RankingPage> createState() => _RankingPageState();
|
|
}
|
|
|
|
class _RankingPageState extends State<RankingPage> {
|
|
final ScrollController _scroll = ScrollController();
|
|
PagedLocalListController<Drama>? _pager;
|
|
bool _refreshing = false;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
if (_pager != null) return;
|
|
_pager = PagedLocalListController<Drama>(
|
|
items: _ranked(context.read<CatalogController>().dramas),
|
|
)..addListener(_rebuild);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_pager
|
|
?..removeListener(_rebuild)
|
|
..dispose();
|
|
_scroll.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _rebuild() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
List<Drama> _ranked(List<Drama> source) {
|
|
final dramas = [...source]
|
|
..sort((a, b) => b.viewCount.compareTo(a.viewCount));
|
|
return dramas.take(_topCount).toList(growable: false);
|
|
}
|
|
|
|
Future<void> _refresh() async {
|
|
if (_refreshing) return;
|
|
_refreshing = true;
|
|
try {
|
|
final catalog = context.read<CatalogController>();
|
|
await catalog.refresh();
|
|
if (!mounted) return;
|
|
_pager!.replaceAll(_ranked(catalog.dramas));
|
|
if (_scroll.hasClients) {
|
|
_scroll.jumpTo(_scroll.position.minScrollExtent);
|
|
}
|
|
} finally {
|
|
_refreshing = false;
|
|
}
|
|
}
|
|
|
|
bool _onScroll(ScrollNotification notification) {
|
|
if (notification.depth != 0 || _refreshing) return false;
|
|
if (notification.metrics.extentAfter > _loadMoreThreshold) return false;
|
|
if (notification is ScrollUpdateNotification ||
|
|
notification is ScrollEndNotification ||
|
|
notification is OverscrollNotification) {
|
|
unawaited(_pager!.loadMore());
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final catalog = context.watch<CatalogController>();
|
|
final top = _pager!.visibleItems;
|
|
final content = NotificationListener<ScrollNotification>(
|
|
onNotification: _onScroll,
|
|
child: AppPullToRefresh(
|
|
key: const Key('ranking-refresh'),
|
|
onRefresh: _refresh,
|
|
child: ListView(
|
|
key: const Key('ranking-list'),
|
|
controller: _scroll,
|
|
physics: AppPullToRefresh.physics,
|
|
padding: const EdgeInsets.only(top: 8, bottom: 24),
|
|
children: top.isEmpty
|
|
? [_emptyState(l10n, catalog)]
|
|
: [
|
|
if (widget.embedded) _intro(l10n),
|
|
const SizedBox(height: AppDimens.spaceMd),
|
|
for (var index = 0; index < top.length; index++) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.feedPageHorizontal,
|
|
vertical: AppDimens.spaceSm,
|
|
),
|
|
child: RankingDramaRow(
|
|
rank: index + 1,
|
|
drama: top[index],
|
|
onTap: () => unawaited(
|
|
playDramaFromCatalog(context, top[index]),
|
|
),
|
|
),
|
|
),
|
|
if (index != top.length - 1)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: AppDimens.feedPageHorizontal,
|
|
),
|
|
child: Divider(
|
|
height: AppDimens.mineGridDividerWidth,
|
|
thickness: AppDimens.mineGridDividerWidth,
|
|
color: AppColors.border,
|
|
),
|
|
),
|
|
],
|
|
PagedLoadFooter(
|
|
loading: _pager!.loading,
|
|
hasMore: _pager!.hasMore,
|
|
hasItems: _pager!.visibleItems.isNotEmpty,
|
|
loadMoreKey: const Key('ranking-load-more'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
if (widget.embedded) {
|
|
return KeyedSubtree(key: const Key('ranking-page'), child: content);
|
|
}
|
|
return PlainPageScaffold(
|
|
pageKey: const Key('ranking-page'),
|
|
backKey: const Key('ranking-back'),
|
|
title: l10n.topCharts,
|
|
subtitle: l10n.rankingSubtitle,
|
|
child: content,
|
|
);
|
|
}
|
|
|
|
Widget _intro(AppLocalizations l10n) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.feedPageHorizontal,
|
|
),
|
|
child: Column(
|
|
key: const Key('ranking-hero'),
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.topCharts,
|
|
style: const TextStyle(
|
|
color: AppColors.textPrimary,
|
|
fontSize: AppDimens.rankingHeroTitle,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l10n.rankingSubtitle,
|
|
style: const TextStyle(
|
|
color: AppColors.textTertiary,
|
|
fontSize: AppDimens.rankingHeroSubtitle,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _emptyState(AppLocalizations l10n, CatalogController catalog) {
|
|
final theme = Theme.of(context);
|
|
return SizedBox(
|
|
height: 420,
|
|
child: Theme(
|
|
data: theme.copyWith(
|
|
colorScheme: theme.colorScheme.copyWith(
|
|
onSurface: AppColors.textTertiary,
|
|
),
|
|
),
|
|
child: EmptyState(
|
|
key: Key(catalog.error == null ? 'ranking-empty' : 'ranking-error'),
|
|
icon: catalog.error == null
|
|
? Icons.leaderboard_outlined
|
|
: Icons.cloud_off_rounded,
|
|
title: catalog.error == null ? l10n.noStories : l10n.catalogError,
|
|
actionLabel: catalog.error == null ? null : l10n.retry,
|
|
onAction: catalog.error == null ? null : () => unawaited(_refresh()),
|
|
stretchContent: true,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|