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:
Vicky committed 2026-10-15 17:53:27 +08:00
1 parent 14d7bd39c7
commit 77d5748dbb
33 files changed
+1264 -1064

No files matched your search

+373 -453
View File
@@ -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),
);
}
}