feat: 视频详情页,分类页。个人中心

This commit is contained in:
phoenix--zhang 2026-07-07 16:04:15 +08:00
parent b5bce6edcf
commit a3426831ed
26 changed files with 2878 additions and 1077 deletions

BIN
assets/imgs/home_head.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
assets/other/app_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@ -37,6 +37,8 @@ class ConstImage {
// remove
static const String remove = "assets/imgs/remove.png";
static const String homeEyes = "assets/imgs/home_eyes.png";
// home_head
static const String homeHead = "assets/imgs/home_head.png";
/// other
static const String mineBg = "assets/other/mine_bg.png";
@ -51,6 +53,8 @@ class ConstImage {
static const String primary = "assets/other/pricary.png";
static const String webSite = "assets/other/web_site.png";
static const String emptyImg = "assets/other/empty_img.png";
// app_logo
static const String appLogo = "assets/other/app_logo.png";
}

View File

@ -0,0 +1,14 @@
import 'package:novyronst/common/model/ny_drama_model.dart';
int collectShortPlayId(NyDramaModel drama) {
return drama.shortPlayId ?? drama.shortId;
}
String dramaEpisodeText(NyDramaModel drama) {
final currentEpisode = drama.currentEpisode ?? drama.episode ?? 1;
return 'EP.$currentEpisode/EP.${drama.episodeTotal}';
}
String collectionTabLabel(int index) {
return index == 1 ? 'History' : 'Favorites';
}

View File

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import '../../../tools/widgets/app_bg_page.dart';
import 'package:novyronst/pages/collect/pages/collect_page_helpers.dart';
import 'package:novyronst/pages/collect/pages/favorite_page.dart';
import 'package:novyronst/pages/collect/pages/history_page.dart';
import 'package:novyronst/tools/widgets/app_bg_page.dart';
class CollectionPage extends StatefulWidget {
const CollectionPage({super.key});
@ -12,125 +14,27 @@ class CollectionPage extends StatefulWidget {
}
class _RootPageState extends State<CollectionPage> {
static const List<_DramaItem> _favoriteItems = [
_DramaItem(
title: 'An Uninvited Guest',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/09/26/66f4d0ce67383.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Vanished Without a Word',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/06/11/6667f17c9df69.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Dawn of Stardom',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/07/19/669a15b8a2e28.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Marry Me Twice',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/04/30/6630c89b746a8.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Not Your Bride',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/05/22/664daf9d042d4.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: "Husband's Hidden Crown",
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/08/12/66b9dfd8182cc.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'In the City of Love',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/07/24/66a0b88fda163.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: "Mommy's Genius Squad",
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/05/17/664716f187b2b.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Burned by Love',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/07/05/66876b720daf4.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
];
final PageController _pageController = PageController();
int _currentIndex = 0;
bool _isEditing = false;
static const List<_DramaItem> _historyItems = [
_DramaItem(
title: 'Shadows of Vengeance',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/09/10/66e01c7ab24ab.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: "CEO's Naughty Wife Is Hidden Rich",
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/08/02/66accc37da3a5.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Her Veiled Protector',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/06/28/667e7312bc731.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Love Deep As The Ocean',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/06/14/666c06fb54983.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Not Your Bride',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/05/22/664daf9d042d4.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Shadows of Vengeance',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/09/10/66e01c7ab24ab.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
_DramaItem(
title: 'Shadows of Vengeance',
imageUrl:
'https://static.goodshort.com/upload/serial/cover/2024/09/10/66e01c7ab24ab.jpg',
currentEpisode: 1,
totalEpisode: 51,
),
];
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
bool _showHistory = false;
void _changePage(int index) {
setState(() {
_currentIndex = index;
_isEditing = false;
});
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 220),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
@ -142,11 +46,18 @@ class _RootPageState extends State<CollectionPage> {
children: [
_buildHeader(),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: _showHistory
? _buildHistoryList()
: _buildFavoritesGrid(),
child: PageView(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
_isEditing = false;
});
},
children: [
FavoritePage(isEditing: _isEditing && _currentIndex == 0),
HistoryPage(isEditing: _isEditing && _currentIndex == 1),
],
),
),
],
@ -157,49 +68,41 @@ class _RootPageState extends State<CollectionPage> {
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.fromLTRB(30, 18, 26, 12),
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
child: Row(
children: [
_buildTab(
'Favorites',
active: !_showHistory,
onTap: () {
setState(() {
_showHistory = false;
});
},
),
_buildTab(0),
const SizedBox(width: 16),
_buildTab(
'History',
active: _showHistory,
onTap: () {
_buildTab(1),
const Spacer(),
IconButton(
onPressed: () {
setState(() {
_showHistory = true;
_isEditing = !_isEditing;
});
},
icon: Icon(
_isEditing ? Icons.done : Icons.edit_outlined,
color: Colors.white,
size: 22,
),
),
const Spacer(),
const Icon(Icons.edit_outlined, color: Colors.white, size: 22),
],
),
);
}
Widget _buildTab(
String title, {
required bool active,
required VoidCallback onTap,
}) {
Widget _buildTab(int index) {
final active = _currentIndex == index;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
onTap: () => _changePage(index),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
collectionTabLabel(index),
style: TextStyle(
color: active
? Colors.white
@ -223,196 +126,4 @@ class _RootPageState extends State<CollectionPage> {
),
);
}
Widget _buildFavoritesGrid() {
return GridView.builder(
key: const ValueKey('favorites'),
padding: const EdgeInsets.fromLTRB(30, 12, 14, 96),
itemCount: _favoriteItems.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 14,
childAspectRatio: 0.55,
),
itemBuilder: (context, index) {
return _FavoriteCard(item: _favoriteItems[index]);
},
);
}
Widget _buildHistoryList() {
return ListView.separated(
key: const ValueKey('history'),
padding: const EdgeInsets.fromLTRB(30, 8, 20, 96),
itemCount: _historyItems.length,
separatorBuilder: (context, index) => const SizedBox(height: 13),
itemBuilder: (context, index) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 30,
child: Icon(
Icons.bookmark_border,
color: Colors.white.withValues(alpha: 0.28),
size: 32,
),
),
const SizedBox(width: 18),
Expanded(child: _HistoryItem(item: _historyItems[index])),
],
);
},
);
}
}
class _FavoriteCard extends StatelessWidget {
const _FavoriteCard({required this.item});
final _DramaItem item;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: Image.network(
item.imageUrl,
width: constraints.maxWidth,
height: constraints.maxWidth * 1.33,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: constraints.maxWidth,
height: constraints.maxWidth * 1.33,
color: Colors.white.withValues(alpha: 0.12),
);
},
),
),
const SizedBox(height: 8),
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
height: 1.18,
fontFamily: 'Montserrat-Bold',
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
item.episodeText,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.48),
fontSize: 11,
fontFamily: 'Montserrat-Medium',
fontWeight: FontWeight.w500,
),
),
],
);
},
);
}
}
class _HistoryItem extends StatelessWidget {
const _HistoryItem({required this.item});
final _DramaItem item;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 68,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: Image.network(
item.imageUrl,
width: 51,
height: 68,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 51,
height: 68,
color: Colors.white.withValues(alpha: 0.12),
);
},
),
),
const SizedBox(width: 15),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.22,
fontFamily: 'Montserrat-Bold',
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text.rich(
TextSpan(
children: [
TextSpan(
text: 'EP.${item.currentEpisode}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
TextSpan(text: ' /EP.${item.totalEpisode}'),
],
),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 12,
fontFamily: 'Montserrat-Medium',
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
}
class _DramaItem {
const _DramaItem({
required this.title,
required this.imageUrl,
required this.currentEpisode,
required this.totalEpisode,
});
final String title;
final String imageUrl;
final int currentEpisode;
final int totalEpisode;
String get episodeText => 'EP.$currentEpisode/EP.$totalEpisode';
}

View File

@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/collect/pages/collect_page_helpers.dart';
import 'package:novyronst/pages/widgets/ny_empty_view.dart';
class FavoritePage extends StatefulWidget {
const FavoritePage({
super.key,
required this.isEditing,
this.fetchCollections,
this.deleteCollection,
});
final bool isEditing;
final Future<List<NyDramaModel>> Function({int page, int pageSize})?
fetchCollections;
final Future<bool> Function({required int shortPlayId})? deleteCollection;
@override
State<FavoritePage> createState() => _FavoritePageState();
}
class _FavoritePageState extends State<FavoritePage> {
final List<NyDramaModel> _items = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadFavorites();
}
Future<void> _loadFavorites() async {
setState(() {
_isLoading = true;
});
final fetch = widget.fetchCollections ?? VideoRequest.getCollections;
final items = await fetch(page: 1, pageSize: 20);
if (!mounted) return;
setState(() {
_items
..clear()
..addAll(items);
_isLoading = false;
});
}
Future<void> _deleteFavorite(int index) async {
if (index < 0 || index >= _items.length) return;
final item = _items.removeAt(index);
setState(() {});
final delete = widget.deleteCollection ?? VideoRequest.cancelCollect;
final success = await delete(shortPlayId: collectShortPlayId(item));
if (!mounted || success) return;
setState(() {
_items.insert(index.clamp(0, _items.length), item);
});
}
@override
Widget build(BuildContext context) {
if (_isLoading && _items.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_items.isEmpty) {
return const NyEmptyView();
}
return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 14, 96),
itemCount: _items.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 8,
mainAxisSpacing: 14,
childAspectRatio: 109 / 215,
),
itemBuilder: (context, index) {
return _FavoriteCard(
item: _items[index],
isEditing: widget.isEditing,
onDelete: () => _deleteFavorite(index),
);
},
);
}
}
class _FavoriteCard extends StatelessWidget {
const _FavoriteCard({
required this.item,
required this.isEditing,
required this.onDelete,
});
final NyDramaModel item;
final bool isEditing;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: Image.network(
item.imageUrl,
width: constraints.maxWidth,
height: constraints.maxWidth * 1.33,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: constraints.maxWidth,
height: constraints.maxWidth * 1.33,
color: Colors.white.withValues(alpha: 0.12),
);
},
),
),
if (isEditing)
Positioned(
top: 4,
right: 4,
child: _DeleteButton(onTap: onDelete),
),
],
),
const SizedBox(height: 8),
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
height: 1.18,
fontFamily: 'Montserrat-Bold',
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
dramaEpisodeText(item),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.48),
fontSize: 11,
fontFamily: 'Montserrat-Medium',
fontWeight: FontWeight.w500,
),
),
],
);
},
);
}
}
class _DeleteButton extends StatelessWidget {
const _DeleteButton({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black.withValues(alpha: 0.62),
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: const SizedBox(
width: 30,
height: 30,
child: Icon(Icons.delete_outline, color: Colors.redAccent, size: 18),
),
),
);
}
}

View File

