feat(app): 剧场页九宫格与极简我的页

This commit is contained in:
Zhou Weidong committed 2026-10-20 05:28:51 +08:00
1 parent dc8ca7b743
commit 35338cbfc8
46 files changed
+2353 -4358

No files matched your search

@@ -0,0 +1,71 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/empty_state.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.dark(),
child: Scaffold(
key: const Key('category-dramas-page'),
backgroundColor: AppColors.ink,
appBar: AppBar(
backgroundColor: AppColors.ink,
foregroundColor: AppColors.onSurface,
title: Text(title),
),
body: SafeArea(
top: false,
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: 12, 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)),
);
}
}
+2 -7
View File
@@ -1,14 +1,9 @@
// Author: fruit.com.cn
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'discover_logic.dart';
class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
required WatchHistoryRepository history,
SearchRepository? search,
}) => DiscoverLogic(drama: drama, history: history, searchRepository: search);
DiscoverLogic create({SearchRepository? search}) =>
DiscoverLogic(searchRepository: search);
}
+17 -157
View File
@@ -5,39 +5,23 @@ import 'dart:async';
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/watch_progress.dart';
import '../../core/data/remote/api_exception.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
enum DiscoverPageAction { home, my, watchlist, detail, resume }
import 'discover_types.dart';
/// 剧场页搜索状态:服务端检索、防抖与错误态。
class DiscoverLogic extends ChangeNotifier {
DiscoverLogic({
required this.history,
required this.drama,
this.searchRepository,
}) {
refreshPosition();
}
DiscoverLogic({this.searchRepository});
final WatchHistoryRepository history;
final SearchRepository? searchRepository;
Drama drama;
late Future<Duration?> position;
WatchProgress? resumeRecord;
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
bool searchOpen = false;
/// 搜索防抖与分页口径:与后端 `/app/search` 一致。
Duration searchDebounce = const Duration(milliseconds: 350);
static const int searchPageSize = 20;
List<Drama> searchResults = const [];
String? selectedLanguage;
int searchTotal = 0;
bool searchLoading = false;
String? searchError;
Timer? _searchTimer;
@@ -45,36 +29,12 @@ class DiscoverLogic extends ChangeNotifier {
int _searchRequest = 0;
bool _disposed = false;
bool get hasSearchQuery => search.text.trim().isNotEmpty;
void notify() {
if (!_disposed) notifyListeners();
}
void refreshPosition() {
// 续看位置来自服务端(每剧一条记录);未登录或没有记录时给 null,UI 显示"从头看"
position = history
.resumePointFor(drama.id)
.then((point) => point?.position)
.catchError((Object _) => null);
refreshResume();
}
Future<void> refreshResume() async {
try {
final recent = await history.recent(limit: 1);
if (recent.isEmpty || recent.first.finished) {
resumeRecord = null;
} else {
resumeRecord = recent.first;
}
} catch (_) {
resumeRecord = null;
}
notify();
}
bool get hasSearchQuery => search.text.trim().isNotEmpty;
/// 输入变化只触发后端查询;本地不再做标题/简介匹配。
void onSearchChanged() {
final query = search.text.trim();
if (query.isEmpty) {
@@ -86,47 +46,17 @@ class DiscoverLogic extends ChangeNotifier {
_scheduleSearch(query);
}
/// 搜索态展示后端结果,非搜索态展示剧场自身的可见短剧。
List<Drama> visibleDramas(List<Drama> fallback) {
if (!hasSearchQuery) return _filterByLanguage(fallback);
return _filterByLanguage(searchResults);
}
void openSearch() => searchFocus.requestFocus();
/// 当前 feed 中出现过的语种,按首次出现顺序去重。
List<String> languagesOf(List<Drama> dramas) {
final languages = <String>[];
for (final drama in dramas) {
final language = drama.language;
if (language.isNotEmpty && !languages.contains(language)) {
languages.add(language);
}
}
return languages;
}
/// 按语种筛选;null 表示全部。
void selectLanguage(String? code) {
selectedLanguage = code;
notify();
}
List<Drama> _filterByLanguage(List<Drama> dramas) {
final code = selectedLanguage;
if (code == null) return dramas;
return dramas.where((drama) => drama.language == code).toList();
}
Future<void> retrySearch() async {
void retrySearch() {
final query = search.text.trim();
if (query.isEmpty) return;
await _runSearch(query);
if (query.isNotEmpty) unawaited(_runSearch(query));
}
void _resetSearch() {
_searchTimer?.cancel();
_searchRequest++;
searchResults = const [];
searchTotal = 0;
searchLoading = false;
searchError = null;
_lastRequestedQuery = '';
@@ -162,7 +92,6 @@ class DiscoverLogic extends ChangeNotifier {
);
if (request != _searchRequest) return;
searchResults = result.list;
searchTotal = result.total;
} on ApiException catch (exception) {
_applySearchError(request, exception.message);
} catch (_) {
@@ -179,47 +108,20 @@ class DiscoverLogic extends ChangeNotifier {
if (request != _searchRequest) return;
searchError = message;
searchResults = const [];
searchTotal = 0;
}
Drama? resumeDrama(List<Drama> dramas) {
final record = resumeRecord;
if (record == null) return null;
for (final drama in dramas) {
if (drama.id == record.dramaId) return drama;
}
return null;
}
bool get showSearch => searchOpen || search.text.isNotEmpty;
void openSearch() {
searchOpen = true;
notify();
searchFocus.requestFocus();
}
void syncDrama({
/// 从播放器发起的搜索请求只在剧场页可见时抢焦点。
void syncSearch({
required bool active,
required bool wasActive,
required Drama nextDrama,
required bool dramaChanged,
required int searchRequest,
required int previousSearchRequest,
required int request,
required int previousRequest,
required bool Function() isActive,
}) {
if (active && (!wasActive || dramaChanged)) {
drama = nextDrama;
refreshPosition();
notify();
}
if (active && searchRequest != previousSearchRequest) {
searchOpen = true;
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
}
if (!active || request == previousRequest) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isActive()) searchFocus.requestFocus();
});
WidgetsBinding.instance.scheduleFrame();
}
void act(
@@ -235,48 +137,6 @@ class DiscoverLogic extends ChangeNotifier {
}
}
Drama? dramaFor(String? dramaId, List<Drama> dramas) {
for (final item in dramas) {
if (item.id == dramaId) return item;
}
return null;
}
void openCard(
BuildContext context,
Drama? selected, {
required bool embedded,
ValueChanged<Drama>? onDramaSelected,
ValueChanged<DiscoverPageAction>? onAction,
}) {
if (selected == null) {
unavailable(context);
} else if (embedded && onDramaSelected != null) {
onDramaSelected(selected);
} else {
act(
context,
DiscoverPageAction.detail,
embedded: embedded,
onAction: onAction,
);
}
}
void unavailable(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice)));
}
void catalogInfo(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
@override
void dispose() {
_disposed = true;
+23
View File
@@ -0,0 +1,23 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
/// 剧场页与播放器之间的返回动作。
enum DiscoverPageAction { home, my }
/// 剧场页导航回调;没有 `onAction` 时按独立页面处理。
class DiscoverPageCallbacks {
const DiscoverPageCallbacks({
this.onAction,
this.onDramaSelected,
this.onProfile,
});
final ValueChanged<DiscoverPageAction>? onAction;
final ValueChanged<Drama>? onDramaSelected;
final VoidCallback? onProfile;
bool get embedded => onAction != null;
}
+230 -653
View File
@@ -4,12 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../core/config/app_config.dart';
import '../../core/data/models/content_category.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/catalog_repository.dart';
import '../../core/data/repositories/search_repository.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../core/data/services/category_controller.dart';
import '../../core/formatting/error_text.dart';
import '../../core/theme/app_colors.dart';
@@ -17,53 +17,34 @@ import '../../core/theme/app_dimens.dart';
import '../../core/theme/app_theme.dart';
import '../../core/widgets/app_bottom_nav.dart';
import '../../core/widgets/empty_state.dart';
import '../../core/widgets/remote_image.dart';
import '../../l10n/app_localizations.dart';
import '../drama_detail/drama_detail_view.dart';
import 'category_dramas_page.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
import 'discover_types.dart';
import 'widgets/category_grid.dart';
import 'widgets/discover_header.dart';
import 'widgets/discover_search_field.dart';
import 'widgets/discover_search_results.dart';
import 'widgets/drama_grid.dart';
import 'widgets/feed_banner_carousel.dart';
import 'widgets/ranking_section.dart';
part 'widgets/home_drama_grid.dart';
/// 剧场页:搜索 + 分类九宫格 + 榜单 + 全部剧目。
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
required this.drama,
required this.duration,
this.focusSearch = false,
this.history,
this.searchRepository,
this.embedded = false,
this.active = true,
this.searchRequest = 0,
this.signedIn = false,
this.onSignIn,
this.onCoins,
this.onRewards,
this.onAction,
this.dramas,
this.onDramaSelected,
this.banners = const [],
this.onBannerSelected,
this.focusSearch = false,
this.callbacks = const DiscoverPageCallbacks(),
});
final Drama drama;
final Duration duration;
final bool focusSearch;
final WatchHistoryRepository? history;
final SearchRepository? searchRepository;
final bool embedded;
final bool active;
final int searchRequest;
final bool signedIn;
final VoidCallback? onSignIn;
final VoidCallback? onCoins;
final VoidCallback? onRewards;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
final List<FeedBanner> banners;
final ValueChanged<FeedBanner>? onBannerSelected;
final bool focusSearch;
final DiscoverPageCallbacks callbacks;
@override
State<DiscoverPage> createState() => _DiscoverPageState();
@@ -72,32 +53,28 @@ class DiscoverPage extends StatefulWidget {
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
bool get embedded => widget.callbacks.embedded;
@override
void initState() {
super.initState();
logic = DiscoverBinding().create(
drama: widget.drama,
history: widget.history ?? context.read<WatchHistoryRepository>(),
search: widget.searchRepository ?? context.read<SearchRepository>(),
);
if (widget.focusSearch) {
logic.searchOpen = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
logic = DiscoverBinding().create(search: context.read<SearchRepository>());
if (widget.focusSearch) _focusOnFirstFrame();
}
void _focusOnFirstFrame() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.openSearch();
});
}
@override
void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget);
logic.syncDrama(
logic.syncSearch(
active: widget.active,
wasActive: oldWidget.active,
nextDrama: widget.drama,
dramaChanged: oldWidget.drama.id != widget.drama.id,
searchRequest: widget.searchRequest,
previousSearchRequest: oldWidget.searchRequest,
request: widget.searchRequest,
previousRequest: oldWidget.searchRequest,
isActive: () => mounted && widget.active,
);
}
@@ -119,15 +96,14 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _page(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final categories = context.watch<CategoryController>();
final allDramas = [...?widget.dramas, ...categories.visibleDramas];
final visible = logic.visibleDramas(categories.visibleDramas);
final banners = context.watch<CatalogController>().banners;
final content = SafeArea(
bottom: !widget.embedded,
bottom: !embedded,
child: Column(
children: [
_header(l10n),
Expanded(child: _feed(l10n, categories, allDramas, visible)),
if (!widget.embedded) _bottomNav(l10n),
DiscoverHeader(onSearch: logic.openSearch, onProfile: _openProfile),
Expanded(child: _feed(l10n, categories, banners)),
if (!embedded) _bottomNav(l10n),
],
),
);
@@ -135,7 +111,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
value: SystemUiOverlayStyle.light,
child: Theme(
data: AppTheme.dark(),
child: widget.embedded
child: embedded
? content
: Scaffold(backgroundColor: AppColors.ink, body: content),
),
@@ -145,523 +121,222 @@ class _DiscoverPageState extends State<DiscoverPage> {
Widget _feed(
AppLocalizations l10n,
CategoryController categories,
List<Drama> allDramas,
List<Drama> visible,
) => RefreshIndicator(
key: const Key('discover-refresh'),
color: AppColors.blue,
backgroundColor: AppColors.card,
onRefresh: () async {
await Future.wait([categories.load(), logic.refreshResume()]);
},
child: CustomScrollView(
controller: logic.scroll,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (widget.banners.isNotEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: FeedBannerCarousel(
banners: widget.banners,
active: widget.active,
onOpen: (banner) => widget.onBannerSelected?.call(banner),
),
List<FeedBanner> banners,
) {
return RefreshIndicator(
key: const Key('discover-refresh'),
color: AppColors.blue,
backgroundColor: AppColors.card,
onRefresh: categories.load,
child: ListView(
controller: logic.scroll,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 24),
children: [
Padding(
padding: const EdgeInsets.only(top: 8),
child: DiscoverSearchField(
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
loading: logic.searchLoading,
),
),
SliverToBoxAdapter(child: _search()),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 14, 0, 12),
child: _categories(l10n, categories),
..._content(l10n, categories, banners),
],
),
);
}
List<Widget> _content(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (logic.hasSearchQuery) return [_searchContent(l10n)];
return _theaterContent(l10n, categories, banners);
}
Widget _searchContent(AppLocalizations l10n) => DiscoverSearchResults(
loading: logic.searchLoading,
error: logic.searchError,
results: logic.searchResults,
onRetry: logic.retrySearch,
onOpen: _openDrama,
);
List<Widget> _theaterContent(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
if (categories.loading && categories.feed.sections.isEmpty) {
return [_block(const CircularProgressIndicator(strokeWidth: 2))];
}
final error = categories.error;
if (error != null) {
return [
_block(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, error, l10n.categoriesError),
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 12),
child: _languages(l10n, allDramas),
];
}
return _theaterBlocks(l10n, categories, banners);
}
List<Widget> _theaterBlocks(
AppLocalizations l10n,
CategoryController categories,
List<FeedBanner> banners,
) {
final dramas = categories.feed.dramas;
return [
Padding(
padding: const EdgeInsets.only(top: 12),
child: CategoryGrid(
categories: categories.categories,
onTap: (category) => _openCategory(category, categories),
),
),
if (banners.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 12),
child: FeedBannerCarousel(
banners: banners,
active: widget.active,
onOpen: _openBanner,
),
),
if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _continueCard(
l10n,
logic.resumeDrama(allDramas)!,
logic.resumeRecord!,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 14),
child: _quickActions(l10n, categories),
),
if (categories.feed.sections.isNotEmpty || dramas.isNotEmpty)
RankingSection(
sections: categories.feed.sections,
fallback: dramas,
onOpen: _openDrama,
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 2, 14, 10),
child: Row(
children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w800,
),
),
),
TextButton(
onPressed: () =>
categories.selectCode(CategoryController.allCode),
style: TextButton.styleFrom(
foregroundColor: AppColors.accent,
minimumSize: const Size(44, 44),
padding: EdgeInsets.zero,
),
child: Text(
'${l10n.all} ›',
style: const TextStyle(fontSize: 10),
),
),
],
),
),
),
_HomeDramaGrid(
l10n: l10n,
categories: categories,
dramas: visible,
searching: logic.hasSearchQuery,
searchLoading: logic.searchLoading,
searchError: logic.searchError,
onRetrySearch: () => logic.retrySearch(),
onOpen: (drama) => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
),
],
_sectionTitle(l10n.allDramas),
if (dramas.isEmpty) _emptyCatalog(l10n) else _dramaGrid(l10n, dramas),
];
}
Widget _block(Widget child) =>
SizedBox(height: 280, child: Center(child: child));
Widget _emptyCatalog(AppLocalizations l10n) => Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: EmptyState(
key: const Key('discover-empty'),
icon: Icons.movie_filter_outlined,
title: l10n.noStories,
subtitle: l10n.emptyHint,
),
);
Widget _header(AppLocalizations l10n) {
return SizedBox(
height: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
),
Widget _dramaGrid(AppLocalizations l10n, List<Drama> dramas) {
return DramaGrid(l10n: l10n, dramas: dramas, onOpen: _openDrama);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.fromLTRB(14, 18, 14, 10),
child: Row(
children: [
Container(
width: 3,
height: 18,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(2),
),
const SizedBox(width: 8),
Text(
l10n.discoverTab,
),
const SizedBox(width: 6),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontSize: AppDimens.previewSectionTitle,
fontWeight: FontWeight.w800,
),
),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: logic.openSearch,
),
const SizedBox(width: 8),
Semantics(
button: true,
label: widget.signedIn ? l10n.myTab : l10n.logIn,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: widget.signedIn ? widget.onCoins : widget.onSignIn,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
),
),
],
),
),
);
}
Widget _search() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
height: 36,
child: TextField(
key: const Key('discover-search'),
controller: logic.search,
focusNode: logic.searchFocus,
onChanged: (_) => logic.onSearchChanged(),
onTapOutside: (_) => logic.searchFocus.unfocus(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
suffixIcon: logic.searchLoading
? const Padding(
padding: EdgeInsets.all(11),
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
),
);
}
Widget _categories(AppLocalizations l10n, CategoryController categories) {
final choices = <({String code, String label})>[
(code: CategoryController.allCode, label: l10n.recommended),
for (final category in categories.categories)
(code: 'category-${category.id}', label: category.name),
];
return SizedBox(
key: const Key('home-category-chips'),
height: AppDimens.tapTarget,
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
itemCount: choices.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
final choice = choices[index];
final selected = categories.selectedCode == choice.code;
return Semantics(
selected: selected,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => categories.selectCode(choice.code),
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 15),
decoration: BoxDecoration(
color: selected ? AppColors.blue : const Color(0xFF29272B),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: selected ? AppColors.blue : Colors.transparent,
),
),
child: Text(
choice.label,
maxLines: 1,
style: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
height: 1.1,
),
),
),
),
),
);
},
),
);
}
Widget _languages(AppLocalizations l10n, List<Drama> dramas) {
final languages = logic.languagesOf(dramas);
if (languages.isEmpty) return const SizedBox.shrink();
final selected = logic.selectedLanguage;
return SizedBox(
key: const Key('discover-language-chips'),
height: AppDimens.tapTarget,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 14),
scrollDirection: Axis.horizontal,
children: [
_languageChip(
key: const Key('discover-language-all'),
label: l10n.all,
selected: selected == null,
onTap: () => logic.selectLanguage(null),
),
for (final language in languages)
_languageChip(
key: Key('discover-language-$language'),
label: _languageLabel(l10n, language),
selected: selected == language,
onTap: () => logic.selectLanguage(language),
),
],
),
);
}
Widget _languageChip({
required Key key,
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Center(
child: ChoiceChip(
key: key,
label: Text(label),
selected: selected,
showCheckmark: false,
onSelected: (_) => onTap(),
selectedColor: AppColors.blue,
backgroundColor: const Color(0xFF29272B),
side: BorderSide.none,
labelStyle: TextStyle(
color: selected ? AppColors.onPrimary : AppColors.muted,
fontSize: AppDimens.chipFont,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
),
),
),
);
}
String _languageLabel(AppLocalizations l10n, String code) {
if (code == 'zh-CN' || code == 'zh') return l10n.languageChinese;
if (code == 'id') return l10n.languageIndonesian;
if (code == 'en') return l10n.languageEnglish;
return code;
}
Widget _continueCard(
AppLocalizations l10n,
Drama drama,
WatchProgress record,
) {
final progress = record.fraction;
return Container(
key: const Key('discover-continue-watching'),
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: AppColors.cardAlt,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 52,
height: 66,
child: RemoteImage(
drama.posterUrl,
fit: BoxFit.cover,
showFallbackIcon: true,
),
),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.accent,
fontSize: 8,
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(
_resumeShort(drama, record),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: progress <= 0 ? null : progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
valueColor: const AlwaysStoppedAnimation(AppColors.blue),
),
),
],
),
),
const SizedBox(width: 10),
FilledButton(
key: const Key('discover-continue-play'),
onPressed: () => logic.openCard(
context,
drama,
embedded: widget.embedded,
onDramaSelected: widget.onDramaSelected,
onAction: widget.onAction,
),
style: FilledButton.styleFrom(
backgroundColor: AppColors.blue,
foregroundColor: AppColors.onPrimary,
minimumSize: const Size(56, 28),
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
child: Text(l10n.resume),
),
],
),
);
}
String _resumeShort(Drama drama, WatchProgress record) {
final label = AppLocalizations.of(context)!;
Episode? episode;
for (final item in drama.episodes) {
if (item.id == record.episodeId) {
episode = item;
break;
void _openProfile() {
final callback = widget.callbacks.onProfile;
if (callback != null) {
callback();
return;
}
_act(DiscoverPageAction.my);
}
void _act(DiscoverPageAction action) {
logic.act(
context,
action,
embedded: embedded,
onAction: widget.callbacks.onAction,
);
}
void _openCategory(ContentCategory category, CategoryController categories) {
Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => CategoryDramasPage(
title: category.name,
dramas: categories.dramasOf(category.id),
onOpen: widget.callbacks.onDramaSelected == null ? null : _openDrama,
),
),
);
}
void _openDrama(Drama drama) {
final callback = widget.callbacks.onDramaSelected;
if (callback != null) {
callback(drama);
return;
}
_pushDetail(drama);
}
void _pushDetail(Drama drama) {
Navigator.of(context).push<void>(
MaterialPageRoute(builder: (_) => DramaDetailPage(drama: drama)),
);
}
Future<void> _openBanner(FeedBanner banner) async {
final dramaId = banner.dramaId;
if (dramaId == null) return;
for (final drama in context.read<CatalogController>().dramas) {
if (drama.id == dramaId) {
_pushDetail(drama);
return;
}
}
if (episode == null) return _duration(record.position);
return label.episodeOfTotal(episode.number, drama.episodeCount);
}
Widget _quickActions(AppLocalizations l10n, CategoryController categories) {
return Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('discover-quick-filter'),
icon: Icons.tune_rounded,
label: l10n.filter,
color: AppColors.secondary,
onTap: logic.openSearch,
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-charts'),
icon: Icons.local_fire_department_rounded,
label: l10n.topCharts,
color: AppColors.blue,
onTap: () => categories.selectCode(CategoryController.hotCode),
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-new'),
icon: Icons.auto_awesome_rounded,
label: l10n.newReleases,
color: AppColors.gold,
onTap: () => categories.selectCode(CategoryController.newCode),
),
),
const SizedBox(width: 7),
Expanded(
child: _QuickAction(
key: const Key('discover-quick-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
color: AppColors.success,
onTap: () => logic.act(
context,
DiscoverPageAction.watchlist,
embedded: widget.embedded,
onAction: widget.onAction,
),
),
),
],
);
try {
final drama = await context.read<CatalogRepository>().fetchDrama(dramaId);
if (mounted) _pushDetail(drama);
} catch (_) {
if (!mounted) return;
final message = AppLocalizations.of(context)!.detailLoadFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
}
Widget _bottomNav(AppLocalizations l10n) => AppBottomNav(
@@ -671,12 +346,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.play_circle_outline_rounded,
selectedIcon: Icons.play_circle_fill_rounded,
label: l10n.homeTab,
onTap: () => logic.act(
context,
DiscoverPageAction.home,
embedded: widget.embedded,
onAction: widget.onAction,
),
onTap: () => _act(DiscoverPageAction.home),
),
AppBottomNavItem(
tabKey: const Key('discover-nav-current'),
@@ -691,101 +361,8 @@ class _DiscoverPageState extends State<DiscoverPage> {
icon: Icons.video_library_outlined,
selectedIcon: Icons.video_library_rounded,
label: l10n.myTab,
onTap: () => logic.act(
context,
DiscoverPageAction.my,
embedded: widget.embedded,
onAction: widget.onAction,
),
onTap: () => _act(DiscoverPageAction.my),
),
],
);
}
class _HeaderIcon extends StatelessWidget {
const _HeaderIcon({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 18),
),
);
}
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
final IconData icon;
final String label;
final Color color;
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: Container(
height: 62,
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 5),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color, size: 16),
const SizedBox(height: 4),
Text(
label,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.muted,
fontSize: 9,
fontWeight: FontWeight.w500,
height: 1.05,
),
),
],
),
),
),
);
}
}
String _duration(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';
}
+76
View File
@@ -0,0 +1,76 @@
// Author: fruit.com.cn
import 'package:flutter/foundation.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/models/home_feed.dart';
/// Ranking boards backed by the feed sections the server already returns.
enum RankingBoard { hot, newReleases }
/// One ranked title; rank is one-based and mirrors server section order.
class RankingEntry {
const RankingEntry({required this.rank, required this.drama});
final int rank;
final Drama drama;
}
/// Keeps the active board and turns real feed sections into ranked entries.
///
/// The backend does not expose a dedicated ranking endpoint, so this class
/// never invents scores: it preserves the order of the `hot` and `new`
/// sections and only falls back to the catalog when a section is absent.
class RankingLogic extends ChangeNotifier {
RankingLogic({required this.sections, required this.fallback});
static const hotSectionCode = 'hot';
static const newSectionCode = 'new';
List<FeedSection> sections;
List<Drama> fallback;
RankingBoard _board = RankingBoard.hot;
RankingBoard get board => _board;
List<RankingEntry> get entries {
final sectionCode = _board == RankingBoard.hot
? hotSectionCode
: newSectionCode;
final source = _sectionDramas(sectionCode);
final dramas = source.isEmpty ? fallback : source;
final seen = <String>{};
final unique = [
for (final drama in dramas)
if (seen.add(drama.id)) drama,
];
return [
for (var index = 0; index < unique.length; index++)
RankingEntry(rank: index + 1, drama: unique[index]),
];
}
/// Switches boards and notifies only when the visible source changes.
void select(RankingBoard next) {
if (_board == next) return;
_board = next;
notifyListeners();
}
/// Applies refreshed feed data without rebuilding the page state.
void updateSources({
required List<FeedSection> sections,
required List<Drama> fallback,
}) {
this.sections = sections;
this.fallback = fallback;
notifyListeners();
}
List<Drama> _sectionDramas(String code) {
for (final section in sections) {
if (section.code == code) return section.dramas;
}
return const [];
}
}
@@ -0,0 +1,114 @@
// 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,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,134 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 剧场页页头:品牌、搜索与个人页入口。
class DiscoverHeader extends StatelessWidget {
const DiscoverHeader({
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: 46,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_brand(l10n),
const Spacer(),
_HeaderIcon(
key: const Key('discover-search-button'),
icon: Icons.search_rounded,
tooltip: l10n.search,
onTap: onSearch,
),
const SizedBox(width: 8),
_profile(l10n),
],
),
),
);
}
Widget _brand(AppLocalizations l10n) {
return Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.play_arrow_rounded,
color: AppColors.onPrimary,
size: 16,
),
),
const SizedBox(width: 8),
Text(
l10n.discoverTab,
style: const TextStyle(
fontFamily: 'Outfit',
color: AppColors.onSurface,
fontSize: 15,
fontWeight: FontWeight.w800,
),
),
],
);
}
Widget _profile(AppLocalizations l10n) {
return Semantics(
button: true,
label: l10n.myTab,
child: InkWell(
key: const Key('discover-profile'),
customBorder: const CircleBorder(),
onTap: onProfile,
child: Container(
width: 36,
height: 36,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF6A5CFF), AppColors.blue],
),
),
child: const Icon(
Icons.person_rounded,
color: AppColors.onSurface,
size: 18,
),
),
),
);
}
}
class _HeaderIcon extends StatelessWidget {
const _HeaderIcon({
super.key,
required this.icon,
required this.tooltip,
required this.onTap,
});
final IconData icon;
final String tooltip;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 36,
height: 36,
child: IconButton(
tooltip: tooltip,
onPressed: onTap,
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF2C2D33),
foregroundColor: AppColors.onSurface,
),
icon: Icon(icon, size: 18),
),
);
}
}
@@ -0,0 +1,68 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
/// 剧场顶部搜索框。
class DiscoverSearchField extends StatelessWidget {
const DiscoverSearchField({
super.key,
required this.controller,
required this.focusNode,
required this.onChanged,
required this.loading,
});
final TextEditingController controller;
final FocusNode focusNode;
final ValueChanged<String> onChanged;
final bool loading;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: SizedBox(
height: 36,
child: TextField(
key: const Key('discover-search'),
controller: controller,
focusNode: focusNode,
onChanged: onChanged,
onTapOutside: (_) => focusNode.unfocus(),
textInputAction: TextInputAction.search,
style: const TextStyle(color: AppColors.onSurface, fontSize: 13),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: AppColors.input,
hintText: AppLocalizations.of(context)!.searchDiscover,
hintStyle: const TextStyle(color: AppColors.cream, fontSize: 11),
prefixIcon: const Icon(
Icons.search_rounded,
color: AppColors.subtle,
size: 17,
),
suffixIcon: loading
? const Padding(
padding: EdgeInsets.all(11),
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: const BorderSide(color: AppColors.blue, width: 1),
),
),
),
),
);
}
}
@@ -0,0 +1,65 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/error_text.dart';
import '../../../core/widgets/empty_state.dart';
import '../../../l10n/app_localizations.dart';
import 'drama_grid.dart';
/// 剧场搜索结果:加载、失败与空结果都留在同一页内。
class DiscoverSearchResults extends StatelessWidget {
const DiscoverSearchResults({
super.key,
required this.loading,
required this.error,
required this.results,
required this.onRetry,
required this.onOpen,
});
final bool loading;
final String? error;
final List<Drama> results;
final VoidCallback onRetry;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Padding(
padding: const EdgeInsets.only(top: 12),
child: _content(l10n),
);
}
Widget _content(AppLocalizations l10n) {
if (loading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 80),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
final message = error;
if (message != null) {
return EmptyState(
key: const Key('discover-search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, message, l10n.searchFailed),
actionLabel: l10n.retry,
actionKey: const Key('discover-search-retry'),
onAction: onRetry,
);
}
if (results.isEmpty) {
return EmptyState(
key: const Key('discover-search-empty'),
icon: Icons.search_off_rounded,
title: l10n.noSearchResults,
subtitle: l10n.emptyHint,
);
}
return DramaGrid(l10n: l10n, dramas: results, onOpen: onOpen);
}
}
+187
View File
@@ -0,0 +1,187 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/theme/app_colors.dart';
import '../../../l10n/app_localizations.dart';
const int _columns = 2;
const double _columnGap = 10;
const double _cardGap = 10;
/// 剧场/分类页共用的双列剧目网格。
class DramaGrid extends StatelessWidget {
const DramaGrid({
super.key,
required this.l10n,
required this.dramas,
required this.onOpen,
});
final AppLocalizations l10n;
final List<Drama> dramas;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
return Padding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _column(0)),
const SizedBox(width: _columnGap),
Expanded(child: _column(1)),
],
),
);
}
Widget _column(int start) {
final children = <Widget>[];
for (var index = start; index < dramas.length; index += _columns) {
final drama = dramas[index];
children.add(
DramaGridCard(l10n: l10n, drama: drama, onTap: () => onOpen(drama)),
);
if (index + _columns < dramas.length) {
children.add(const SizedBox(height: _cardGap));
}
}
return Column(children: children);
}
}
/// 双列网格中的剧目卡片。
class DramaGridCard extends StatelessWidget {
const DramaGridCard({
super.key,
required this.l10n,
required this.drama,
required this.onTap,
});
final AppLocalizations l10n;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_cover(),
const SizedBox(height: 5),
_title(),
const SizedBox(height: 2),
_genre(),
],
),
);
}
Widget _cover() {
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return ClipRRect(
key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
_DiscoverCoverImage(path: drama.posterUrl),
if (free) Positioned(top: 6, left: 6, child: _freeBadge()),
],
),
);
}
Widget _freeBadge() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
);
}
Widget _title() {
return Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w800,
),
);
}
Widget _genre() {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
return Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
);
}
}
class _DiscoverCoverImage extends StatelessWidget {
const _DiscoverCoverImage({required this.path});
final String path;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
width: double.infinity,
fit: BoxFit.fitWidth,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder();
},
errorBuilder: (_, _, _) => _placeholder(),
);
}
Widget _placeholder() {
return AspectRatio(
aspectRatio: 3 / 4,
child: ColoredBox(
color: AppColors.card,
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: AppColors.muted,
size: 32,
),
),
),
);
}
}
@@ -1,258 +0,0 @@
// Author: fruit.com.cn
part of '../discover_view.dart';
class _HomeDramaGrid extends StatelessWidget {
const _HomeDramaGrid({
required this.l10n,
required this.categories,
required this.dramas,
required this.searching,
required this.searchLoading,
required this.searchError,
required this.onRetrySearch,
required this.onOpen,
});
final AppLocalizations l10n;
final CategoryController categories;
final List<Drama> dramas;
final bool searching;
final bool searchLoading;
final String? searchError;
final VoidCallback onRetrySearch;
final ValueChanged<Drama> onOpen;
@override
Widget build(BuildContext context) {
final loading = _loadingState();
if (loading != null) return loading;
final search = _searchState();
if (search != null) return search;
if (dramas.isEmpty) return _emptyState();
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: _masonry(dramas)),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
}
Widget? _loadingState() {
if (categories.loading && categories.feed.sections.isEmpty) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (categories.error == null) return null;
return _fill(
EmptyState(
key: const Key('home-categories-error'),
icon: Icons.cloud_off_rounded,
title: l10n.categoriesError,
subtitle: l10n.emptyHint,
actionLabel: l10n.retry,
onAction: categories.load,
),
);
}
Widget? _searchState() {
if (searching && searchLoading) {
return const SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: CircularProgressIndicator(
key: Key('discover-search-loading'),
strokeWidth: 2,
),
),
);
}
if (!searching || searchError == null) return null;
return _fill(
EmptyState(
key: const Key('discover-search-error'),
icon: Icons.cloud_off_rounded,
title: errorText(l10n, searchError, l10n.searchFailed),
actionLabel: l10n.retry,
actionKey: const Key('discover-search-retry'),
onAction: onRetrySearch,
),
);
}
Widget _emptyState() {
return _fill(
EmptyState(
key: const Key('home-all-dramas-empty'),
icon: searching
? Icons.search_off_rounded
: Icons.movie_filter_outlined,
title: searching
? l10n.noSearchResults
: categories.selectedSectionMissing
? l10n.noCategoryDramas
: l10n.noSearchResults,
subtitle: l10n.emptyHint,
),
);
}
Widget _masonry(List<Drama> items) {
return Padding(
key: const Key('home-all-dramas-section'),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
key: const Key('home-all-dramas-grid'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _column(items, 0)),
const SizedBox(width: 10),
Expanded(child: _column(items, 1)),
],
),
);
}
Widget _column(List<Drama> items, int start) {
final children = <Widget>[];
for (var index = start; index < items.length; index += 2) {
children.add(
_HomeDramaCard(
l10n: l10n,
drama: items[index],
onTap: () => onOpen(items[index]),
),
);
if (index + 2 < items.length) {
children.add(const SizedBox(height: 10));
}
}
return Column(children: children);
}
Widget _fill(Widget child) =>
SliverFillRemaining(hasScrollBody: false, child: child);
}
class _HomeDramaCard extends StatelessWidget {
const _HomeDramaCard({
required this.l10n,
required this.drama,
required this.onTap,
});
final AppLocalizations l10n;
final Drama drama;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final genre = drama.genre.isEmpty ? drama.category : drama.genre;
final free =
drama.freeEpisodes > 0 ||
drama.episodes.any((episode) => episode.isFree);
return InkWell(
key: Key('home-drama-card-${drama.id}'),
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
key: Key('home-drama-cover-${drama.id}'),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: [
_DiscoverCoverImage(path: drama.posterUrl),
if (free)
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.blue,
borderRadius: BorderRadius.circular(6),
),
child: Text(
l10n.free,
style: const TextStyle(
color: AppColors.onPrimary,
fontSize: 8,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
const SizedBox(height: 5),
Text(
drama.title,
key: Key('home-drama-title-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 11,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
genre.isEmpty ? l10n.videoPending : genre,
key: Key('home-drama-genre-${drama.id}'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFBBBBBB), fontSize: 9),
),
],
),
);
}
}
class _DiscoverCoverImage extends StatelessWidget {
const _DiscoverCoverImage({required this.path});
final String path;
@override
Widget build(BuildContext context) {
final url = AppConfig.mediaUrl(path);
if (url.isEmpty) return _placeholder();
return Image.network(
url,
width: double.infinity,
fit: BoxFit.fitWidth,
loadingBuilder: (context, child, progress) {
if (progress == null) return child;
return _placeholder();
},
errorBuilder: (_, _, _) => _placeholder(),
);
}
Widget _placeholder() {
return AspectRatio(
aspectRatio: 3 / 4,
child: ColoredBox(
color: AppColors.card,
child: const Center(
child: Icon(
Icons.image_not_supported_outlined,
color: AppColors.muted,
size: 32,
),
),
),
);
}
}
@@ -0,0 +1,268 @@
// 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.raised,
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.muted,
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,
),
),
);
}
}