chore(client): 归档原型阶段未提交改动(binding/logic/view 分层与各页迭代)
为什么:客户端原型在 14d7bd3 之后又做了一轮(页面分层、发现页、分类滑动、播放器控件等),
一直留在工作区没入库。下一批账号链路要改其中的 app.dart / home_view / player_view / mine_view,
先归档这批既存改动,本批的实际 diff 才说得清边界。
只归档,不改行为:当前工作区 flutter analyze 0 问题、flutter test 148 全过。
This commit is contained in:
1 parent
14d7bd39c7
commit
77d5748dbb
33 files changed
+1264
-1064
No files matched your search
@@ -1,12 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/data/models/drama.dart';
|
||||
import '../../core/formatting/localized_content.dart';
|
||||
import '../../core/data/models/home_feed.dart';
|
||||
import '../../core/data/repositories/watch_history_repository.dart';
|
||||
import '../../core/data/services/catalog_controller.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/widgets/remote_image.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import 'discover_binding.dart';
|
||||
import 'discover_logic.dart';
|
||||
import 'widgets/cover_carousel.dart';
|
||||
|
||||
class DiscoverPage extends StatefulWidget {
|
||||
const DiscoverPage({
|
||||
@@ -18,6 +23,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
this.embedded = false,
|
||||
this.active = true,
|
||||
this.searchRequest = 0,
|
||||
this.signedIn = false,
|
||||
this.onSignIn,
|
||||
this.onCoins,
|
||||
this.onRewards,
|
||||
this.onAction,
|
||||
this.dramas,
|
||||
this.onDramaSelected,
|
||||
@@ -30,6 +39,10 @@ class DiscoverPage extends StatefulWidget {
|
||||
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;
|
||||
@@ -48,6 +61,7 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
drama: widget.drama,
|
||||
history: widget.history,
|
||||
);
|
||||
if (widget.focusSearch) logic.searchOpen = true;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -80,50 +94,64 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
|
||||
Widget _page(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final cards = _cardsFor(l10n, logic.category, logic.search.text);
|
||||
final catalog = context.watch<CatalogController>();
|
||||
final allDramas = widget.dramas ?? [logic.drama];
|
||||
final cards = _cardsFor(
|
||||
allDramas,
|
||||
catalog.sections,
|
||||
logic.category,
|
||||
'',
|
||||
);
|
||||
final content = SafeArea(
|
||||
bottom: !widget.embedded,
|
||||
child: Column(
|
||||
children: [
|
||||
_topBar(l10n),
|
||||
_header(l10n),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: logic.scroll,
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: [
|
||||
_searchBar(l10n),
|
||||
const SizedBox(height: 12),
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_continueCard(l10n),
|
||||
const SizedBox(height: 15),
|
||||
_quickActions(l10n),
|
||||
const SizedBox(height: 20),
|
||||
_sectionTitle(l10n),
|
||||
const SizedBox(height: 13),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: cards.length,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 14,
|
||||
childAspectRatio: 0.59,
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_posterCard(l10n, cards[index]),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: _carousel(l10n, catalog.banners, allDramas),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_categories(l10n),
|
||||
const SizedBox(height: 18),
|
||||
_continueHeader(l10n),
|
||||
const SizedBox(height: 12),
|
||||
if (cards.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
child: Text(
|
||||
l10n.noSearchResults,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3)),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
height: 196,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final card in cards) ...[
|
||||
_continueTile(l10n, card),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -142,412 +170,233 @@ class _DiscoverPageState extends State<DiscoverPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _topBar(AppLocalizations l10n) => SizedBox(
|
||||
height: 51,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
Widget _header(AppLocalizations l10n) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 29,
|
||||
height: 29,
|
||||
decoration: BoxDecoration(
|
||||
color: _orange,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(
|
||||
l10n.discoverTab,
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: l10n.search,
|
||||
onPressed: () => logic.searchFocus.requestFocus(),
|
||||
icon: const Icon(Icons.search_rounded, size: 22),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.my,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: const CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: Color(0xFF34353B),
|
||||
child: Icon(Icons.person_rounded, size: 19),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _searchBar(AppLocalizations l10n) => Container(
|
||||
height: 43,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF25252A),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 11),
|
||||
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: logic.search,
|
||||
focusNode: logic.searchFocus,
|
||||
autofocus: widget.focusSearch,
|
||||
onChanged: (_) => logic.onSearchChanged(),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.searchDiscover,
|
||||
hintStyle: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.filter,
|
||||
onPressed: () => logic.showFilters(context),
|
||||
icon: const Icon(Icons.tune_rounded, size: 20),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.featured,
|
||||
l10n.suspense,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 7),
|
||||
itemBuilder: (context, index) => ChoiceChip(
|
||||
label: Text(labels[index]),
|
||||
selected: logic.category == index,
|
||||
onSelected: (_) => logic.selectCategory(index),
|
||||
showCheckmark: false,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
backgroundColor: const Color(0xFF29272B),
|
||||
selectedColor: _orange,
|
||||
side: BorderSide.none,
|
||||
labelStyle: TextStyle(
|
||||
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
|
||||
future: logic.position,
|
||||
builder: (context, snapshot) {
|
||||
final position = snapshot.data ?? Duration.zero;
|
||||
final total = widget.duration > Duration.zero
|
||||
? widget.duration
|
||||
: const Duration(seconds: 12);
|
||||
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
|
||||
0.0,
|
||||
1.0,
|
||||
);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1C1D21),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: SizedBox(
|
||||
width: 55,
|
||||
height: 69,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 12,
|
||||
child: ColoredBox(color: Color(0xFF1C1D21)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l10n.continueWatchingHeading,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFA490),
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
localizedDramaTitle(l10n, widget.drama),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l10n.episodeProgress(1, widget.drama.episodes.length),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFE6E1E3),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 3,
|
||||
backgroundColor: const Color(0xFF474248),
|
||||
color: _orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
FilledButton.icon(
|
||||
onPressed: () => logic.act(
|
||||
context,
|
||||
DiscoverPageAction.resume,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFFFBD5C),
|
||||
foregroundColor: const Color(0xFF2A160B),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
minimumSize: const Size(0, 32),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow_rounded, size: 14),
|
||||
label: Text(l10n.resume),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget _quickActions(AppLocalizations l10n) {
|
||||
final actions = [
|
||||
(Icons.tune_rounded, l10n.filter, () => logic.showFilters(context)),
|
||||
(Icons.bar_chart_rounded, l10n.topCharts, () => logic.catalogInfo(context)),
|
||||
(
|
||||
Icons.auto_awesome_rounded,
|
||||
l10n.newReleases,
|
||||
() => logic.catalogInfo(context),
|
||||
),
|
||||
(
|
||||
Icons.bookmark_outline_rounded,
|
||||
l10n.watchlist,
|
||||
() {
|
||||
logic.act(
|
||||
context,
|
||||
DiscoverPageAction.watchlist,
|
||||
embedded: widget.embedded,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
return Row(
|
||||
children: [
|
||||
for (var index = 0; index < actions.length; index++) ...[
|
||||
const _BrandMark(),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: actions[index].$3,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
height: 77,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF202126),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: const Color(0xFF333036),
|
||||
child: Icon(
|
||||
actions[index].$1,
|
||||
size: 19,
|
||||
color: index == 1
|
||||
? const Color(0xFFFFC15E)
|
||||
: const Color(0xFFFFAB96),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
actions[index].$2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
l10n.appName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index < actions.length - 1) const SizedBox(width: 7),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.signedIn) ...[
|
||||
_HeaderPill(
|
||||
icon: Icons.monetization_on_rounded,
|
||||
label: r'$ 0',
|
||||
onTap: widget.onCoins,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_HeaderPill(
|
||||
icon: Icons.card_giftcard_rounded,
|
||||
label: l10n.rewards,
|
||||
onTap: widget.onRewards,
|
||||
),
|
||||
] else
|
||||
_LoginButton(label: l10n.logIn, onTap: widget.onSignIn),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _carousel(
|
||||
AppLocalizations l10n,
|
||||
List<FeedBanner> banners,
|
||||
List<Drama> allDramas,
|
||||
) {
|
||||
final items = banners.where((banner) => banner.imageUrl.isNotEmpty).toList();
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return CoverCarousel(
|
||||
items: [
|
||||
for (final banner in items)
|
||||
CoverItem(
|
||||
title: _bannerTitle(banner, allDramas),
|
||||
subtitle: '',
|
||||
image: banner.imageUrl,
|
||||
onOpen: () => _openBanner(banner, allDramas),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _sectionTitle(AppLocalizations l10n) => Row(
|
||||
String _bannerTitle(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return '';
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) return drama.title;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
void _openBanner(FeedBanner banner, List<Drama> allDramas) {
|
||||
final dramaId = banner.dramaId;
|
||||
if (dramaId == null) return;
|
||||
for (final drama in allDramas) {
|
||||
if (drama.id == dramaId) {
|
||||
logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _continueHeader(AppLocalizations l10n) => Row(
|
||||
children: [
|
||||
Container(width: 3, height: 20, color: _orange),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.trendingMicroDramas,
|
||||
l10n.continueWatching,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => logic.selectCategory(0),
|
||||
child: Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(color: Color(0xFFFFB3A1)),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
l10n.seeAll,
|
||||
style: const TextStyle(
|
||||
color: AppColors.blue,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final playable =
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]) != null;
|
||||
return InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]),
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.asset(card.image, fit: BoxFit.cover),
|
||||
const Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 37,
|
||||
child: ColoredBox(color: Color(0xFF101116)),
|
||||
Widget _continueTile(AppLocalizations l10n, _ShowcaseCard card) {
|
||||
final drama = logic.dramaFor(card.dramaId, widget.dramas ?? [logic.drama]);
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
child: InkWell(
|
||||
onTap: () => logic.openCard(
|
||||
context,
|
||||
drama,
|
||||
embedded: widget.embedded,
|
||||
onDramaSelected: widget.onDramaSelected,
|
||||
onAction: widget.onAction,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RemoteImage(card.image),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0x00000000), Color(0xE6000000)],
|
||||
stops: [0.45, 1],
|
||||
),
|
||||
Positioned(
|
||||
top: 6,
|
||||
left: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: playable ? _orange : const Color(0xDD37343B),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Text(
|
||||
playable ? l10n.free : l10n.artworkOnly,
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (drama == null)
|
||||
const ColoredBox(
|
||||
color: Color(0x99000000),
|
||||
child: Center(
|
||||
child: Icon(Icons.image_outlined, color: Colors.white),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
playable ? l10n.freeTestMedia : l10n.videoPending,
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFC789),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
drama == null ? l10n.videoPending : card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xE6FFFFFF),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _categories(AppLocalizations l10n) {
|
||||
final labels = [
|
||||
l10n.forYou,
|
||||
l10n.hot,
|
||||
l10n.newest,
|
||||
l10n.romance,
|
||||
l10n.actionGenre,
|
||||
];
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: labels.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final selected = logic.category == index;
|
||||
return GestureDetector(
|
||||
onTap: () => logic.selectCategory(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.blue : const Color(0xFF1C1E24),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(
|
||||
labels[index],
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : AppColors.muted,
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
card.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
card.genre,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -637,60 +486,131 @@ class _ShowcaseCard {
|
||||
required this.title,
|
||||
required this.genre,
|
||||
required this.image,
|
||||
required this.category,
|
||||
this.dramaId,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String genre;
|
||||
final String image;
|
||||
final int category;
|
||||
final String? dramaId;
|
||||
}
|
||||
|
||||
/// 分区由服务端下发:0 为全部,其余按分区顺序对应筛选菜单(热门 / 最新 / 各分类)。
|
||||
List<_ShowcaseCard> _cardsFor(
|
||||
AppLocalizations l10n,
|
||||
List<Drama> dramas,
|
||||
List<FeedSection> sections,
|
||||
int category,
|
||||
String query,
|
||||
) {
|
||||
final cards = [
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaSignalTitle,
|
||||
genre: l10n.genreSuspenseMystery,
|
||||
image: 'assets/images/stitch_signal_poster_clean.png',
|
||||
category: 1,
|
||||
dramaId: 'the-signal-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaCityTitle,
|
||||
genre: l10n.genreUrbanRomance,
|
||||
image: 'assets/images/stitch_city_poster_clean.png',
|
||||
category: 2,
|
||||
dramaId: 'city-lights-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaVengeanceTitle,
|
||||
genre: l10n.genreActionThriller,
|
||||
image: 'assets/images/stitch_vengeance_poster_clean.png',
|
||||
category: 3,
|
||||
dramaId: 'vengeance-demo',
|
||||
),
|
||||
_ShowcaseCard(
|
||||
title: l10n.dramaEchoesTitle,
|
||||
genre: l10n.genreDramaMystery,
|
||||
image: 'assets/images/stitch_rain_scene.jpg',
|
||||
category: 1,
|
||||
),
|
||||
];
|
||||
final source = switch (category) {
|
||||
0 => dramas,
|
||||
_ => category < sections.length ? sections[category].dramas : const <Drama>[],
|
||||
};
|
||||
final normalized = query.trim().toLowerCase();
|
||||
return cards.where((card) {
|
||||
final categoryMatches = category == 0 || card.category == category;
|
||||
final queryMatches =
|
||||
normalized.isEmpty ||
|
||||
card.title.toLowerCase().contains(normalized) ||
|
||||
card.genre.toLowerCase().contains(normalized);
|
||||
return categoryMatches && queryMatches;
|
||||
}).toList();
|
||||
return [
|
||||
for (final drama in source)
|
||||
if (normalized.isEmpty ||
|
||||
drama.title.toLowerCase().contains(normalized) ||
|
||||
drama.genre.toLowerCase().contains(normalized) ||
|
||||
drama.category.toLowerCase().contains(normalized))
|
||||
_ShowcaseCard(
|
||||
title: drama.title,
|
||||
genre: drama.genre.isEmpty ? drama.category : drama.genre,
|
||||
image: drama.posterUrl,
|
||||
dramaId: drama.id,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const _orange = Color(0xFFFF5B3D);
|
||||
const _orange = AppColors.blue;
|
||||
const _coin = Color(0xFFF5C451);
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
const _BrandMark();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPill extends StatelessWidget {
|
||||
const _HeaderPill({
|
||||
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: const Color(0xFF1C1E24),
|
||||
shape: const StadiumBorder(side: BorderSide(color: Color(0x66F5C451))),
|
||||
child: InkWell(
|
||||
customBorder: const StadiumBorder(),
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 15, color: _coin),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoginButton extends StatelessWidget {
|
||||
const _LoginButton({required this.label, required this.onTap});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton(
|
||||
onPressed: onTap,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: AppColors.blue,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
shape: const StadiumBorder(),
|
||||
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
child: Text(label, maxLines: 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user