@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/collect/pages/collect_page_helpers.dart';
class HistoryPage extends StatefulWidget {
const HistoryPage({super.key, required this.isEditing, this.fetchHistory});
final bool isEditing;
final Future<List<NyDramaModel>> Function({int page, int pageSize})?
fetchHistory;
@override
State<HistoryPage> createState() => _HistoryPageState();
}
class _HistoryPageState extends State<HistoryPage> {
final List<NyDramaModel> _items = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
setState(() {
_isLoading = true;
});
final fetch = widget.fetchHistory ?? VideoRequest.getHistory;
final items = await fetch(page: 1, pageSize: 20);
if (!mounted) return;
setState(() {
_items
..clear()
..addAll(items);
_isLoading = false;
});
}
void _deleteHistory(int index) {
if (index < 0 || index >= _items.length) return;
setState(() {
_items.removeAt(index);
});
}
@override
Widget build(BuildContext context) {
if (_isLoading && _items.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_items.isEmpty) {
return const Center(
child: Text('No history', style: TextStyle(color: Colors.white70)),
);
}
return ListView.separated(
padding: const EdgeInsets.fromLTRB(30, 8, 20, 96),
itemCount: _items.length,
separatorBuilder: (context, index) => const SizedBox(height: 13),
itemBuilder: (context, index) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 30,
child: _HistoryLeadingButton(
isEditing: widget.isEditing,
onDelete: () => _deleteHistory(index),
),
),
const SizedBox(width: 18),
Expanded(child: _HistoryItem(item: _items[index])),
],
);
},
);
}
}
class _HistoryLeadingButton extends StatelessWidget {
const _HistoryLeadingButton({
required this.isEditing,
required this.onDelete,
});
final bool isEditing;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
if (isEditing) {
return IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 30, height: 40),
onPressed: onDelete,
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 28),
);
}
return Icon(
Icons.bookmark_border,
color: Colors.white.withValues(alpha: 0.28),
size: 32,
);
}
}
class _HistoryItem extends StatelessWidget {
const _HistoryItem({required this.item});
final NyDramaModel item;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 68,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: Image.network(
item.imageUrl,
width: 51,
height: 68,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 51,
height: 68,
color: Colors.white.withValues(alpha: 0.12),
);
},
),
),
const SizedBox(width: 15),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.22,
fontFamily: 'Montserrat-Bold',
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
dramaEpisodeText(item),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 12,
fontFamily: 'Montserrat-Medium',
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,280 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/const/const_string.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/help/help_time.dart';
import 'package:novyronst/common/model/ny_category_model.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/first/player_detail/video_detail_page.dart';
import 'package:novyronst/pages/widgets/ny_empty_view.dart';
import 'package:novyronst/pages/widgets/ny_tag_widget.dart';
import 'package:novyronst/tools/widgets/novy_app_bar.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
class CategoryListPage extends StatefulWidget {
const CategoryListPage({super.key, this.preCategory});
final NyCategoryModel? preCategory;
@override
State<CategoryListPage> createState() => _CatePageState();
}
class _CatePageState extends State<CategoryListPage> {
final List<NyCategoryModel> _categories = [];
final List<NyDramaModel> _dramas = [];
int _selectedIndex = 0;
int? _loadingCategoryId;
bool _loadingCategories = true;
bool _loadingDramas = false;
@override
void initState() {
super.initState();
_loadCategories();
}
Future<void> _loadCategories() async {
final categories = await VideoRequest.getCategoriesList();
if (!mounted) return;
final nextIndex = selectedCategoryIndex(categories, widget.preCategory);
setState(() {
_categories
..clear()
..addAll(categories);
_selectedIndex = nextIndex < 0 ? 0 : nextIndex;
_loadingCategories = false;
});
if (categories.isNotEmpty) {
_loadCategoryDetail(categories[_selectedIndex].id);
}
}
Future<void> _loadCategoryDetail(int categoryId) async {
_loadingCategoryId = categoryId;
setState(() {
_loadingDramas = true;
_dramas.clear();
});
final dramas = await VideoRequest.getCategoriesDetail(
categoryId: categoryId,
);
if (!mounted || _loadingCategoryId != categoryId) return;
setState(() {
_dramas
..clear()
..addAll(dramas);
_loadingDramas = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: const NovyAppBar(title: 'Categories', titleColor: Colors.white),
body: SafeArea(
top: false,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCategoryList(),
Expanded(child: _buildDramaList()),
],
),
),
);
}
Widget _buildCategoryList() {
return Container(
width: 112.w,
height: double.infinity,
padding: EdgeInsets.only(top: 12.h, bottom: 18.h),
color: Colors.black,
child: _loadingCategories
? const SizedBox.shrink()
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: _categories.length,
itemBuilder: (context, index) => _buildCategoryItem(index),
),
);
}
Widget _buildCategoryItem(int index) {
final category = _categories[index];
final selected = index == _selectedIndex;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (selected) return;
setState(() {
_selectedIndex = index;
});
_loadCategoryDetail(category.id);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
height: 58,
margin: EdgeInsets.only(left: 8.w, right: 8.w, bottom: 6.h),
padding: EdgeInsets.symmetric(horizontal: 8.w),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? ConstColor.btGreen : const Color(0xFF151515),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: selected
? Colors.black
: Colors.white.withValues(alpha: 0.08),
width: 1,
),
),
child: NovyText(
category.name,
color: selected ? Colors.black : Colors.white.withValues(alpha: 0.62),
fontSize: 12,
fontFamily: selected ? ConstFont.montBold : ConstFont.montMedium,
textAlign: TextAlign.center,
maxLines: 2,
lineHeight: 1.05,
),
),
);
}
Widget _buildDramaList() {
if (_loadingDramas) {
return Center(
child: CircularProgressIndicator(color: ConstColor.btGreen),
);
}
if (_dramas.isEmpty) {
return const NyEmptyView();
}
return ListView.separated(
padding: EdgeInsets.only(left: 4.w, right: 16.w, top: 12.h, bottom: 28.h),
itemCount: _dramas.length,
separatorBuilder: (context, index) => SizedBox(height: 12.h),
itemBuilder: (context, index) {
return _DramaItem(drama: _dramas[index]);
},
);
}
}
class _DramaItem extends StatelessWidget {
const _DramaItem({required this.drama});
final NyDramaModel drama;
@override
Widget build(BuildContext context) {
final tag = categoryDramaTag(drama);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => AppNav.to(VideoDetailPage(drama: drama)),
child: Container(
height: 118.h,
padding: EdgeInsets.all(8.w),
decoration: BoxDecoration(
color: const Color(0xFF101010),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HelpImage.networkRadius(
drama.imageUrl,
width: 78.w,
height: 102.h,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(4),
),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovyText(
drama.name,
color: Colors.white,
fontSize: 14,
fontFamily: ConstFont.montBold,
maxLines: 2,
lineHeight: 1.08,
),
SizedBox(height: 6.h),
NovyText(
drama.description,
color: Colors.white.withValues(alpha: 0.56),
fontSize: 11,
maxLines: 2,
lineHeight: 1.2,
),
const Spacer(),
Row(
children: [
if (tag.isNotEmpty) ...[
NyTagWidget(
text: tag,
bgColor: Colors.white.withValues(alpha: 0.12),
textColor: Colors.white.withValues(alpha: 0.72),
),
SizedBox(width: 10.w),
],
NyHotWidget(
text: HelpTime.formatNum(drama.watchTotal),
textColor: ConstColor.btGreen,
),
],
),
],
),
),
],
),
),
);
}
}
int selectedCategoryIndex(
List<NyCategoryModel> categories,
NyCategoryModel? preCategory,
) {
if (categories.isEmpty) return -1;
if (preCategory == null) return 0;
final index = categories.indexWhere(
(category) => category.id == preCategory.id,
);
return index >= 0 ? index : 0;
}
String categoryDramaTag(NyDramaModel drama) {
final category = drama.category;
if (category != null && category.isNotEmpty) {
return category.first;
}
final categoryList = drama.categoryList;
if (categoryList != null && categoryList.isNotEmpty) {
return categoryList.first.name;
}
return drama.tagType;
}

View File

@ -1,4 +1,5 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/help/app_nav.dart';
@ -6,6 +7,7 @@ import 'package:novyronst/common/model/ny_category_model.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/common/model/ny_home_model.dart';
import 'package:novyronst/pages/first/pages/search_page.dart';
import 'package:novyronst/pages/first/widgets/home_card_widget.dart';
import 'package:novyronst/pages/first/widgets/home_first_view.dart';
import 'package:novyronst/pages/first/widgets/home_rank_widget.dart';
import 'package:novyronst/pages/first/widgets/home_remaining_sections.dart';
@ -24,12 +26,24 @@ class FirstPage extends StatefulWidget {
}
class _RootPageState extends State<FirstPage> {
List<NyCategoryModel> _categories = [];
List<NyHomeModel> _homeDatas = [];
final EasyRefreshController _refreshController = EasyRefreshController(
controlFinishRefresh: true,
);
final List<String> _cateTitles = [
'Counterattack Life',
'Reborn and Returned',
'God of War Returns',
'System Flow',
];
final List<String> _cateTags = [
'home_v3_recommand',
'new_recommand',
'get_details_recommand',
'week_ranking',
];
NyDramaModel? _dramaModel;
bool _useGlassHeader = false;
@override
void initState() {
@ -45,8 +59,17 @@ class _RootPageState extends State<FirstPage> {
_homeDatas = value;
});
for (NyHomeModel element in value) {
print('ele len===: ${element.moduleKey} : ${element.dataList.length}');
List<NyDramaModel> temp = [];
temp = _homeDatas
.firstWhere((element) => element.moduleKey == 'week_ranking')
.dataList;
if (temp.isEmpty) {
temp = _homeDatas
.firstWhere((element) => element.moduleKey == 'home_v3_recommand')
.dataList;
}
if (temp.isNotEmpty) {
_dramaModel = temp.first;
}
});
}
@ -56,48 +79,158 @@ class _RootPageState extends State<FirstPage> {
setState(() {
_categories = value;
});
// for (NyCategoryModel element in value) {
// print('NyCategoryModel===: ${element.name}');
// }
});
}
@override
Widget build(BuildContext context) {
return AppBgPage(
child: Column(
child: Stack(
children: [
HomeSearchView(
onlyTap: true,
onTap: () {
AppNav.to(SearchPage());
},
),
_homeDatas.isEmpty ? SizedBox() : Expanded(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: HomeFirstView(
categories: _categories,
)),
SliverPadding(
padding: const EdgeInsets.only(left: 16, right: 10, top: 16, bottom: 8),
sliver: SliverToBoxAdapter(
child: HelpImage.asset(ConstImage.homeRank),
)
),
SliverPadding(
padding: const EdgeInsetsGeometry.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: HomeRankWidget(
dramaRanks: _homeDatas
.firstWhere((element) => element.moduleKey == 'week_ranking')
.dataList,
Positioned.fill(
child: _homeDatas.isEmpty
? const SizedBox()
: NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: AppScreen.navBarHeight),
),
SliverToBoxAdapter(
child: HomeFirstView(categories: _categories),
),
SliverPadding(
padding: const EdgeInsets.only(
left: 16,
right: 10,
top: 16,
bottom: 8,
),
sliver: SliverToBoxAdapter(
child: HelpImage.asset(ConstImage.homeRank),
),
),
SliverPadding(
padding: const EdgeInsetsGeometry.symmetric(
horizontal: 16,
),
sliver: SliverToBoxAdapter(
child: HomeRankWidget(
dramaRanks: _homeDatas
.firstWhere(
(element) =>
element.moduleKey == 'week_ranking',
)
.dataList,
),
),
),
SliverToBoxAdapter(
child: HomeRemainingSections(dramaOne: _dramaModel!),
),
..._buildSliversFromData(),
const SliverToBoxAdapter(child: SizedBox(height: 110)),
],
),
),
),
const SliverToBoxAdapter(child: HomeRemainingSections()),
],
),
Positioned(
left: 0,
right: 0,
top: 0,
child: _HomeGlassHeader(
useGlass: _useGlassHeader,
child: HomeSearchView(
onlyTap: true,
showLogo: !_useGlassHeader,
onTap: () {
AppNav.to(SearchPage());
},
),
),
),
],
),
);
}
bool _handleScrollNotification(ScrollNotification notification) {
if (notification.metrics.axis != Axis.vertical) return false;
final nextUseGlass = shouldUseHomeGlassHeader(notification.metrics.pixels);
if (nextUseGlass != _useGlassHeader) {
setState(() {
_useGlassHeader = nextUseGlass;
});
}
return false;
}
List<Widget> _buildSliversFromData() {
final List<Widget> slivers = [];
int index = 0;
for (final str in _cateTags) {
List<NyDramaModel> list = _homeDatas
.firstWhere((element) => element.moduleKey == str)
.dataList;
if (list.isEmpty) continue;
slivers.add(
SliverPadding(
padding: const EdgeInsetsGeometry.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: HomeCardWidget(
title: _cateTitles[index],
dramaList: _homeDatas
.firstWhere((element) => element.moduleKey == str)
.dataList,
),
),
),
);
slivers.add(const SliverToBoxAdapter(child: SizedBox(height: 16)));
index++;
}
return slivers;
}
}
class _HomeGlassHeader extends StatelessWidget {
const _HomeGlassHeader({required this.useGlass, required this.child});
final bool useGlass;
final Widget child;
@override
Widget build(BuildContext context) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: useGlass ? 18 : 0,
sigmaY: useGlass ? 18 : 0,
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
color: useGlass
? Colors.white.withValues(alpha: 0.16)
: Colors.transparent,
child: child,
),
),
);
}
}
bool shouldUseHomeGlassHeader(double scrollOffset) {
return scrollOffset > 8;
}

