提交 Nontonki 短剧原型,准备推送到远程仓库。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
VickyandCursor committed 2026-10-13 16:23:08 +08:00
commit b1f6ce4bf9
148 files changed
+11175

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
import '../../core/data/models/drama.dart';
import 'home_logic.dart';
class HomeBinding {
HomeLogic create(List<Drama> dramas) => HomeLogic(dramas);
}
+30
View File
@@ -0,0 +1,30 @@
import '../../core/data/models/drama.dart';
enum HomeFeedCategory { following, animated, liveAction, recommended }
/// Maps catalog entries into home feeds without owning playback or UI state.
class HomeFeedSections {
HomeFeedSections(this.dramas, {this.followedDramaIds = const {}});
final List<Drama> dramas;
final Set<String> followedDramaIds;
List<int> indicesFor(HomeFeedCategory category) => [
for (var index = 0; index < dramas.length; index++)
if (dramas[index].episodes.isNotEmpty &&
switch (category) {
HomeFeedCategory.following => followedDramaIds.contains(
dramas[index].id,
),
HomeFeedCategory.animated =>
dramas[index].format == DramaFormat.animated,
HomeFeedCategory.liveAction =>
dramas[index].format == DramaFormat.liveAction,
HomeFeedCategory.recommended => true,
})
index,
];
Drama dramaAt(HomeFeedCategory category, int categoryIndex) =>
dramas[indicesFor(category)[categoryIndex]];
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
class HomeLogic {
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama;
int tab = 0;
int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0;
int backRequest = 0;
int mySection = 0;
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
void select(
int nextTab, {
bool search = false,
bool watchlist = false,
bool resume = false,
int? mySection,
}) {
FocusManager.instance.primaryFocus?.unfocus();
tab = nextTab;
if (search) searchRequest++;
if (watchlist || mySection != null) {
this.mySection = mySection ?? 1;
sectionRequest++;
}
if (resume) resumeRequest++;
}
}
+325
View File
@@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_view.dart';
import '../player/player_view.dart';
import 'home_binding.dart';
import 'home_logic.dart';
import 'widgets/home_quick_drawer.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final catalog = context.watch<CatalogController>();
final l10n = AppLocalizations.of(context)!;
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: _buildHome(context, catalog, l10n),
);
}
Widget _buildHome(
BuildContext context,
CatalogController catalog,
AppLocalizations l10n,
) {
if (catalog.loading) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator()),
);
}
if (catalog.error != null || catalog.dramas.isEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
const SizedBox(height: 12),
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
],
),
),
);
}
return _HomeTabs(
key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas,
);
}
}
/// The three primary destinations share a single route and keep their state.
class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.dramas});
final List<Drama> dramas;
@override
State<_HomeTabs> createState() => _HomeTabsState();
}
class _HomeTabsState extends State<_HomeTabs> {
late final HomeLogic logic;
@override
void initState() {
super.initState();
logic = HomeBinding().create(widget.dramas);
}
void _select(
int tab, {
bool search = false,
bool watchlist = false,
bool resume = false,
int? mySection,
}) {
setState(
() => logic.select(
tab,
search: search,
watchlist: watchlist,
resume: resume,
mySection: mySection,
),
);
}
void _openMenu() {
setState(() => logic.drawerOpen = true);
logic.scaffoldKey.currentState?.openDrawer();
}
void _selectFromMenu(int tab, {int? mySection}) {
logic.scaffoldKey.currentState?.closeDrawer();
_select(tab, mySection: mySection);
}
Future<void> _openCurrentSeries() async {
setState(() => logic.detailOpen = true);
logic.scaffoldKey.currentState?.closeDrawer();
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
try {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => DramaDetailPage(drama: logic.currentDrama),
),
);
} finally {
if (mounted) setState(() => logic.detailOpen = false);
}
}
void _discoverAction(DiscoverPageAction action) {
switch (action) {
case DiscoverPageAction.home:
_select(0);
case DiscoverPageAction.my:
_select(2);
case DiscoverPageAction.watchlist:
_select(2, watchlist: true);
case DiscoverPageAction.resume:
_select(0, resume: true);
case DiscoverPageAction.detail:
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DramaDetailPage(drama: logic.currentDrama),
),
);
}
}
void _myAction(MyPageAction action) {
switch (action) {
case MyPageAction.home:
_select(0);
case MyPageAction.discover:
_select(1, search: true);
case MyPageAction.resume:
_select(0, resume: true);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (logic.drawerOpen) {
logic.scaffoldKey.currentState?.closeDrawer();
} else if (logic.videoImmersive) {
setState(() => logic.backRequest++);
} else if (logic.tab != 0) {
_select(0);
} else {
setState(() => logic.backRequest++);
}
},
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: const Color(0xFF0B0C10),
drawer: HomeQuickDrawer(
drama: logic.currentDrama,
onOpenSeries: _openCurrentSeries,
onDiscover: () => _selectFromMenu(1),
onHistory: () => _selectFromMenu(2, mySection: 0),
onWatchlist: () => _selectFromMenu(2, mySection: 1),
onLiked: () => _selectFromMenu(2, mySection: 2),
),
drawerEnableOpenDragGesture: false,
onDrawerChanged: (opened) {
if (logic.drawerOpen != opened) {
setState(() => logic.drawerOpen = opened);
}
},
body: IndexedStack(
index: logic.tab,
children: [
PlayerPage(
drama: widget.dramas.first,
feedDramas: widget.dramas,
onDramaChanged: (drama) =>
setState(() => logic.currentDrama = drama),
initialIndex: 0,
homeMode: true,
embedded: true,
active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen,
resumeRequest: logic.resumeRequest,
backRequest: logic.backRequest,
onDiscover: () => _select(1),
onMy: () => _select(2),
onSearch: () => _select(1, search: true),
onMenu: _openMenu,
onImmersiveChanged: (value) =>
setState(() => logic.videoImmersive = value),
),
DiscoverPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DramaDetailPage(drama: drama),
),
),
duration: Duration.zero,
embedded: true,
active: logic.tab == 1,
searchRequest: logic.searchRequest,
onAction: _discoverAction,
),
MyPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DramaDetailPage(drama: drama),
),
),
duration: Duration.zero,
initialSection: logic.mySection,
sectionRequest: logic.sectionRequest,
embedded: true,
active: logic.tab == 2,
onAction: _myAction,
),
],
),
bottomNavigationBar: logic.videoImmersive
? null
: SafeArea(
top: false,
child: Container(
key: const Key('home-tabs-bar'),
height: 63,
decoration: const BoxDecoration(
color: Color(0xFF0D0D11),
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_TabButton(
key: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
onTap: () => _select(0),
),
_TabButton(
key: const Key('tab-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
selected: logic.tab == 1,
onTap: () => _select(1),
),
_TabButton(
key: const Key('tab-my'),
icon: Icons.video_library_outlined,
label: l10n.myTab,
selected: logic.tab == 2,
onTap: () => _select(2),
),
],
),
),
),
),
);
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
super.key,
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 80,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
),
Text(
label,
style: TextStyle(
fontSize: 10,
color: selected
? const Color(0xFFFF5C37)
: const Color(0xFFE6E1DE),
),
),
],
),
),
);
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// Converts horizontal drags into a previous/next category request.
/// Placement decides where the gesture is active; the seek bar sits outside it.
class CategorySwipeRegion extends StatefulWidget {
const CategorySwipeRegion({
super.key,
required this.onShift,
required this.child,
});
final ValueChanged<int> onShift;
final Widget child;
@override
State<CategorySwipeRegion> createState() => _CategorySwipeRegionState();
}
class _CategorySwipeRegionState extends State<CategorySwipeRegion> {
double _distance = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => _distance = 0,
onHorizontalDragUpdate: (details) => _distance += details.delta.dx,
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity ?? 0;
if (_distance.abs() < 44 && velocity.abs() < 300) return;
final direction = _distance.abs() >= 44 ? _distance : velocity;
widget.onShift(direction < 0 ? 1 : -1);
},
child: widget.child,
);
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../home_feed_sections.dart';
import 'category_swipe_region.dart';
/// Reused by playable and empty home feeds.
class HomeFeedHeader extends StatelessWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.category,
required this.onCategorySelected,
required this.onCategoryShift,
required this.onSearch,
required this.onMy,
this.onMenu,
this.onMute,
this.muted = false,
});
final AppLocalizations l10n;
final HomeFeedCategory category;
final ValueChanged<HomeFeedCategory> onCategorySelected;
final ValueChanged<int> onCategoryShift;
final VoidCallback onSearch;
final VoidCallback onMy;
final VoidCallback? onMenu;
final VoidCallback? onMute;
final bool muted;
String _label(HomeFeedCategory category) => switch (category) {
HomeFeedCategory.following => l10n.followingTab,
HomeFeedCategory.animated => l10n.animatedTab,
HomeFeedCategory.liveAction => l10n.liveActionTab,
HomeFeedCategory.recommended => l10n.recommendedTab,
};
@override
Widget build(BuildContext context) => IconTheme(
data: const IconThemeData(color: Colors.white),
child: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
child: _header(context),
),
);
Widget _header(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
if (onMenu != null)
IconButton(
key: const Key('home-open-menu'),
tooltip: l10n.openQuickMenu,
onPressed: onMenu,
icon: const Icon(Icons.menu_rounded),
),
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: const Color(0xFFFF5C37),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 8),
Text(
l10n.homeTab,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: onSearch,
icon: const Icon(Icons.search_rounded),
),
IconButton(
tooltip: l10n.myTab,
onPressed: onMy,
icon: const CircleAvatar(
radius: 15,
backgroundColor: Color(0xFF3C3A3A),
child: Icon(
Icons.person_rounded,
color: Colors.white,
size: 19,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Row(
children: [
Expanded(
child: CategorySwipeRegion(
key: const Key('home-category-swipe'),
onShift: onCategoryShift,
child: Row(
children: [
for (final item in HomeFeedCategory.values)
Expanded(
child: InkWell(
key: Key('home-category-${item.name}'),
onTap: () => onCategorySelected(item),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_label(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: category == item
? const Color(0xFFFFC2B0)
: const Color(0xFFF7F4F2),
shadows: const [
Shadow(blurRadius: 8, color: Colors.black),
],
fontSize: 12,
fontWeight: category == item
? FontWeight.w700
: FontWeight.w500,
),
),
const SizedBox(height: 4),
SizedBox(
width: 18,
height: 2,
child: DecoratedBox(
decoration: BoxDecoration(
color: category == item
? const Color(0xFFFF5C37)
: Colors.transparent,
),
),
),
],
),
),
),
),
],
),
),
),
if (onMute != null)
IconButton(
tooltip: muted ? l10n.unmute : l10n.mute,
onPressed: onMute,
icon: Icon(
muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
size: 21,
),
),
],
),
),
],
);
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../l10n/app_localizations.dart';
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
class HomeQuickDrawer extends StatelessWidget {
const HomeQuickDrawer({
super.key,
required this.drama,
required this.onOpenSeries,
required this.onDiscover,
required this.onHistory,
required this.onWatchlist,
required this.onLiked,
});
final Drama drama;
final VoidCallback onOpenSeries;
final VoidCallback onDiscover;
final VoidCallback onHistory;
final VoidCallback onWatchlist;
final VoidCallback onLiked;
static const _panel = Color(0xFF222225);
static const _card = Color(0xFF303033);
static const _orange = Color(0xFFFF5C37);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode;
return Drawer(
width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0),
backgroundColor: _panel,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
children: [
Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF4B4142),
child: Icon(
Icons.person_rounded,
size: 34,
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.localViewer,
style: const TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.localDemoProfile,
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
],
),
),
],
),
const SizedBox(height: 26),
_SectionCard(
title: l10n.currentSeries,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
drama.posterAsset,
width: 74,
height: 100,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
const SizedBox(height: 5),
Text(
l10n.freeTestMedia,
maxLines: 2,
style: const TextStyle(
color: Color(0xFFFFB99E),
fontSize: 11,
),
),
],
),
),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
key: const Key('drawer-open-series'),
onPressed: onOpenSeries,
style: FilledButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.exploreSeries),
),
),
],
),
),
const SizedBox(height: 14),
_SectionCard(
title: l10n.quickAccess,
child: Column(
children: [
Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: onDiscover,
),
),
const SizedBox(width: 9),
Expanded(
child: _QuickAction(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: onHistory,
),
),
],
),
const SizedBox(height: 9),
Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: onWatchlist,
),
),
const SizedBox(width: 9),
Expanded(
child: _QuickAction(
key: const Key('drawer-liked'),
icon: Icons.favorite_border_rounded,
label: l10n.likedTab,
onTap: onLiked,
),
),
],
),
],
),
),
const SizedBox(height: 14),
_SectionCard(
title: l10n.appLanguage,
child: Row(
children: [
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
]) ...[
Expanded(
child: ChoiceChip(
label: Text(label, maxLines: 1),
selected: languageCode == code,
onSelected: (_) =>
context.read<LocaleController>().select(code),
),
),
if (code == 'en') const SizedBox(width: 8),
],
],
),
),
],
),
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: HomeQuickDrawer._card,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
const SizedBox(height: 14),
child,
],
),
);
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF3B3B3F),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
child: Column(
children: [
Icon(icon, color: HomeQuickDrawer._orange, size: 25),
const SizedBox(height: 6),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
),
),
);
}