提交 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

+13
View File
@@ -0,0 +1,13 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'discover_logic.dart';
class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
WatchHistoryRepository? history,
}) => DiscoverLogic(
drama: drama,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
class DiscoverLogic {
DiscoverLogic({required this.history, required this.drama}) {
refreshPosition();
}
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
int category = 0;
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
void dispose() {
search.dispose();
searchFocus.dispose();
scroll.dispose();
}
}
+731
View File
@@ -0,0 +1,731 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/data/models/drama.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
enum DiscoverPageAction { home, my, watchlist, detail, resume }
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
required this.drama,
required this.duration,
this.focusSearch = false,
this.history,
this.embedded = false,
this.active = true,
this.searchRequest = 0,
this.onAction,
this.dramas,
this.onDramaSelected,
});
final Drama drama;
final Duration duration;
final bool focusSearch;
final WatchHistoryRepository? history;
final bool embedded;
final bool active;
final int searchRequest;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@override
State<DiscoverPage> createState() => _DiscoverPageState();
}
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
@override
void initState() {
super.initState();
logic = DiscoverBinding().create(
drama: widget.drama,
history: widget.history,
);
}
@override
void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active &&
(!oldWidget.active || oldWidget.drama.id != widget.drama.id)) {
logic.drama = widget.drama;
setState(logic.refreshPosition);
}
if (widget.active && widget.searchRequest != oldWidget.searchRequest) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
}
void _act(DiscoverPageAction action) {
if (widget.embedded) {
widget.onAction?.call(action);
} else {
Navigator.pop(context, action);
}
}
Drama? _dramaFor(_ShowcaseCard card) {
for (final drama in widget.dramas ?? [logic.drama]) {
if (drama.id == card.dramaId) return drama;
}
return null;
}
void _openCard(_ShowcaseCard card) {
final drama = _dramaFor(card);
if (drama == null) {
_unavailable();
} else if (widget.embedded && widget.onDramaSelected != null) {
widget.onDramaSelected!(drama);
} else {
_act(DiscoverPageAction.detail);
}
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
void _unavailable() {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice)));
}
void _catalogInfo() {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
void _showFilters() {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202126),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.filter)),
for (var index = 0; index < labels.length; index++)
ListTile(
title: Text(labels[index]),
trailing: index == logic.category
? const Icon(Icons.check_rounded, color: _orange)
: null,
onTap: () {
setState(() => logic.category = index);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cards = _cardsFor(l10n, logic.category, logic.search.text);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
Expanded(
child: ListView(
controller: logic.scroll,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 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]),
),
],
),
),
if (!widget.embedded) _bottomNav(l10n),
],
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: ThemeData.dark(useMaterial3: true),
child: widget.embedded
? content
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
),
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 51,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
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: () => _act(DiscoverPageAction.my),
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: (_) => setState(() {}),
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: _showFilters,
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: (_) => setState(() => logic.category = 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: () => _act(DiscoverPageAction.resume),
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, _showFilters),
(Icons.bar_chart_rounded, l10n.topCharts, _catalogInfo),
(Icons.auto_awesome_rounded, l10n.newReleases, _catalogInfo),
(
Icons.bookmark_outline_rounded,
l10n.watchlist,
() {
_act(DiscoverPageAction.watchlist);
},
),
];
return Row(
children: [
for (var index = 0; index < actions.length; index++) ...[
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,
),
),
],
),
),
),
),
if (index < actions.length - 1) const SizedBox(width: 7),
],
],
);
}
Widget _sectionTitle(AppLocalizations l10n) => Row(
children: [
Container(width: 3, height: 20, color: _orange),
const SizedBox(width: 7),
Expanded(
child: Text(
l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
),
),
TextButton(
onPressed: () => setState(() => logic.category = 0),
child: Text(
l10n.seeAll,
style: const TextStyle(color: Color(0xFFFFB3A1)),
),
),
],
);
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
final playable = _dramaFor(card) != null;
return InkWell(
onTap: () => _openCard(card),
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)),
),
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,
),
),
),
),
Positioned(
bottom: 10,
left: 8,
right: 8,
child: Text(
playable ? l10n.freeTestMedia : l10n.videoPending,
style: const TextStyle(
color: Color(0xFFFFC789),
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
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),
),
],
),
);
}
Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63,
decoration: const BoxDecoration(
color: Color(0xFF0D0D11),
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_DiscoverNavItem(
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
onTap: () => Navigator.pop(context, DiscoverPageAction.home),
),
_DiscoverNavItem(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
selected: true,
onTap: () {},
),
_DiscoverNavItem(
icon: Icons.video_library_outlined,
label: l10n.myTab,
onTap: () => Navigator.pop(context, DiscoverPageAction.my),
),
],
),
);
}
class _DiscoverNavItem extends StatelessWidget {
const _DiscoverNavItem({
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 72,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? _orange : const Color(0xFFCAC4C7),
),
Text(
label,
style: TextStyle(
color: selected ? _orange : const Color(0xFFCAC4C7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
class _ShowcaseCard {
const _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;
}
List<_ShowcaseCard> _cardsFor(
AppLocalizations l10n,
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 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();
}
const _orange = Color(0xFFFF5B3D);
@@ -0,0 +1,13 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'drama_detail_logic.dart';
class DramaDetailBinding {
DramaDetailLogic create({
required Drama drama,
WatchHistoryRepository? history,
}) => DramaDetailLogic(
drama: drama,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
@@ -0,0 +1,19 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
class DramaDetailLogic {
DramaDetailLogic({required this.drama, required this.history});
final Drama drama;
final WatchHistoryRepository history;
int? continueIndex;
Future<void> loadContinue() async {
final id = await history.lastEpisodeId(demoProfileId, drama.id);
if (id == null) return;
final index = drama.episodes.indexWhere((episode) => episode.id == id);
if (index >= 0 && drama.episodes[index].isFree) {
continueIndex = index;
}
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/formatting/localized_content.dart';
import '../../l10n/app_localizations.dart';
import '../player/player_view.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama});
final Drama drama;
@override
State<DramaDetailPage> createState() => _DramaDetailPageState();
}
class _DramaDetailPageState extends State<DramaDetailPage> {
late final DramaDetailLogic logic;
@override
void initState() {
super.initState();
logic = DramaDetailBinding().create(drama: widget.drama);
_loadContinue();
}
Future<void> _loadContinue() async {
try {
await logic.loadContinue();
if (mounted) setState(() {});
} catch (_) {
// Keep the first episode available if preferences cannot be read.
}
}
Future<void> _openPlayer(BuildContext context, int index) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: logic.drama, initialIndex: index),
),
);
await _loadContinue();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final drama = logic.drama;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 40),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(
l10n.technicalDemoCount(drama.episodes.length),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 1.4,
color: const Color(0xFF9278EA),
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
localizedDramaTitle(l10n, drama),
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(
localizedDramaSynopsis(l10n, drama),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => _openPlayer(context, logic.continueIndex ?? 0),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
logic.continueIndex == null
? l10n.watchEpisodeOne
: l10n.continueEpisode(logic.continueIndex! + 1),
),
),
const SizedBox(height: 30),
Text(
l10n.episodes,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
]),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 40),
sliver: SliverList.builder(
itemCount: drama.episodes.length,
itemBuilder: (context, index) {
final episode = drama.episodes[index];
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
minTileHeight: 76,
leading: CircleAvatar(
backgroundColor: const Color(
0xFFFF6B45,
).withValues(alpha: 0.18),
child: Text('${episode.number}'),
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_circle_outline_rounded),
onTap: () => _openPlayer(context, index),
),
);
},
),
),
],
),
);
}
}
+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),
),
],
),
),
),
);
}
+15
View File
@@ -0,0 +1,15 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'mine_logic.dart';
class MineBinding {
MineLogic create({
required Drama drama,
required int initialSection,
WatchHistoryRepository? history,
}) => MineLogic(
drama: drama,
initialSection: initialSection,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
+26
View File
@@ -0,0 +1,26 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
class MineLogic {
MineLogic({
required this.history,
required this.drama,
required int initialSection,
}) : section = initialSection {
refreshPosition();
}
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
int section;
int filter = 0;
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
}
+682
View File
@@ -0,0 +1,682 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/locale_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../l10n/app_localizations.dart';
import 'mine_binding.dart';
import 'mine_logic.dart';
enum MyPageAction { home, discover, resume }
class MyPage extends StatefulWidget {
const MyPage({
super.key,
required this.drama,
required this.duration,
this.initialSection = 0,
this.history,
this.embedded = false,
this.active = true,
this.sectionRequest = 0,
this.onAction,
this.dramas,
this.onDramaSelected,
});
final Drama drama;
final Duration duration;
final int initialSection;
final WatchHistoryRepository? history;
final bool embedded;
final bool active;
final int sectionRequest;
final ValueChanged<MyPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
late final MineLogic logic;
@override
void initState() {
super.initState();
logic = MineBinding().create(
drama: widget.drama,
initialSection: widget.initialSection,
history: widget.history,
);
}
@override
void didUpdateWidget(covariant MyPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active &&
(!oldWidget.active || oldWidget.drama.id != widget.drama.id)) {
logic.drama = widget.drama;
setState(logic.refreshPosition);
}
if (widget.sectionRequest != oldWidget.sectionRequest) {
setState(() => logic.section = widget.initialSection);
}
}
void _act(MyPageAction action) {
if (widget.embedded) {
widget.onAction?.call(action);
} else {
Navigator.pop(context, action);
}
}
Future<void> _showLanguage() async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF1C1D22),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.language)),
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
])
ListTile(
title: Text(label),
onTap: () {
context.read<LocaleController>().select(code);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
Expanded(
child: 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 fraction =
(position.inMilliseconds / total.inMilliseconds).clamp(
0.0,
1.0,
);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
children: [
_profile(l10n),
const SizedBox(height: 22),
_continueCard(l10n, fraction, position, total),
const SizedBox(height: 26),
_library(l10n, position, total, fraction),
const SizedBox(height: 26),
_preferences(l10n),
const SizedBox(height: 24),
Center(
child: Text(
l10n.brandMark,
style: TextStyle(
color: Color(0xFFD2CDD0),
fontSize: 10,
letterSpacing: 1.3,
),
),
),
],
);
},
),
),
if (!widget.embedded) _bottomNav(l10n),
],
),
);
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: widget.embedded
? content
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 54,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
const SizedBox(width: 7),
Text(
l10n.myTab,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: () => _act(MyPageAction.discover),
icon: const Icon(Icons.search_rounded, size: 23),
),
const SizedBox(width: 6),
const CircleAvatar(
radius: 14,
backgroundColor: Color(0xFF2C2D33),
child: Icon(Icons.person_rounded, color: Colors.white, size: 19),
),
],
),
),
);
Widget _profile(AppLocalizations l10n) => Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.localViewer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Text(
l10n.localDemoProfile,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF24252A),
borderRadius: BorderRadius.circular(20),
),
child: Text(l10n.demo, style: const TextStyle(fontSize: 11)),
),
],
);
Widget _continueCard(
AppLocalizations l10n,
double fraction,
Duration position,
Duration total,
) => Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: const Color(0xFF201B1E),
borderRadius: BorderRadius.circular(13),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.history_rounded, color: _orange, size: 17),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10),
),
],
),
const SizedBox(height: 12),
Row(
children: [
_poster(width: 75, height: 96),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, widget.drama),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
l10n.dramaTag,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
const SizedBox(height: 10),
Text(
l10n.watchedPercent((fraction * 100).round()),
style: const TextStyle(
color: Color(0xFFE7DEDD),
fontSize: 11,
),
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: fraction,
minHeight: 4,
backgroundColor: const Color(0xFF444046),
color: _orange,
),
),
const SizedBox(height: 6),
Text(
'${_clock(position)} / ${_clock(total)}',
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _act(MyPageAction.resume),
style: FilledButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(37),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(l10n.resume),
),
),
],
),
);
Widget _library(
AppLocalizations l10n,
Duration position,
Duration total,
double fraction,
) {
final tabs = [l10n.history, l10n.watchlist, l10n.likedTab];
final filters = [l10n.all, l10n.inProgress, l10n.completed];
final hasHistory =
logic.filter == 0 ||
(logic.filter == 1 && fraction < 0.99) ||
(logic.filter == 2 && fraction >= 0.99);
final engagement = context.watch<EngagementController>();
final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) {
return logic.section == 1
? engagement.isSaved(drama.id)
: engagement.isLiked(drama.id);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < tabs.length; index++) ...[
InkWell(
onTap: () => setState(() => logic.section = index),
child: Text(
tabs[index],
style: TextStyle(
color: logic.section == index
? _orange
: const Color(0xFFE4DBDD),
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
if (index < tabs.length - 1) const SizedBox(width: 18),
],
],
),
),
const SizedBox(height: 17),
if (logic.section == 0)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < filters.length; index++)
Padding(
padding: const EdgeInsets.only(right: 7),
child: ChoiceChip(
label: Text(filters[index]),
selected: logic.filter == index,
onSelected: (_) => setState(() => logic.filter = index),
showCheckmark: false,
visualDensity: VisualDensity.compact,
backgroundColor: const Color(0xFF242328),
selectedColor: _orange,
labelStyle: TextStyle(
color: logic.filter == index
? Colors.white
: const Color(0xFFD5CED0),
fontSize: 11,
fontWeight: FontWeight.w700,
),
side: BorderSide.none,
),
),
],
),
),
if (logic.section == 0) const SizedBox(height: 11),
if (logic.section == 0 && hasHistory)
InkWell(
onTap: () => _act(MyPageAction.resume),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_poster(width: 74, height: 103),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, widget.drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
l10n.dramaTag,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
const SizedBox(height: 32),
Text(
'${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA18D),
fontSize: 10,
),
),
],
),
),
const CircleAvatar(
radius: 15,
backgroundColor: Color(0xFF313137),
child: Icon(
Icons.play_arrow_rounded,
color: _orange,
size: 20,
),
),
],
),
),
)
else if (logic.section != 0 && markedDramas.isNotEmpty)
for (final drama in markedDramas) ...[
_markedDramaCard(drama, l10n),
const SizedBox(height: 9),
]
else
Padding(
padding: const EdgeInsets.symmetric(vertical: 22),
child: Text(
l10n.noSavedShows,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
),
],
);
}
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
onTap: () {
if (widget.onDramaSelected != null) {
widget.onDramaSelected!(drama);
} else if (drama.id == widget.drama.id) {
_act(MyPageAction.resume);
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
drama.posterAsset,
width: 74,
height: 103,
fit: BoxFit.cover,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
],
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFFF2EEEF)),
],
),
),
);
Widget _preferences(AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: Color(0xFFCEBBB9),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
const SizedBox(height: 12),
Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: const Icon(
Icons.translate_rounded,
color: Color(0xFFFFBC65),
),
title: Text(l10n.appLanguage),
subtitle: Text(l10n.language),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Localizations.localeOf(context).languageCode == 'zh'
? l10n.simplifiedChinese
: l10n.english,
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, size: 18),
],
),
onTap: _showLanguage,
),
),
],
);
Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63,
decoration: const BoxDecoration(
color: Color(0xFF0D0D11),
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NavItem(
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
onTap: () => Navigator.pop(context, MyPageAction.home),
),
_NavItem(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => Navigator.pop(context, MyPageAction.discover),
),
_NavItem(
icon: Icons.video_library_outlined,
label: l10n.myTab,
selected: true,
onTap: () {},
),
],
),
);
Widget _poster({required double width, required double height}) => ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.asset(
widget.drama.posterAsset,
width: width,
height: height,
fit: BoxFit.cover,
),
);
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 72,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? _orange : const Color(0xFFCAC4C7),
),
Text(
label,
style: TextStyle(
color: selected ? _orange : const Color(0xFFCAC4C7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
String _clock(Duration duration) {
final seconds = duration.inSeconds;
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
}
const _orange = Color(0xFFFF5B3D);
+10
View File
@@ -0,0 +1,10 @@
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import 'player_logic.dart';
class PlayerBinding {
PlayerLogic create() => PlayerLogic(
history: SharedPreferencesWatchHistoryRepository(),
pictureInPicture: AndroidPictureInPicture(),
);
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../home/home_feed_sections.dart';
class PlayerLogic {
PlayerLogic({required this.history, required this.pictureInPicture});
final WatchHistoryRepository history;
final AndroidPictureInPicture pictureInPicture;
late PageController pages;
late PlaybackQueue queue;
late HomeFeedSections homeSections;
late List<int> visibleIndices;
HomeFeedCategory category = HomeFeedCategory.recommended;
int categoryRequest = 0;
late PlaybackSession session;
VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode;
Drama? trackedDrama;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
Future<void> pendingWrite = Future<void>.value();
int pageRequest = 0;
bool muted = false;
bool stoppedAtFeedEnd = false;
VideoPlayerPort? scrubbingPlayer;
bool fullscreen = false;
bool pip = false;
bool pipPreparing = false;
bool get immersive => fullscreen || pip || pipPreparing;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/engagement_controller.dart';
import '../../../l10n/app_localizations.dart';
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama});
final Drama drama;
@override
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
}
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
final _input = TextEditingController();
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _post() {
final text = _input.text.trim();
if (text.isEmpty) return;
context.read<EngagementController>().addComment(widget.drama.id, text);
_input.clear();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final comments = context.watch<EngagementController>().commentsFor(
widget.drama.id,
);
final available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: available.clamp(240.0, 460.0),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.comments,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.localOnlyComments,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13),
),
const SizedBox(height: 14),
Expanded(
child: comments.isEmpty
? Center(
child: Text(
l10n.noComments,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
: ListView.separated(
itemCount: comments.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: const CircleAvatar(
radius: 16,
child: Icon(Icons.person_rounded, size: 18),
),
title: Text(comments[index]),
contentPadding: EdgeInsets.zero,
),
),
),
Row(
children: [
Expanded(
child: TextField(
key: const Key('comment-input'),
controller: _input,
maxLength: 300,
maxLines: 1,
decoration: InputDecoration(
hintText: l10n.writeComment,
counterText: '',
filled: true,
fillColor: const Color(0xFF35353A),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _post(),
),
),
const SizedBox(width: 8),
IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
onPressed: _post,
icon: const Icon(Icons.send_rounded),
),
],
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
/// Keeps seeks within the playable range so dragging to the edge does not
/// trigger the player's completion callback before playback resumes.
Duration seekPositionForFraction(double fraction, Duration duration) {
final total = duration.inMilliseconds;
if (total <= 0) return Duration.zero;
final lastPlayable = total > 250 ? total - 250 : total - 1;
final requested = (fraction.clamp(0.0, 1.0) * total).round();
return Duration(milliseconds: requested.clamp(0, lastPlayable));
}
class SeekProgressBar extends StatefulWidget {
const SeekProgressBar({
super.key,
required this.valueListenable,
required this.semanticsLabel,
this.onScrubStart,
this.onSeek,
this.showTimes = true,
this.compactIdle = false,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
final String semanticsLabel;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
final bool compactIdle;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
}
class _SeekProgressBarState extends State<SeekProgressBar> {
double? _previewFraction;
@override
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.valueListenable != widget.valueListenable) {
_previewFraction = null;
}
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.valueListenable;
setState(() => _previewFraction = fraction);
try {
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
} finally {
if (mounted && widget.valueListenable == source) {
setState(() => _previewFraction = null);
}
}
}
@override
Widget build(BuildContext context) {
final source = widget.valueListenable;
if (source == null) {
return _buildTrack(context, const VideoPlayerValue.uninitialized());
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) => _buildTrack(context, value),
);
}
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
final played = total > 0
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
final fraction = _previewFraction ?? played;
final previewPosition = _previewFraction == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final scrubbing = _previewFraction != null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: widget.compactIdle && !scrubbing ? 20 : 36,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
activeTrackColor: const Color(0xFFFF5C37),
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
onChangeStart: enabled
? (value) {
setState(() => _previewFraction = value);
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => setState(() => _previewFraction = value)
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
),
),
),
if (widget.showTimes && (!widget.compactIdle || scrubbing))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(previewPosition),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
Text(
_format(video.duration),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
],
),
],
);
}
String _format(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';
}
}