View File

@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/model/ny_detail_model.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/common/model/ny_episode_model.dart';
import 'package:novyronst/pages/first/player_detail/video_esp_page.dart';
import 'package:novyronst/pages/play/widgets/player_view.dart';
class VideoDetailPage extends StatefulWidget {
const VideoDetailPage({super.key, required this.drama});
final NyDramaModel drama;
@override
State<VideoDetailPage> createState() => _VideoDetailPageState();
}
class _VideoDetailPageState extends State<VideoDetailPage> {
NyDetailModel? _detail;
List<NyDramaModel> _videos = [];
int _currentIndex = 0;
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadDetail();
}
Future<void> _loadDetail() async {
setState(() {
_isLoading = true;
});
final detail = await VideoRequest.getVideoDetail(
shortPlayId: widget.drama.shortPlayId ?? widget.drama.shortId,
shortPlayVideoId:
widget.drama.shortPlayVideoId ??
widget.drama.id ??
widget.drama.shortId,
);
if (!mounted) return;
final videos = detail == null
? [widget.drama]
: buildEpisodeDramaList(detail);
final nextIndex = detail == null
? 0
: episodeIndexForDrama(detail.episodeList, widget.drama);
setState(() {
_detail = detail;
_videos = videos;
_currentIndex = nextIndex < 0 ? 0 : nextIndex;
_isLoading = false;
});
}
Future<void> _toggleCollect(int index) async {
if (index < 0 || index >= _videos.length) return;
final item = _videos[index];
final videoId = item.shortPlayVideoId ?? item.id;
if (videoId == null) return;
final shortPlayId = item.shortPlayId ?? item.shortId;
final oldIsCollect = item.isCollect ?? false;
final oldCollectTotal = item.collectTotal;
final newIsCollect = !oldIsCollect;
final newCollectTotal = oldCollectTotal + (newIsCollect ? 1 : -1);
setState(() {
_videos[index] = item.copyWith(
isCollect: newIsCollect,
collectTotal: newCollectTotal < 0 ? 0 : newCollectTotal,
);
});
final success = newIsCollect
? await VideoRequest.collect(shortPlayId: shortPlayId, videoId: videoId)
: await VideoRequest.cancelCollect(shortPlayId: shortPlayId);
if (!mounted || success) return;
setState(() {
_videos[index] = _videos[index].copyWith(
isCollect: oldIsCollect,
collectTotal: oldCollectTotal,
);
});
}
void _showEpisodeSheet() {
final detail = _detail;
if (detail == null || detail.episodeList.isEmpty) return;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return VideoEspPage(
detail: detail,
currentEpisode: _currentEpisode,
onSelectedEpisode: (episode) {
Navigator.pop(context);
final nextIndex = episodeIndexForEpisode(
detail.episodeList,
episode,
);
if (nextIndex < 0) return;
setState(() {
_currentIndex = nextIndex;
});
},
);
},
);
}
int get _currentEpisode {
if (_videos.isEmpty || _currentIndex >= _videos.length) return 1;
return _videos[_currentIndex].episode ?? _currentIndex + 1;
}
int get _totalEpisode {
final detail = _detail;
final total =
detail?.shortPlayInfo?.episodeTotal ?? widget.drama.episodeTotal;
if (total > 0) return total;
return detail?.episodeList.length ?? _videos.length;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(child: _buildBody()),
Positioned(
left: 10,
top: MediaQuery.paddingOf(context).top + 8,
child: IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
),
),
],
),
);
}
Widget _buildBody() {
if (_isLoading && _videos.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_videos.isEmpty) {
return const Center(
child: Text('No videos', style: TextStyle(color: Colors.white70)),
);
}
return PlayerView(
videos: _videos,
initialIndex: _currentIndex,
bottomMetaSpacing: 24,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
onCollectTap: _toggleCollect,
progressHeaderBuilder: (context, drama, index) {
return _EpisodeSelectorBar(
currentEpisode: drama.episode ?? index + 1,
totalEpisode: _totalEpisode,
onTap: _showEpisodeSheet,
);
},
);
}
}
class _EpisodeSelectorBar extends StatelessWidget {
const _EpisodeSelectorBar({
required this.currentEpisode,
required this.totalEpisode,
required this.onTap,
});
final int currentEpisode;
final int totalEpisode;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const Icon(
Icons.video_library_outlined,
color: Colors.white,
size: 17,
),
const SizedBox(width: 10),
Text(
'EP.$currentEpisode',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
Text(
' / EP.$totalEpisode',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 13,
),
),
const Spacer(),
const Icon(Icons.arrow_forward_ios, color: Colors.white, size: 13),
],
),
),
);
}
}
List<NyDramaModel> buildEpisodeDramaList(NyDetailModel detail) {
final drama = detail.shortPlayInfo ?? detail.videoInfo;
final info = detail.videoInfo ?? detail.shortPlayInfo;
final total = drama?.episodeTotal ?? detail.episodeList.length;
return detail.episodeList.map((episode) {
return NyDramaModel(
id: episode.id,
shortId: episode.shortId,
shortPlayId: episode.shortPlayId,
name: drama?.name ?? info?.name ?? '',
description: drama?.description ?? info?.description ?? '',
imageUrl: drama?.imageUrl ?? info?.imageUrl ?? '',
horizontallyImg: drama?.horizontallyImg ?? info?.horizontallyImg ?? '',
collectTotal: drama?.collectTotal ?? info?.collectTotal ?? 0,
watchTotal: drama?.watchTotal ?? info?.watchTotal ?? 0,
episodeTotal: total,
isCollect: detail.isCollect ?? drama?.isCollect ?? info?.isCollect,
videoUrl: episode.videoUrl,
videoInfo: {'video_url': episode.videoUrl},
tagType: drama?.tagType ?? info?.tagType ?? '',
episode: episode.episode,
shortPlayVideoId: episode.shortPlayVideoId,
currentEpisode: episode.episode,
);
}).toList();
}
int episodeIndexForEpisode(
List<NyEpisodeModel> episodes,
NyEpisodeModel selectedEpisode,
) {
return episodes.indexWhere(
(episode) => episode.shortPlayVideoId == selectedEpisode.shortPlayVideoId,
);
}
int episodeIndexForDrama(List<NyEpisodeModel> episodes, NyDramaModel drama) {
final videoId = drama.shortPlayVideoId ?? drama.id;
if (videoId != null) {
final index = episodes.indexWhere(
(episode) => episode.shortPlayVideoId == videoId,
);
if (index >= 0) return index;
}
final episodeNumber = drama.episode ?? drama.currentEpisode;
if (episodeNumber != null) {
return episodes.indexWhere((episode) => episode.episode == episodeNumber);
}
return -1;
}

View File

@ -0,0 +1,217 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/model/ny_detail_model.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/common/model/ny_episode_model.dart';
import 'package:novyronst/tools/util/app_screen.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
class VideoEspPage extends StatefulWidget {
const VideoEspPage({
super.key,
required this.detail,
this.currentEpisode,
this.onSelectedEpisode,
});
final NyDetailModel detail;
final int? currentEpisode;
final ValueChanged<NyEpisodeModel>? onSelectedEpisode;
@override
State<VideoEspPage> createState() => _VideoEspPageState();
}
class _VideoEspPageState extends State<VideoEspPage> {
late int _selectedEpisode;
NyDramaModel? get _drama =>
widget.detail.shortPlayInfo ?? widget.detail.videoInfo;
NyDramaModel? get _info => widget.detail.videoInfo;
List<NyEpisodeModel> get _episodes => widget.detail.episodeList;
@override
void initState() {
super.initState();
_selectedEpisode =
widget.currentEpisode ?? _drama?.currentEpisode ?? _drama?.episode ?? 1;
}
@override
Widget build(BuildContext context) {
const height = 520.0;
return Container(
width: AppScreen.screenWidth,
height: height,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(18)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDramaInfo(),
const SizedBox(height: 12),
Divider(
height: 1,
thickness: 0.5,
color: Colors.black.withValues(alpha: 0.08),
),
const SizedBox(height: 12),
NovyText(
'$_episodeTotal EP',
color: Colors.black,
fontSize: 15,
fontWeight: FontWeight.w500,
),
const SizedBox(height: 8),
Expanded(child: _buildEpisodeGrid()),
],
),
),
),
);
}
Widget _buildDramaInfo() {
final drama = _drama;
final imageUrl = drama?.imageUrl ?? '';
return SizedBox(
height: 130,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HelpImage.networkRadius(
imageUrl,
width: 96,
height: 130,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(2),
),
const SizedBox(width: 16),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 7),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovyText(
drama?.name ?? '',
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w700,
maxLines: 2,
lineHeight: 1.12,
),
const SizedBox(height: 10),
NovyText(
drama?.description ?? _info?.description ?? '',
color: Colors.black.withValues(alpha: 0.46),
fontSize: 14,
maxLines: 2,
lineHeight: 1.45,
),
const SizedBox(height: 13),
_buildTag(),
],
),
),
),
],
),
);
}
Widget _buildTag() {
final categoryList = _drama?.categoryList;
final name = categoryList?.isNotEmpty == true
? categoryList!.first.name
: (_drama?.tagType ?? '');
if (name.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFE7FFE5),
border: Border.all(color: const Color(0xFF86D57A), width: 1),
),
child: NovyText(
name,
color: Colors.black,
fontSize: 14,
overflow: TextOverflow.visible,
),
);
}
Widget _buildEpisodeGrid() {
if (_episodes.isEmpty) return const SizedBox.shrink();
return GridView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount: _episodes.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 61 / 50,
),
itemBuilder: (context, index) {
return _buildEpisodeItem(_episodes[index]);
},
);
}
Widget _buildEpisodeItem(NyEpisodeModel episode) {
final selected = episode.episode == _selectedEpisode;
final selectedPlayable = selected && !episode.isLock;
final selectedLocked = selected && episode.isLock;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (_selectedEpisode == episode.episode) return;
setState(() {
_selectedEpisode = episode.episode;
});
widget.onSelectedEpisode?.call(episode);
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: selectedPlayable
? ConstColor.btGreen
: const Color(0xFFF8F8F8),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selectedPlayable
? Colors.black
: (selectedLocked ? ConstColor.btGreen : Colors.transparent),
width: selected ? 1 : 0,
),
),
child: NovyText(
episode.episode.toString(),
color: Colors.black.withValues(alpha: 0.86),
fontSize: 20,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
),
),
);
}
int get _episodeTotal {
final total = _drama?.episodeTotal ?? 0;
return total > 0 ? total : _episodes.length;
}
}

View File

@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/first/player_detail/video_detail_page.dart';
import 'package:novyronst/pages/widgets/ny_tag_widget.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
import '../../../tools/util/app_screen.dart';
import '../../../tools/widgets/novy_button_bg.dart';
import '../../widgets/cut_corner_clipper.dart';
import 'home_remaining_sections.dart';
class HomeCardWidget extends StatelessWidget {
const HomeCardWidget({
super.key,
required this.dramaList,
required this.title,
});
final List<NyDramaModel> dramaList;
final String title;
@override
Widget build(BuildContext context) {
return CutCornerClipper(
width: AppScreen.screenWidth - 32,
height: 308.h,
cutCorner: CutCorner.topRight,
cutSize: 30,
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_threeLine(),
const SizedBox(height: 10),
OutlinedTitle(title, fontSize: 17),
const SizedBox(height: 10),
Expanded(child: _listView()),
const SizedBox(height: 6),
NovyButtonBg(text: 'Click to watch', width: 327.w),
],
),
),
);
}
Widget _listView() {
return ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: dramaList.length,
itemBuilder: (context, index) {
final drama = dramaList[index];
var tag = '';
if (drama.category != null && drama.category!.isNotEmpty) {
tag = drama.category!.first;
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => AppNav.to(VideoDetailPage(drama: drama)),
child: Container(
margin: const EdgeInsets.only(right: 7),
width: 104.w,
child: Column(
children: [
HelpImage.network(drama.imageUrl, width: 104.w, height: 157.h),
const SizedBox(height: 5),
NovyText(
drama.name,
fontSize: 12.sp,
color: Colors.black,
maxLines: 2,
),
const SizedBox(height: 2),
if (tag != '') Row(children: [NyTagWidget(text: tag)]),
],
),
),
);
},
);
}
Widget _threeLine() {
return Row(
children: List.generate(3, (index) {
return Container(
width: 3,
height: 8,
color: Colors.black,
margin: const EdgeInsets.only(right: 3),
);
}),
);
}
}

View File

@ -1,19 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/common/model/ny_category_model.dart';
import 'package:novyronst/pages/first/pages/category_list_page.dart';
import 'package:novyronst/pages/widgets/cut_corner_clipper.dart';
import 'package:novyronst/pages/widgets/ny_progress_widget.dart';
import 'package:novyronst/pages/widgets/ny_tag_widget.dart';
import 'package:novyronst/root/lib_export.dart';
import 'package:novyronst/tools/widgets/extend_widget.dart';
import 'package:novyronst/tools/widgets/novy_button_bg.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
import 'package:novyronst/tools/widgets/novy_img_bg.dart';
class HomeFirstView extends StatefulWidget {
const HomeFirstView({super.key,
required this.categories,
});
const HomeFirstView({super.key, required this.categories});
final List<NyCategoryModel> categories;
@ -24,21 +24,22 @@ class HomeFirstView extends StatefulWidget {
}
class _HomeFirstViewState extends State<HomeFirstView> {
bool _hasHistory = true;
@override
Widget build(BuildContext context) {
const double _space = 25;
const double space = 25;
return Stack(
children: [
// 151
SizedBox(
height: 151 - _space + 200 + 52 + 20,
SizedBox(height: 151 - space + 200 + 52 + 20),
HelpImage.asset(
ConstImage.homeFirst,
width: AppScreen.screenWidth,
fit: BoxFit.fill,
),
HelpImage.asset(ConstImage.homeFirst, width: AppScreen.screenWidth, fit: BoxFit.fill),
Positioned(
top: 151-_space, left: 16, right: 16,
top: 151 - space,
left: 16,
right: 16,
child: Column(
children: [
CutCornerClipper(
@ -52,40 +53,46 @@ class _HomeFirstViewState extends State<HomeFirstView> {
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(widget.categories.length, (index) {
final _item = widget.categories[index];
final item = widget.categories[index];
return Container(
margin: const EdgeInsets.only(right: 8),
child: NovyImgBg(imgBg: ConstImage.cateBg,
child: NovyImgBg(
imgBg: ConstImage.cateBg,
height: 52,
child: Stack(
children:[
NovyText(_item.name, color: ConstColor.btGreen,
children: [
NovyText(
item.name,
color: ConstColor.btGreen,
fontSize: 21,
textAlign: TextAlign.center,
fontFamily: ConstFont.montBlack,
shadow: Shadow(
offset: const Offset(1, 1), // X: 1, Y: 1
blurRadius: 0, // Blur: 0
color: Colors.black, // #000000
blurRadius: 0, // Blur: 0
color: Colors.black, // #000000
),
),
Positioned(
bottom: 0, left: 0, right: 0,
child: Container(
height: 2,
color: Colors.black,
),
)
]
bottom: 0,
left: 0,
right: 0,
child: Container(height: 2, color: Colors.black),
),
],
),
),
).addInkWell(
onTap: () {
AppNav.to(CategoryListPage(preCategory: item));
},
);
}),
),
),
],
)
)
),
),
],
);
}
@ -97,13 +104,14 @@ class _HomeFirstViewState extends State<HomeFirstView> {
children: [
Row(
children: [
NovyText('Current Viewing Progress',
NovyText(
'Current Viewing Progress',
color: Colors.black,
fontSize: 16,
fontFamily: ConstFont.montBlack,
),
const SizedBox(width: 10),
HelpImage.asset(ConstImage.homeCurrentTag, height: 10)
HelpImage.asset(ConstImage.homeCurrentTag, height: 10),
],
),
const SizedBox(height: 10),
@ -111,7 +119,8 @@ class _HomeFirstViewState extends State<HomeFirstView> {
children: [
Container(
color: Colors.grey.withAlpha(200),
width: 60, height: 90,
width: 60,
height: 90,
),
const SizedBox(width: 12),
SizedBox(
@ -119,13 +128,15 @@ class _HomeFirstViewState extends State<HomeFirstView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovyText('Jdksyjs dsaoy',
NovyText(
'Jdksyjs dsaoy',
color: Colors.black,
fontSize: 14,
fontFamily: ConstFont.montMedium,
),
const SizedBox(height: 4),
NovyText('Jdksyjs dsaoy dasku dsaldsa dljas',
NovyText(
'Jdksyjs dsaoy dasku dsaldsa dljas',
color: Colors.grey,
fontSize: 10,
fontFamily: ConstFont.montRegular,
@ -138,18 +149,18 @@ class _HomeFirstViewState extends State<HomeFirstView> {
const SizedBox(width: 8),
NyHotWidget(text: '12.4k'),
],
)
),
],
),
)
),
],
),
const SizedBox(height: 10),
NyProgressWidget(),
const SizedBox(height: 6),
NovyButtonBg(text: 'Keep Watching', type: 2, width: 327.w,)
NovyButtonBg(text: 'Keep Watching', type: 2, width: 327.w),
],
),
);
}
}
}

View File

@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/const/const_string.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/first/player_detail/video_detail_page.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
import '../../../tools/util/app_screen.dart';
@ -12,11 +14,7 @@ import '../../widgets/cut_corner_clipper.dart';
import '../../widgets/ny_tag_widget.dart';
class HomeRankWidget extends StatelessWidget {
const HomeRankWidget({
super.key,
required this.dramaRanks,
});
const HomeRankWidget({super.key, required this.dramaRanks});
final List<NyDramaModel> dramaRanks;
@ -28,24 +26,25 @@ class HomeRankWidget extends StatelessWidget {
cutSize: 40,
child: Padding(
padding: const EdgeInsets.all(8),
child: dramaRanks.isEmpty ? SizedBox(height: 20,) : Column(
children: [
_buildRankList(),
const SizedBox(height: 5),
Row(
children: [
SizedBox(width: 20.w),
NovyButtonBg(text: 'Keep Watching', width: 308.w,)
],
)
],
),
child: dramaRanks.isEmpty
? const SizedBox(height: 20)
: Column(
children: [
_buildRankList(),
const SizedBox(height: 5),
Row(
children: [
SizedBox(width: 20.w),
NovyButtonBg(text: 'Keep Watching', width: 308.w),
],
),
],
),
),
);
}
Row _buildRankList() {
return Row(
children: [
_firstVideoView(),
@ -56,85 +55,91 @@ class HomeRankWidget extends StatelessWidget {
}
Widget _firstVideoView() {
NyDramaModel _item = dramaRanks.first;
String _tag = "Safiray";
if (_item.categoryList!=null && _item.categoryList!.isNotEmpty) {
_tag = _item.categoryList!.first.name;
final item = dramaRanks.first;
var tag = 'Safiray';
if (item.categoryList != null && item.categoryList!.isNotEmpty) {
tag = item.categoryList!.first.name;
}
return SizedBox(
width: 160.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
SizedBox(
width: 160.w, height: 240.w,
child: HelpImage.network(_item.imageUrl, fit: BoxFit.cover),
),
Positioned(
top: -10, left: 1,
child: _colorsText(1),
)
],
),
const SizedBox(height: 10),
NovyText(_item.name, fontSize: 15,
color: Colors.black,
fontFamily: ConstFont.montMedium,
),
const SizedBox(height: 5),
Row(
children: [
NyTagWidget(text: _tag),
],
),
const SizedBox(height: 8),
],
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => AppNav.to(VideoDetailPage(drama: item)),
child: SizedBox(
width: 160.w,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
SizedBox(
width: 160.w,
height: 240.w,
child: HelpImage.network(item.imageUrl, fit: BoxFit.cover),
),
Positioned(top: -10, left: 1, child: _colorsText(1)),
],
),
const SizedBox(height: 10),
NovyText(
item.name,
fontSize: 15,
color: Colors.black,
fontFamily: ConstFont.montMedium,
),
const SizedBox(height: 5),
Row(children: [NyTagWidget(text: tag)]),
const SizedBox(height: 8),
],
),
),
);
}
Widget _otherVideoView() {
int _count = dramaRanks.length - 1;
if (_count > 3) _count = 3;
var count = dramaRanks.length - 1;
if (count > 3) count = 3;
return Column(
children: List.generate(_count, (index) {
NyDramaModel _item = dramaRanks[index + 1];
String _tag = "";
if (_item.categoryList!=null && _item.categoryList!.isNotEmpty) {
_tag = _item.categoryList!.first.name;
children: List.generate(count, (index) {
final item = dramaRanks[index + 1];
var tag = '';
if (item.categoryList != null && item.categoryList!.isNotEmpty) {
tag = item.categoryList!.first.name;
}
return Container(
margin: const EdgeInsets.only(bottom: 8),
width: 160.w, height: 93.w,
child: Row(
children: [
HelpImage.network(_item.imageUrl, width: 62.w, height: 93.w),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_colorsText(index + 2),
const SizedBox(height: 8),
NovyText(_item.name, fontSize: 13,
color: Colors.black,
),
const SizedBox(height: 5),
_tag.isNotEmpty ? NyTagWidget(text: _tag) : SizedBox()
],
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => AppNav.to(VideoDetailPage(drama: item)),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
width: 160.w,
height: 93.w,
child: Row(
children: [
HelpImage.network(item.imageUrl, width: 62.w, height: 93.w),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_colorsText(index + 2),
const SizedBox(height: 8),
NovyText(item.name, fontSize: 13, color: Colors.black),
const SizedBox(height: 5),
tag.isNotEmpty
? NyTagWidget(text: tag)
: const SizedBox(),
],
),
),
)
],
],
),
),
);
})
}),
);
}
_colorsText(int index) {
Widget _colorsText(int index) {
if (index == 4) {
return Text(
'$index',
@ -160,7 +165,7 @@ class HomeRankWidget extends StatelessWidget {
child: Text(
'$index',
style: TextStyle(
fontSize: index==1 ? 40: 20,
fontSize: index == 1 ? 40 : 20,
fontWeight: FontWeight.bold,
color: Colors.white,
fontStyle: FontStyle.italic,
@ -168,4 +173,4 @@ class HomeRankWidget extends StatelessWidget {
),
);
}
}
}

View File

@ -1,15 +1,54 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/const/const_string.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/common/model/ny_episode_model.dart';
import 'package:novyronst/pages/first/player_detail/video_detail_page.dart';
import 'package:novyronst/pages/widgets/cut_corner_clipper.dart';
import 'package:novyronst/pages/widgets/ny_tag_widget.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
class HomeRemainingSections extends StatelessWidget {
const HomeRemainingSections({super.key});
import '../../../common/model/ny_detail_model.dart';
const int episodeSeriesPageSize = 6;
static const _ActorInfo _actor = _ActorInfo(
class HomeRemainingSections extends StatefulWidget {
const HomeRemainingSections({super.key, required this.dramaOne});
final NyDramaModel dramaOne;
@override
State<StatefulWidget> createState() {
return _HomeRemainingSectionsState();
}
}
class _HomeRemainingSectionsState extends State<HomeRemainingSections> {
NyDetailModel? _detailModel;
@override
void initState() {
super.initState();
_getDetailsData();
}
void _getDetailsData() {
VideoRequest.getVideoDetail(
shortPlayId: widget.dramaOne.shortPlayId ?? 0,
shortPlayVideoId: widget.dramaOne.shortPlayVideoId ?? 0,
).then((value) {
if (value != null) {
setState(() {
_detailModel = value;
});
}
});
}
final _ActorInfo _actor = _ActorInfo(
name: 'Cheng Zi',
tags: 'Romantic Lead | Revenge Drama | Lead Actor',
bornYear: '2002',
@ -17,164 +56,32 @@ class HomeRemainingSections extends StatelessWidget {
nationality: 'China',
);
static const List<_DramaItem> _seriesItems = [
_DramaItem(
title: 'Cities Of Smoke Starlight',
category: 'Satisfying',
palette: [Color(0xFF33241D), Color(0xFFD8A65B)],
),
_DramaItem(
title: 'Cities Of Smoke Starlight',
category: 'Satisfying',
palette: [Color(0xFF25354C), Color(0xFFC88B57)],
),
_DramaItem(
title: 'Cities Of Smoke Starlight',
category: 'Satisfying',
palette: [Color(0xFF3E2B2C), Color(0xFFE2B36B)],
),
_DramaItem(
title: 'Cities Of Smoke Starlight',
category: 'Satisfying',
palette: [Color(0xFF443A2C), Color(0xFFC48645)],
),
_DramaItem(
title: 'Cities Of Smoke Starlight',
category: 'Satisfying',
palette: [Color(0xFF314559), Color(0xFFE5AA65)],
),
_DramaItem(
title: 'Cities Of Smoke Starlight',
category: 'Satisfying',
palette: [Color(0xFF2C2637), Color(0xFFD39458)],
),
];
static const List<String> _categoryTitles = [
'Counterattack Life',
'Reborn And Returned',
'God Of War Returns',
];
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 92),
child: Column(
padding: const EdgeInsets.fromLTRB(16, 3, 16, 16),
child: Stack(
children: [
const SizedBox(height: 10),
const _ActorSection(actor: _actor, items: _seriesItems),
const SizedBox(height: 18),
..._categoryTitles.map((title) {
return Padding(
padding: const EdgeInsets.only(bottom: 18),
child: _DramaShelfSection(title: title, items: _seriesItems),
);
}),
Container(
margin: const EdgeInsets.only(top: 12),
child: _ActorSection(actor: _actor, detailModel: _detailModel),
),
Positioned(
left: -10,
top: 0,
child: HelpImage.asset(ConstImage.homeEyes, width: 72),
),
],
),
);
}
}
class _TopRankingPoster extends StatelessWidget {
const _TopRankingPoster({required this.item});
final _DramaItem item;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
children: [
Positioned.fill(
child: _PosterBlock(item: item, showLargeTitle: true),
),
Positioned(
top: 4,
left: 5,
child: NovyText(
'1',
color: ConstColor.btGreen,
fontSize: 36,
fontFamily: ConstFont.montBlack,
shadow: const Shadow(
offset: Offset(1.5, 1.5),
color: Colors.black,
),
),
),
],
),
),
const SizedBox(height: 8),
NovyText(
item.title,
color: Colors.black,
fontSize: 14,
fontFamily: ConstFont.montMedium,
),
const SizedBox(height: 5),
NyTagWidget(text: item.category),
],
);
}
}
class _RankingListItem extends StatelessWidget {
const _RankingListItem({required this.rank, required this.item});
final int rank;
final _DramaItem item;
@override
Widget build(BuildContext context) {
final Color rankColor = rank == 4
? const Color(0xFFBBBBBB)
: ConstColor.btGreen;
return Row(
children: [
SizedBox(width: 42, height: 64, child: _PosterBlock(item: item)),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
NovyText(
'$rank',
color: rankColor,
fontSize: 19,
fontFamily: ConstFont.montBlack,
lineHeight: 1,
),
const SizedBox(height: 3),
NovyText(
item.title,
color: Colors.black,
fontSize: 11,
fontFamily: ConstFont.montBold,
maxLines: 1,
),
const SizedBox(height: 4),
NyTagWidget(text: item.category),
],
),
),
],
);
}
}
class _ActorSection extends StatelessWidget {
const _ActorSection({required this.actor, required this.items});
const _ActorSection({required this.actor, required this.detailModel});
final _ActorInfo actor;
final List<_DramaItem> items;
final NyDetailModel? detailModel;
@override
Widget build(BuildContext context) {
@ -184,26 +91,43 @@ class _ActorSection extends StatelessWidget {
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(10, 18, 10, 10),
padding: const EdgeInsets.fromLTRB(10, 16, 10, 10),
child: Column(
children: [
const _CornerEyes(),
const SizedBox(height: 4),
_OutlinedTitle('Read This Series Of Short Plays', fontSize: 19),
// const SizedBox(height: 10),
Align(
alignment: Alignment.topRight,
child: OutlinedTitle(
'Read This Series Of Short Plays',
fontSize: 17,
),
),
const SizedBox(height: 26),
const _ActorAvatar(),
Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFA6D9), Color(0xFFFFE6F5)],
),
),
child: HelpImage.asset(ConstImage.homeHead, width: 100),
),
const SizedBox(height: 10),
NovyText(
actor.name,
color: Colors.black,
fontSize: 22,
fontSize: 18,
fontFamily: ConstFont.montBlack,
),
const SizedBox(height: 10),
NovyText(
actor.tags,
color: const Color(0xFF888888),
fontSize: 13,
fontSize: 12,
fontFamily: ConstFont.montRegular,
),
const SizedBox(height: 12),
@ -211,9 +135,7 @@ class _ActorSection extends StatelessWidget {
const SizedBox(height: 8),
_SectionHeader(title: 'Series'),
const SizedBox(height: 8),
_SeriesGrid(items: items, showTags: false),
const SizedBox(height: 12),
const _PagerDots(),
_SeriesGrid(detailModel: detailModel),
],
),
),
@ -223,147 +145,144 @@ class _ActorSection extends StatelessWidget {
}
}
class _DramaShelfSection extends StatelessWidget {
const _DramaShelfSection({required this.title, required this.items});
class _SeriesGrid extends StatefulWidget {
const _SeriesGrid({required this.detailModel});
final String title;
final List<_DramaItem> items;
final NyDetailModel? detailModel;
@override
Widget build(BuildContext context) {
return CutCornerClipper(
cutSize: 40,
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 14, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _GripBars(),
const SizedBox(height: 8),
_OutlinedTitle(title, fontSize: 19),
const SizedBox(height: 12),
Row(
children: List.generate(3, (index) {
return Expanded(
child: Padding(
padding: EdgeInsets.only(right: index == 2 ? 0 : 8),
child: _SeriesCard(item: items[index], showTag: true),
),
);
}),
),
const SizedBox(height: 8),
const _GreenActionButton(text: 'Click to watch'),
],
),
),
);
}
State<_SeriesGrid> createState() => _SeriesGridState();
}
class _SeriesGrid extends StatelessWidget {
const _SeriesGrid({required this.items, required this.showTags});
class _SeriesGridState extends State<_SeriesGrid> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<_DramaItem> items;
final bool showTags;
List<NyEpisodeModel> get _items => widget.detailModel?.episodeList ?? [];
String get _coverUrl {
final shortPlayImage = widget.detailModel?.shortPlayInfo?.imageUrl ?? '';
if (shortPlayImage.isNotEmpty) return shortPlayImage;
return widget.detailModel?.videoInfo?.imageUrl ?? '';
}
@override
void didUpdateWidget(covariant _SeriesGrid oldWidget) {
super.didUpdateWidget(oldWidget);
final pageCount = episodeSeriesPageCount(_items.length);
if (_currentPage >= pageCount) {
_currentPage = pageCount - 1;
if (_pageController.hasClients) {
_pageController.jumpToPage(_currentPage);
}
}
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GridView.builder(
padding: EdgeInsets.zero,
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 9,
mainAxisSpacing: 10,
childAspectRatio: 0.47,
),
itemBuilder: (context, index) {
return _SeriesCard(item: items[index], showTag: showTags);
final pageCount = episodeSeriesPageCount(_items.length);
return LayoutBuilder(
builder: (context, constraints) {
final gridHeight = 200.0 * 2;
return Column(
children: [
SizedBox(
height: gridHeight,
child: PageView.builder(
controller: _pageController,
itemCount: pageCount,
onPageChanged: (index) {
setState(() {
_currentPage = index;
});
},
itemBuilder: (context, pageIndex) {
final pageItems = episodeSeriesPageItems(_items, pageIndex);
return GridView.builder(
padding: EdgeInsets.zero,
itemCount: pageItems.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 9,
mainAxisSpacing: 10,
childAspectRatio: 104 / 190,
),
itemBuilder: (context, index) {
final item = pageItems[index];
return _SeriesCard(
item: item,
coverUrl: _coverUrl,
onTap: () => _openDetail(item),
);
},
);
},
),
),
const SizedBox(height: 12),
_PagerDots(count: pageCount, currentIndex: _currentPage),
],
);
},
);
}
void _openDetail(NyEpisodeModel item) {
final detail = widget.detailModel;
if (detail == null) return;
final videos = buildEpisodeDramaList(detail);
if (videos.isEmpty) return;
final selected = videos.firstWhere(
(video) => video.shortPlayVideoId == item.shortPlayVideoId,
orElse: () => videos.first,
);
AppNav.to(VideoDetailPage(drama: selected));
}
}
class _SeriesCard extends StatelessWidget {
const _SeriesCard({required this.item, required this.showTag});
const _SeriesCard({
required this.item,
required this.coverUrl,
required this.onTap,
});
final _DramaItem item;
final bool showTag;
final NyEpisodeModel item;
final String coverUrl;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 0.68,
child: _PosterBlock(item: item, showLargeTitle: true),
),
const SizedBox(height: 7),
NovyText(
item.title,
color: Colors.black,
fontSize: 14,
lineHeight: 1.05,
fontFamily: ConstFont.montMedium,
maxLines: 2,
),
if (showTag) ...[
const SizedBox(height: 7),
NyTagWidget(text: item.category),
],
],
);
}
}
class _PosterBlock extends StatelessWidget {
const _PosterBlock({required this.item, this.showLargeTitle = false});
final _DramaItem item;
final bool showLargeTitle;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: item.palette,
),
),
child: Stack(
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Positioned(
top: 12,
left: 10,
right: 10,
child: Container(
height: showLargeTitle ? 74 : 28,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(80),
),
),
AspectRatio(
aspectRatio: 0.68,
child: HelpImage.network(coverUrl, fit: BoxFit.cover),
),
Positioned(
left: 7,
right: 7,
bottom: 10,
child: NovyText(
showLargeTitle ? 'CITIES\nOF SMOKE\nSTARLIGHT' : item.title,
color: Colors.white,
fontSize: showLargeTitle ? 17 : 8,
lineHeight: 0.95,
textAlign: TextAlign.center,
fontFamily: ConstFont.montBlack,
maxLines: showLargeTitle ? 3 : 2,
shadow: const Shadow(offset: Offset(1, 1), color: Colors.black54),
),
const SizedBox(height: 7),
NovyText(
'Episode ${item.episode}',
color: Colors.black,
fontSize: 14,
lineHeight: 1.05,
fontFamily: ConstFont.montMedium,
),
],
),
@ -371,37 +290,6 @@ class _PosterBlock extends StatelessWidget {
}
}
class _ActorAvatar extends StatelessWidget {
const _ActorAvatar();
@override
Widget build(BuildContext context) {
return Container(
width: 116,
height: 116,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFA6D9), Color(0xFFFFE6F5)],
),
),
child: Center(
child: Container(
width: 58,
height: 58,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(18),
),
child: const Icon(Icons.person, size: 44, color: Color(0xFF8E2A55)),
),
),
);
}
}
class _ActorStats extends StatelessWidget {
const _ActorStats({required this.actor});
@ -429,14 +317,14 @@ class _ActorStats extends StatelessWidget {
NovyText(
stats[index][0],
color: Colors.black,
fontSize: 18,
fontSize: 16,
fontFamily: ConstFont.montMedium,
),
const SizedBox(height: 7),
NovyText(
stats[index][1],
color: const Color(0xFF777777),
fontSize: 13,
fontSize: 12,
fontFamily: ConstFont.montRegular,
),
],
@ -467,8 +355,8 @@ class _SectionHeader extends StatelessWidget {
}
}
class _OutlinedTitle extends StatelessWidget {
const _OutlinedTitle(this.text, {required this.fontSize});
class OutlinedTitle extends StatelessWidget {
const OutlinedTitle(this.text, {super.key, required this.fontSize});
final String text;
final double fontSize;
@ -505,151 +393,29 @@ class _OutlinedTitle extends StatelessWidget {
}
}
class _GreenActionButton extends StatelessWidget {
const _GreenActionButton({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: const EqualCutCornerClipper(
cutSize: 18,
corner: CutCorner.bottomLeft,
),
child: Container(
height: 34,
decoration: BoxDecoration(
color: ConstColor.btGreen,
border: Border.all(color: Colors.black, width: 2),
),
child: Stack(
children: [
const Positioned(left: 22, top: 6, child: _GripBars()),
const Positioned(right: 10, bottom: 4, child: _GripBars()),
Center(
child: NovyText(
text,
color: Colors.black,
fontSize: 14,
fontFamily: ConstFont.montBlack,
),
),
],
),
),
);
}
}
class _CornerEyes extends StatelessWidget {
const _CornerEyes();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 18,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: -3,
top: -38,
child: Transform.rotate(
angle: 0.68,
child: Container(
width: 34,
height: 84,
color: ConstColor.btGreen,
),
),
),
const Positioned(left: 18, top: -12, child: _EyeDot()),
const Positioned(left: 38, top: -18, child: _EyeDot()),
],
),
);
}
}
class _EyeDot extends StatelessWidget {
const _EyeDot();
@override
Widget build(BuildContext context) {
return Container(
width: 22,
height: 26,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Align(
alignment: Alignment.topRight,
child: Container(
width: 10,
height: 10,
margin: const EdgeInsets.only(top: 2, right: 2),
decoration: const BoxDecoration(
color: Colors.black,
shape: BoxShape.circle,
),
),
),
);
}
}
class _PagerDots extends StatelessWidget {
const _PagerDots();
const _PagerDots({required this.count, required this.currentIndex});
final int count;
final int currentIndex;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(7, (index) {
children: List.generate(count, (index) {
final active = index == currentIndex;
return Container(
width: index == 1 ? 26 : 7,
width: active ? 26 : 7,
height: 7,
margin: const EdgeInsets.symmetric(horizontal: 3),
color: index == 1 ? ConstColor.btGreen : const Color(0xFFD9D9D9),
color: active ? ConstColor.btGreen : const Color(0xFFD9D9D9),
);
}),
);
}
}
class _GripBars extends StatelessWidget {
const _GripBars();
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(4, (index) {
return Container(
width: 3,
height: 8,
margin: const EdgeInsets.only(right: 2),
color: Colors.black,
);
}),
);
}
}
class _DramaItem {
const _DramaItem({
required this.title,
required this.category,
required this.palette,
});
final String title;
final String category;
final List<Color> palette;
}
class _ActorInfo {
const _ActorInfo({
required this.name,
@ -665,3 +431,18 @@ class _ActorInfo {
final String height;
final String nationality;
}
int episodeSeriesPageCount(int itemCount) {
if (itemCount <= 0) return 1;
return (itemCount / episodeSeriesPageSize).ceil();
}
List<NyEpisodeModel> episodeSeriesPageItems(
List<NyEpisodeModel> items,
int pageIndex,
) {
final start = pageIndex * episodeSeriesPageSize;
if (start >= items.length) return [];
final end = (start + episodeSeriesPageSize).clamp(0, items.length);
return items.sublist(start, end);
}

View File

@ -1,18 +1,20 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/root/lib_export.dart';
import 'package:novyronst/tools/widgets/extend_widget.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
class HomeSearchView extends StatefulWidget {
const HomeSearchView({super.key,
const HomeSearchView({
super.key,
this.onlyTap = false,
this.onTap,
this.showLogo = true,
});
final bool onlyTap;
final Function()? onTap;
final bool showLogo;
@override
State<StatefulWidget> createState() {
@ -21,66 +23,92 @@ class HomeSearchView extends StatefulWidget {
}
class _SearchViewState extends State<HomeSearchView> {
final _searchController = TextEditingController();
@override
Widget build(BuildContext context) {
return Container(
height: AppScreen.navBarHeight,
padding: EdgeInsets.only(top: AppScreen.statusBarHeight,
left: 16, right: 16,
bottom: 5
padding: EdgeInsets.only(
top: AppScreen.statusBarHeight,
left: 16,
right: 16,
bottom: 5,
),
child: SizedBox(
height: 36,
child: Row(
children: [
HelpImage.asset(ConstImage.homeTopLogo, width: 36),
const SizedBox(width: 16),
if (widget.showLogo) ...[
HelpImage.asset(ConstImage.homeTopLogo, width: 36),
const SizedBox(width: 16),
],
Expanded(
child: Stack(
children: [
HelpImage.asset(ConstImage.searchBg, height: 36, fit: BoxFit.fill),
Padding(
padding: EdgeInsets.only(left: 11),
child: Row(
children: [
Icon(Icons.search, color: Colors.white, size: 24),
Expanded(child:widget.onlyTap
? NovyText('What are...... ',
fontSize: 13,
color: Colors.white.withAlpha(127),
)
: CupertinoTextField(
controller: _searchController,
// focusNode: widget.focusNode,
decoration: BoxDecoration(),
placeholder: 'What are...... ',
placeholderStyle: TextStyle(
fontSize: 13,
color: Colors.white.withAlpha(127),
),
style: TextStyle(
fontSize: 14,
color: Colors.white,
),
textInputAction: TextInputAction.search,
onEditingComplete: () {
},
),),
HelpImage.asset(ConstImage.homeSearch, width: 78, height: 36, fit: BoxFit.fill)
],
child: SizedBox(
height: 36,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: HelpImage.asset(
ConstImage.searchBg,
height: 36,
fit: BoxFit.fill,
),
),
)
],
Padding(
padding: const EdgeInsets.only(left: 11, right: 78),
child: Row(
children: [
const Icon(
Icons.search,
color: Colors.white,
size: 24,
),
Expanded(
child: widget.onlyTap
? NovyText(
'What are...... ',
fontSize: 13,
color: Colors.white.withAlpha(127),
)
: CupertinoTextField(
controller: _searchController,
// focusNode: widget.focusNode,
decoration: const BoxDecoration(),
placeholder: 'What are...... ',
placeholderStyle: TextStyle(
fontSize: 13,
color: Colors.white.withAlpha(127),
),
style: TextStyle(
fontSize: 14,
color: Colors.white,
),
textInputAction: TextInputAction.search,
onEditingComplete: () {},
),
),
],
),
),
Positioned(
top: 0,
right: 0,
bottom: 0,
child: HelpImage.asset(
ConstImage.homeSearch,
width: 78,
height: 36,
fit: BoxFit.fill,
),
),
],
),
),
)
),
],
),
),
@ -88,11 +116,10 @@ class _SearchViewState extends State<HomeSearchView> {
}
}
///
class SeSearchView extends StatefulWidget {
const SeSearchView({super.key,
const SeSearchView({
super.key,
this.onClickSearch,
required this.controller,
this.focusNode,
@ -109,62 +136,77 @@ class SeSearchView extends StatefulWidget {
}
class _SeSearchViewState extends State<SeSearchView> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
height: 44,
padding: EdgeInsets.only(top: 4, bottom: 4),
child: Stack(
children: [
HelpImage.asset(ConstImage.searchBg, height: 36, fit: BoxFit.fill),
Padding(
padding: EdgeInsets.only(left: 11),
child: Row(
children: [
Icon(Icons.search, color: Colors.white, size: 24),
Expanded(child: CupertinoTextField(
controller: widget.controller,
focusNode: widget.focusNode,
decoration: BoxDecoration(),
placeholder: 'What are...... ',
placeholderStyle: TextStyle(
fontSize: 13,
color: Colors.white.withAlpha(127),
),
style: TextStyle(
fontSize: 14,
color: Colors.white,
),
textInputAction: TextInputAction.search,
onEditingComplete: () {
if (widget.controller.text.trim().isNotEmpty) {
widget.onClickSearch?.call(widget.controller.text);
}
},
),),
HelpImage.asset(ConstImage.homeSearch,
width: 78, height: 36, fit: BoxFit.fill
).addInkWell(onTap: () {
if (widget.controller.text.trim().isNotEmpty) {
widget.onClickSearch?.call(widget.controller.text);
}
})
],
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: SizedBox(
height: 36,
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: HelpImage.asset(
ConstImage.searchBg,
height: 36,
fit: BoxFit.fill,
),
),
)
],
)
Padding(
padding: const EdgeInsets.only(left: 11, right: 78),
child: Row(
children: [
const Icon(Icons.search, color: Colors.white, size: 24),
Expanded(
child: CupertinoTextField(
controller: widget.controller,
focusNode: widget.focusNode,
decoration: const BoxDecoration(),
placeholder: 'What are...... ',
placeholderStyle: TextStyle(
fontSize: 13,
color: Colors.white.withAlpha(127),
),
style: TextStyle(fontSize: 14, color: Colors.white),
textInputAction: TextInputAction.search,
onEditingComplete: () {
if (widget.controller.text.trim().isNotEmpty) {
widget.onClickSearch?.call(widget.controller.text);
}
},
),
),
],
),
),
Positioned(
top: 0,
right: 0,
bottom: 0,
child:
HelpImage.asset(
ConstImage.homeSearch,
width: 78,
height: 36,
fit: BoxFit.fill,
).addInkWell(
onTap: () {
if (widget.controller.text.trim().isNotEmpty) {
widget.onClickSearch?.call(widget.controller.text);
}
},
),
),
],
),
),
);
}
}
}

View File

@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/api_string.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/pages/my/pages/mine_link_page.dart';
import 'package:novyronst/pages/widgets/ny_loading_widget.dart';
import 'package:novyronst/root/lib_export.dart';
import 'package:novyronst/tools/util/ny_device_info.dart';
import 'package:novyronst/tools/widgets/extend_widget.dart';
import 'package:novyronst/tools/widgets/novy_app_bar.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
import 'package:url_launcher/url_launcher.dart';
class AboutUsPage extends StatelessWidget {
const AboutUsPage({super.key});
@override
Widget build(BuildContext context) {
// return NyLoadingWidget();
return Scaffold(
backgroundColor: ConstColor.withValue('#110D08'),
appBar: NovyAppBar(
title: 'About',
titleColor: Colors.white,
),
body: SizedBox(
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 50),
SizedBox(
width: 80, height: 80,
child: HelpImage.asset(ConstImage.appLogo),
),
const SizedBox(height: 10),
NovyText( ApiString.appName,
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
const SizedBox(height: 5),
NovyText( 'V ${NyDeviceInfo.instance.appVersion}',
fontSize: 12,
color: Colors.grey,
),
const SizedBox(height: 20),
_buildRowItem('Privacy Policy', () {
AppNav.to(MineLinkPage(
title: 'Privacy Policy',
urlString: ApiString.webPrivacy,
));
}),
_buildRowItem('User Agreement', () {
AppNav.to(MineLinkPage(
title: 'User Agreement',
urlString: ApiString.webAgreement,
));
}),
_buildRowItem('Visit Website', () async {
final encodedUrl = Uri.encodeFull(ApiString.webIndex);
try {
final bool launched = await launchUrl(
Uri.parse(encodedUrl),
mode: LaunchMode.externalApplication,
);
if (!launched) {
print('无法打开链接: $encodedUrl');
}
} catch (e) {
print('打开链接异常: $e');
}
}),
],
),
),
);
}
_buildRowItem(String title, Function() onTap) {
return Container(
height: 46,
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
margin: const EdgeInsets.only(bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
NovyText( title, fontSize: 14, color: Colors.white),
Icon(Icons.arrow_forward_ios, color: ConstColor.btWhite, size: 18,),
],
),
).addInkWell(onTap: onTap);
}
Future<void> openUrl(String url) async {
final encodedUrl = Uri.encodeFull(url);
try {
final bool launched = await launchUrl(
Uri.parse(encodedUrl),
mode: LaunchMode.externalApplication,
);
if (!launched) {
print('无法打开链接: $encodedUrl');
}
} catch (e) {
print('打开链接异常: $e');
}
}
}

View File

@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/collect/pages/collect_page_helpers.dart';
import 'package:novyronst/pages/widgets/ny_empty_view.dart';
import 'package:novyronst/pages/widgets/ny_loading_widget.dart';
import 'package:novyronst/tools/widgets/novy_app_bar.dart';
import '../../../common/const/const_color.dart';
class MineHistoryPage extends StatefulWidget {
const MineHistoryPage({super.key});
@override
State<MineHistoryPage> createState() => _HistoryPageState();
}
class _HistoryPageState extends State<MineHistoryPage> {
List<NyDramaModel> _itemsHis = [];
bool _isLoading = false;
bool _isEditing = false;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
setState(() {
_isLoading = true;
});
final fetch = VideoRequest.getHistory;
final items = await fetch(page: 1, pageSize: 20);
if (!mounted) return;
setState(() {
_itemsHis
..clear()
..addAll(items);
_isLoading = false;
});
}
void _deleteHistory(int index) {
if (index < 0 || index >= _itemsHis.length) return;
setState(() {
_itemsHis.removeAt(index);
});
}
@override
Widget build(BuildContext context) {
if (_isLoading && _itemsHis.isEmpty) {
return const NyLoadingWidget();
}
if (_itemsHis.isEmpty) {
return const NyEmptyView();
}
return Scaffold(
backgroundColor: ConstColor.withValue('#110D08'),
appBar: NovyAppBar(
title: 'History',
titleColor: Colors.white,
actions: [
IconButton(
onPressed: () {
setState(() {
_isEditing = !_isEditing;
});
},
icon: Icon(
_isEditing ? Icons.done : Icons.edit_outlined,
color: Colors.white,
size: 22,
),
)
],
),
body: ListView.separated(
padding: const EdgeInsets.fromLTRB(30, 8, 20, 96),
itemCount: _itemsHis.length,
separatorBuilder: (context, index) => const SizedBox(height: 13),
itemBuilder: (context, index) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 30,
child: _HistoryLeadingButton(
isEditing: _isEditing,
onDelete: () => _deleteHistory(index),
),
),
const SizedBox(width: 18),
Expanded(child: _HistoryItem(item: _itemsHis[index])),
],
);
},
),
);
}
}
class _HistoryLeadingButton extends StatelessWidget {
const _HistoryLeadingButton({
required this.isEditing,
required this.onDelete,
});
final bool isEditing;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
if (isEditing) {
return IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 30, height: 40),
onPressed: onDelete,
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 28),
);
}
return Icon(
Icons.bookmark_border,
color: Colors.white.withValues(alpha: 0.28),
size: 32,
);
}
}
class _HistoryItem extends StatelessWidget {
const _HistoryItem({required this.item});
final NyDramaModel item;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 68,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: Image.network(
item.imageUrl,
width: 51,
height: 68,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return Container(
width: 51,
height: 68,
color: Colors.white.withValues(alpha: 0.12),
);
},
),
),
const SizedBox(width: 15),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
height: 1.22,
fontFamily: 'Montserrat-Bold',
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
dramaEpisodeText(item),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 12,
fontFamily: 'Montserrat-Medium',
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,298 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/pages/widgets/ny_loading_widget.dart';
import 'package:novyronst/tools/widgets/novy_app_bar.dart';
import 'package:webview_flutter/webview_flutter.dart';
/// WebView
class MineLinkPage extends StatefulWidget {
final String urlString;
final String? title;
final bool showAppbar;
const MineLinkPage({
super.key,
required this.urlString,
this.title,
this.showAppbar = true,
});
@override
State<MineLinkPage> createState() => _WebLinkViewState();
}
class _WebLinkViewState extends State<MineLinkPage> {
late WebViewController _webViewController;
int _loadingProgress = 0;
bool _isLoading = true;
bool _hasError = false;
String _errorMessage = '';
String _urlString = '';
@override
void initState() {
super.initState();
_initializeParameters();
_initializeWebView();
_loadUrl();
}
void _initializeParameters() {
_urlString = _validateAndFixUrl(widget.urlString);
}
void _initializeWebView() {
_webViewController = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(const Color(0x00FF0000))
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
setState(() {
_loadingProgress = progress;
});
},
onPageStarted: (String url) {
setState(() {
_isLoading = true;
_hasError = false;
_loadingProgress = 0;
});
},
onPageFinished: (String url) async {
setState(() {
_isLoading = false;
_loadingProgress = 100;
});
//
await _webViewController.runJavaScript('''
document.body.style.background = 'none';
document.body.style.color = '#fff';
//
var titleElements = document.getElementsByClassName('title');
for (var i = 0; i < titleElements.length; i++) {
titleElements[i].style.display = 'none';
}
//
var bgElements = document.getElementsByClassName('bg-fff');
for (var i = 0; i < bgElements.length; i++) {
bgElements[i].style.background = 'none';
bgElements[i].style.paddingTop = '0px';
}
//
var allElements = document.getElementsByTagName('*');
for (var i = 0; i < allElements.length; i++) {
allElements[i].style.color = '#fff';
}
//
var links = document.getElementsByTagName('a');
for (var i = 0; i < links.length; i++) {
links[i].style.color = '#87CEEB';
}
''');
},
onHttpError: (HttpResponseError error) {
setState(() {
_hasError = true;
_errorMessage = 'HTTP Error: ${error.response?.statusCode ?? "Unknown"}';
_isLoading = false;
});
},
onWebResourceError: (WebResourceError error) {
setState(() {
_hasError = true;
_errorMessage = error.description;
_isLoading = false;
});
},
onNavigationRequest: (NavigationRequest request) {
return NavigationDecision.navigate;
},
),
);
}
void _loadUrl() {
if (_urlString.isEmpty) {
setState(() {
_hasError = true;
_errorMessage = 'Invalid URL';
_isLoading = false;
});
return;
}
try {
final uri = Uri.parse(_urlString);
_webViewController.loadRequest(uri);
} catch (e) {
setState(() {
_hasError = true;
_errorMessage = 'Failed to load URL: $e';
_isLoading = false;
});
}
}
String _validateAndFixUrl(String url) {
if (url.isEmpty) {
return '';
}
url = url.trim();
// Already has a valid scheme
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Looks like a domain (has a dot and no spaces)
if (url.contains('.') && !url.contains(' ')) {
return 'https://$url';
}
// Treat as search query
return 'https://www.google.com/search?q=${Uri.encodeComponent(url)}';
}
void _reload() {
setState(() {
_hasError = false;
_isLoading = true;
_loadingProgress = 0;
_errorMessage = '';
});
_initializeWebView();
_loadUrl();
}
void _goBack() {
_webViewController.goBack();
}
void _goForward() {
_webViewController.goForward();
}
@override
void dispose() {
_webViewController.clearCache();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: widget.showAppbar
? _buildAppBar()
: null,
body: _buildBody(),
);
}
PreferredSizeWidget _buildAppBar() {
return NovyAppBar(
backgroundColor: Colors.transparent,
title: widget.title,
titleColor: Colors.white,
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white,),
onPressed: _reload,
),
],
);
}
Widget _buildBody() {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
ConstColor.black,
Color(0xFF2D1B4E),
],
),
),
child: SafeArea(
child: Column(
children: [
//
if (_isLoading)
LinearProgressIndicator(
value: _loadingProgress / 100,
backgroundColor: Colors.white.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
ConstColor.btGreen,
),
),
Expanded(
child: _buildContent(),
),
],
),
),
);
}
Widget _buildContent() {
if (_isLoading) {
return Center(
child: NyLoadingWidget(),
);
}
if (_hasError) {
return _buildErrorWidget();
}
return WebViewWidget(
controller: _webViewController,
);
}
Widget _buildErrorWidget() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64.w,
color: Colors.white.withOpacity(0.6),
),
SizedBox(height: 16.h),
Text(
_errorMessage,
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontSize: 16.sp,
),
textAlign: TextAlign.center,
),
SizedBox(height: 24.h),
ElevatedButton(
onPressed: _reload,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFB98E0),
foregroundColor: Colors.white,
),
child: const Text('重试'),
),
],
),
);
}
}

View File

@ -1,8 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:novyronst/common/api/api_string.dart';
import 'package:novyronst/common/api/user_request.dart';
import 'package:novyronst/common/help/app_nav.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/common/model/novyronst_user.dart';
import 'package:novyronst/pages/my/pages/about_page.dart';
import 'package:novyronst/pages/my/pages/mine_history_page.dart';
import 'package:novyronst/pages/my/pages/mine_link_page.dart';
import 'package:novyronst/pages/my/widgets/ny_dash_line.dart';
import 'package:novyronst/root/lib_export.dart';
import 'package:novyronst/tools/widgets/app_bg_page.dart';
import 'package:novyronst/tools/widgets/app_toast_util.dart';
import 'package:novyronst/tools/widgets/extend_widget.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
@ -17,9 +26,25 @@ class MyPage extends StatefulWidget {
class _RootPageState extends State<MyPage> {
List<String> _titles = [
final List<String> _titles = [
'History', 'Settings', 'About', 'Privacy Policy', 'User Agreement'
];
NovyronstUser? _userInfo;
@override
void initState() {
super.initState();
_getUserInfo();
}
void _getUserInfo() {
UserRequest.getUserInfo().then((value) {
setState(() {
_userInfo = value;
});
});
}
@override
Widget build(BuildContext context) {
@ -35,15 +60,21 @@ class _RootPageState extends State<MyPage> {
...List.generate(_titles.length, (index) {
return _buildSectionView(_titles[index], index, onTap: () {
if (index == 0) {
AppNav.to(MineHistoryPage());
} else if (index == 1) {
} else if (index == 2) {
AppNav.to(AboutUsPage());
} else if (index == 3) {
AppNav.to(MineLinkPage(urlString: ApiString.webPrivacy,
title: 'Privacy Policy',
));
} else if (index == 4) {
AppNav.to(MineLinkPage(urlString: ApiString.webAgreement,
title: 'User Agreement',
));
}
});
})
@ -91,6 +122,16 @@ class _RootPageState extends State<MyPage> {
}
Widget _buildUserInfo() {
String _name = 'Visitor';
String _customerId = '';
if (_userInfo != null) {
_name = _userInfo!.givingName!.isEmpty || _userInfo!.givingName==''
? 'Visitor'
: _userInfo!.givingName!;
_customerId = _userInfo!.customerId;
}
return Row(
children: [
Container(
@ -108,7 +149,7 @@ class _RootPageState extends State<MyPage> {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
NovyText('Visitor',
NovyText(_name,
fontSize: 18,
),
const SizedBox(height: 6),
@ -121,14 +162,19 @@ class _RootPageState extends State<MyPage> {
),
child: Row(
children: [
NovyText('ID: 3242545454',
NovyText('ID: $_customerId',
fontSize: 11,
),
const SizedBox(width: 6),
HelpImage.asset(ConstImage.copy, width: 12, height: 12)
],
),
)
).addInkWell(onTap: () async {
// copy
if (_userInfo!.customerId.isEmpty) return;
await Clipboard.setData(ClipboardData(text: _userInfo!.customerId));
AppToastUtil.showSuccess('ID is Copied!');
}),
],
)
],

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:novyronst/common/api/video_request.dart';
import 'package:novyronst/common/model/ny_drama_model.dart';
import 'package:novyronst/pages/play/widgets/player_view.dart';
import 'package:novyronst/pages/widgets/ny_loading_widget.dart';
class PlayPage extends StatefulWidget {
const PlayPage({super.key, this.isActive = true});
@ -69,33 +70,26 @@ class _RootPageState extends State<PlayPage> {
if (index < 0 || index >= _videoList.length) return;
final item = _videoList[index];
final videoId = item.shortPlayVideoId ?? item.id;
if (videoId == null) return;
final _info = item.videoInfo ?? {};
final videoId = _info['short_play_video_id'];
if (videoId == null) {
return;
}
final shortPlayId = _info['short_play_id'];
if (shortPlayId == null) {
return;
}
final shortPlayId = item.shortPlayId ?? item.shortId;
final oldIsCollect = item.isCollect ?? false;
final oldCollectTotal = item.collectTotal;
final newIsCollect = !oldIsCollect;
final newCollectTotal = oldCollectTotal + (newIsCollect ? 1 : -1);
final success = item.isCollect == false
? await VideoRequest.collect(shortPlayId: shortPlayId ?? 0, videoId: videoId)
: await VideoRequest.cancelCollect(shortPlayId: shortPlayId ?? 0);
if (!mounted) return;
setState(() {
_videoList[index] = item.copyWith(
isCollect: newIsCollect,
collectTotal: newCollectTotal < 0 ? 0 : newCollectTotal,
);
});
final success = newIsCollect
? await VideoRequest.collect(shortPlayId: shortPlayId, videoId: videoId)
: await VideoRequest.cancelCollect(shortPlayId: shortPlayId);
if (!mounted || success) return;
setState(() {
_videoList[index] = _videoList[index].copyWith(
isCollect: oldIsCollect,
collectTotal: oldCollectTotal,
);
_videoList[index] = item.copyWith(isCollect: !item.isCollect!);
// item.isCollect = !item.isCollect!;
});
}
@ -106,9 +100,7 @@ class _RootPageState extends State<PlayPage> {
Widget _buildBody() {
if (_isLoading && _videoList.isEmpty) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
return const NyLoadingWidget();
}
if (_videoList.isEmpty) {

View File

@ -13,6 +13,8 @@ class PlayerView extends StatefulWidget {
this.onCollectTap,
this.isLoadingMore = false,
this.isActive = true,
this.bottomMetaSpacing = 76,
this.progressHeaderBuilder,
});
final List<NyDramaModel> videos;
@ -21,6 +23,9 @@ class PlayerView extends StatefulWidget {
final ValueChanged<int>? onCollectTap;
final bool isLoadingMore;
final bool isActive;
final double bottomMetaSpacing;
final Widget Function(BuildContext context, NyDramaModel drama, int index)?
progressHeaderBuilder;
@override
State<PlayerView> createState() => _PlayerViewState();
@ -48,6 +53,13 @@ class _PlayerViewState extends State<PlayerView> {
super.didUpdateWidget(oldWidget);
if (widget.videos.isEmpty) return;
if (oldWidget.initialIndex != widget.initialIndex) {
_currentIndex = widget.initialIndex.clamp(0, widget.videos.length - 1);
if (_pageController.hasClients) {
_jumpToPageAfterBuild(_currentIndex);
}
}
if (_currentIndex >= widget.videos.length) {
_currentIndex = widget.videos.length - 1;
}
@ -187,12 +199,24 @@ class _PlayerViewState extends State<PlayerView> {
}
void _handlePageChanged(int index) {
if (index == _currentIndex) return;
_currentIndex = index;
widget.onPageChanged?.call(index);
_cachePlaybackWindow(index);
widget.isActive ? _playActiveVideo() : _pauseAllVideos();
}
void _jumpToPageAfterBuild(int index) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || widget.videos.isEmpty || !_pageController.hasClients) {
return;
}
final nextIndex = index.clamp(0, widget.videos.length - 1);
_pageController.jumpToPage(nextIndex);
});
}
void _togglePlayback() {
final controller = _controllers[_currentIndex];
if (!widget.isActive ||
@ -217,12 +241,15 @@ class _PlayerViewState extends State<PlayerView> {
itemBuilder: (context, index) {
return _PlayerPage(
drama: widget.videos[index],
index: index,
controller: _controllers[index],
isPageActive: widget.isActive,
isLoadingMore:
widget.isLoadingMore && index == widget.videos.length - 1,
bottomMetaSpacing: widget.bottomMetaSpacing,
onTap: _togglePlayback,
onCollectTap: () => widget.onCollectTap?.call(index),
progressHeaderBuilder: widget.progressHeaderBuilder,
);
},
);
@ -232,23 +259,33 @@ class _PlayerViewState extends State<PlayerView> {
class _PlayerPage extends StatelessWidget {
const _PlayerPage({
required this.drama,
required this.index,
required this.controller,
required this.isPageActive,
required this.isLoadingMore,
required this.bottomMetaSpacing,
required this.onTap,
required this.onCollectTap,
required this.progressHeaderBuilder,
});
final NyDramaModel drama;
final int index;
final VideoPlayerController? controller;
final bool isPageActive;
final bool isLoadingMore;
final double bottomMetaSpacing;
final VoidCallback onTap;
final VoidCallback onCollectTap;
final Widget Function(BuildContext context, NyDramaModel drama, int index)?
progressHeaderBuilder;
@override
Widget build(BuildContext context) {
final bottomPadding = MediaQuery.paddingOf(context).bottom + 86;
final metaBottom = resolvePlayerMetaBottom(
safeAreaBottom: MediaQuery.paddingOf(context).bottom,
bottomSpacing: bottomMetaSpacing,
);
return GestureDetector(
behavior: HitTestBehavior.opaque,
@ -263,14 +300,14 @@ class _PlayerPage extends StatelessWidget {
Positioned(
left: 16,
right: 16,
bottom: bottomPadding - 10,
child: _buildMeta(),
bottom: metaBottom,
child: _buildMeta(context),
),
if (isLoadingMore)
Positioned(
left: 0,
right: 0,
bottom: bottomPadding - 30,
bottom: metaBottom - 20,
child: const Center(
child: SizedBox(
width: 18,
@ -324,7 +361,7 @@ class _PlayerPage extends StatelessWidget {
);
}
Widget _buildMeta() {
Widget _buildMeta(BuildContext context) {
final isCollect = drama.isCollect ?? false;
return Column(
mainAxisSize: MainAxisSize.min,
@ -370,6 +407,10 @@ class _PlayerPage extends StatelessWidget {
],
),
const SizedBox(height: 16),
if (progressHeaderBuilder != null) ...[
progressHeaderBuilder!(context, drama, index),
const SizedBox(height: 8),
],
_buildProgress(),
],
);
@ -475,3 +516,10 @@ bool shouldPlayActiveVideo(
value.isInitialized &&
!value.isPlaying;
}
double resolvePlayerMetaBottom({
required double safeAreaBottom,
required double bottomSpacing,
}) {
return safeAreaBottom + bottomSpacing;
}

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/root/lib_export.dart';
class NyLoadingWidget extends StatefulWidget {
final double size;
const NyLoadingWidget({
super.key,
this.size = 10,
});
@override
State<NyLoadingWidget> createState() => _ThreeDotLoadingState();
}
class _ThreeDotLoadingState extends State<NyLoadingWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
int _currentIndex = 0;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
)..addListener(() {
//
if (_controller.isCompleted) {
_controller.reset();
setState(() {
_currentIndex = (_currentIndex + 1) % 3;
});
_controller.forward();
}
});
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: AppScreen.screenWidth,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (index) {
final isDark = index == _currentIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 6),
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDark ? ConstColor.withValue('#89F011') : ConstColor.withValue('#E2FFC0'),
),
);
}),
),
);
}
}

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:novyronst/common/const/const_color.dart';
import 'package:novyronst/common/help/help_image.dart';
import 'package:novyronst/tools/widgets/novy_com_widget.dart';
@ -16,6 +17,10 @@ class NyTagWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
String _tag = text;
if (text.length > 12) {
_tag = text.substring(0, 12);
}
return Container(
height: 20,
padding: const EdgeInsets.symmetric(horizontal: 8),
@ -23,8 +28,9 @@ class NyTagWidget extends StatelessWidget {
color: bgColor ?? ConstColor.withValue('EEEEEE'),
borderRadius: BorderRadius.circular(4),
),
// constraints: BoxConstraints(maxWidth: 100.w),
child: Center(
child: NovyText(text,
child: NovyText(_tag,
fontSize: 10,
color: textColor ?? ConstColor.black,
),

View File

@ -717,6 +717,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
@ -725,6 +749,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
@ -821,6 +853,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: e4d3474277c5043f5f3100ed27d94ad693abfd5c602b0221c719a4497e4ba1e4
url: "https://pub.dev"
source: hosted
version: "4.14.0"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: ad5182eff9a550925330cb9f0cb038eddfdd5712aba8b77aa0f0400e50f6e688
url: "https://pub.dev"
source: hosted
version: "4.12.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: "82648217f537573e1ca9ae9952d3eacedca6ab5aee69dc84445fc763766dcea2"
url: "https://pub.dev"
source: hosted
version: "3.25.1"
win32:
dependency: transitive
description:

View File

@ -33,6 +33,9 @@ dependencies:
video_player: ^2.10.0
easy_refresh: ^3.4.0
webview_flutter: ^4.13.0
url_launcher: ^6.3.2