初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart';
import 'package:hgdj/hj_page/mine/mine_vip/mine_charge_vip_page.dart';
import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../tools_base/widget/card_swiper/src/swiper.dart';
import '../../pre_sale/pre_sale_entry.dart';
import '../../pre_sale/pre_sale_provider.dart';
/// 浮窗容器的位置锚点:支付分层弹窗关闭时缩小飞向它(见 GuideHomeDialog)。
/// 挂在容器而不是分层浮窗本身——轮播时子项会轮到屏幕外,落点得是稳定的右下角
final homeFloatKey = GlobalKey();
/// 首页右下角浮窗入口
/// 展示优先级:预售浮窗 + 分层浮窗,两者都在则轮播(预售在前);都没有则不显示
class HomeActivityFloat extends StatelessWidget {
const HomeActivityFloat({super.key});
@override
Widget build(BuildContext context) {
// 预售(PreSaleProvider)、分层(MineMsgProvider)配置变化时重建
return Consumer2<PreSaleProvider, MineMsgProvider>(
builder: (_, presale, msg, __) {
final tier = msg.payTier?.config;
// 按优先级收集可展示的浮窗:预售在前、分层在后
final floats = <Widget>[
//预售浮窗:展示判断走项目统一的 canShowEnter,点击进预售页(入口图/动画由 PreSaleEntry 内部处理)
if (presale.canShowEnter) const PreSaleEntry(),
//支付分层浮窗:有 homePageFlot 图就展示
if ((tier?.homePageFlot ?? '').isNotEmpty)
_LayeredFloatView(config: tier!),
];
if (floats.isEmpty) return const SizedBox();
// 单个直接贴右下角;预售 + 分层并存则轮播
return KeyedSubtree(
key: homeFloatKey,
child: floats.length == 1
? Align(alignment: Alignment.bottomRight, child: floats.first)
: _FloatCarousel(items: floats),
);
},
);
}
}
/// 支付分层浮窗:homePageFlot 海报图(自带倒计时框)+ 底部叠加 lastDiscountTime 倒计时文字
/// 点击跳会员中心并自动选中 config.vipCard 对应会员卡
class _LayeredFloatView extends StatelessWidget {
final PayTierConfig config;
const _LayeredFloatView({required this.config});
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
//点击跳会员中心,vipID 让其自动选中对应卡
onTap: () => Get.to(() => MineChargeVipPage(
vipID: config.vipCard, sourcePage: PaySourcePage.homeFloatWindow)),
child: SizedBox(
width: 60,
height: 66,
child: Stack(
fit: StackFit.expand,
children: [
//分层海报图(后端加密图)
NetworkImageLoader(
imageUrl: config.homePageFlot ?? '',
fit: BoxFit.fill,
borderRadius: 0),
//倒计时文字:监听 tick 每秒刷新(过期自动收起),框由后端图自带,位置不对就调 bottom
Positioned(
left: 0,
right: 0,
bottom: 2,
child: ValueListenableBuilder<int>(
valueListenable: MineMsgProvider().tick,
builder: (_, __, ___) => config.hasDiscountCountdown
? Center(
child: Text(
'${config.discountHour}:${config.discountMin}:${config.discountSec}',
style: const TextStyle(
color: Colors.white,
fontSize: 8.5,
fontWeight: FontWeight.w600),
),
)
: const SizedBox.shrink(),
),
),
],
),
),
);
}
}
/// 预售 + 分层浮窗轮播(复用项目 Swiper,4s 自动切换;预售在前、分层在后)
class _FloatCarousel extends StatelessWidget {
final List<Widget> items;
const _FloatCarousel({required this.items});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 70,
height: 124,
child: Swiper(
//内容数量变化时换 key 强制重建,避免 index 越界
key: ValueKey('home_float_${items.length}'),
autoplay: true,
autoplayDelay: 4000,
loop: true,
itemCount: items.length,
itemBuilder: (_, index) =>
Align(alignment: Alignment.bottomRight, child: items[index]),
),
);
}
}
@@ -0,0 +1,242 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import '../../../config/config.dart';
import '../../../hj_model/splash/domain_source_model.dart';
import '../../../hj_utils/api_service/common_service.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../mine/mine_vip/pay_order_source.dart';
/// 首页红包雨/限时活动浮窗:展示倒计时,活动结束后 ping 刷新下一场
class HomeFloatBanner extends StatefulWidget {
final BannerJumpEntity banner;
final double width;
final double height;
final double radius;
/// banner 被 ping 刷新/移除后回调,通知父级(轮播)重建
final VoidCallback? onBannerUpdated;
const HomeFloatBanner({
super.key,
required this.banner,
required this.width,
required this.height,
this.radius = 0,
this.onBannerUpdated,
});
@override
State<HomeFloatBanner> createState() => _HomeFloatBannerState();
}
class _HomeFloatBannerState extends State<HomeFloatBanner> {
// ===== 数据 =====
late BannerJumpEntity _banner; // 当前展示的 banner(活动结束后可能被 ping 刷新替换)
// ===== 状态标志 =====
bool _endedPingRequested = false; // 本场活动结束后是否已请求过刷新(避免重复 ping)
bool _isPinging = false; // 是否正在 ping(防重入)
// ===== 计时器 & 局部刷新 =====
Timer? _timer; // 倒计时类活动每秒刷新用
// 底部文案(倒计时 / "立即参与")。每秒只更新它,交 ValueListenableBuilder 局部刷新文字,
// 图片(NetworkImageLoader)不参与每秒重建(仿 PreSaleProvider.deadline 的局部刷新思路)
final ValueNotifier<String> _bottomText = ValueNotifier('');
// 是否倒计时类活动(countdownType==1 才展示倒计时并轮询结束)
bool get _isCountdownActivity => _banner.countdownType == 1;
@override
void initState() {
super.initState();
_banner = widget.banner;
_refreshBottomText();
_restartTimer();
}
@override
void didUpdateWidget(covariant HomeFloatBanner oldWidget) {
super.didUpdateWidget(oldWidget);
// banner 换了(父级传入新数据):重置结束标记、刷新文案并按新类型重启计时器
if (oldWidget.banner != widget.banner) {
_banner = widget.banner;
_endedPingRequested = false;
_refreshBottomText();
_restartTimer();
}
}
@override
void dispose() {
_timer?.cancel();
_bottomText.dispose();
super.dispose();
}
// 按当前 banner 类型启停计时器:仅倒计时类活动才每秒刷新
void _restartTimer() {
_timer?.cancel();
_timer = null;
if (_isCountdownActivity) {
_timer =
Timer.periodic(const Duration(seconds: 1), (_) => _onTimerTick());
}
}
// 每秒 tick:活动结束时 ping 一次拿下一场,其余情况只更新底部文案(局部刷新,不重建图片)
void _onTimerTick() {
if (!mounted) return;
if (_activityStatus() == _ActStatus.ended && !_endedPingRequested) {
_endedPingRequested = true;
_fetchLatestBanner();
}
_refreshBottomText();
}
// 重算底部文案:进行中"立即参与"、未开始显示距开始倒计时、已结束为空。
// 只写 ValueNotifier,触发 ValueListenableBuilder 局部刷新
void _refreshBottomText() {
if (!_isCountdownActivity) {
_bottomText.value = '';
return;
}
final start = _parseDate(_banner.startAt);
_bottomText.value = switch (_activityStatus()) {
_ActStatus.inProgress => '立即参与',
_ActStatus.beforeStart =>
start == null ? '' : _formatCountdown(start.difference(DateTime.now())),
_ActStatus.ended => '',
};
}
// 活动结束后拉最新 banner:有新场次则替换并写回 Config,无则从 Config 移除
Future<void> _fetchLatestBanner() async {
final id = _banner.id;
if (id == null || id.isEmpty || _isPinging) return;
_isPinging = true;
try {
final latest = await CommonService.pingBanner(id);
if (!mounted) return;
if (latest != null && latest.banner?.isNotEmpty == true) {
_banner = latest;
_updateConfigBanner(latest);
_endedPingRequested = false;
} else {
_removeConfigBanner();
}
widget.onBannerUpdated?.call();
} catch (_) {
} finally {
_isPinging = false;
if (mounted) {
_refreshBottomText();
setState(() {});
}
}
}
// 用新 banner 替换 Config.bannerJumps 里同位置同 id 的旧数据
void _updateConfigBanner(BannerJumpEntity banner) {
final index = Config.bannerJumps.indexWhere(
(e) => e.position == _banner.position && e.id == _banner.id);
if (index >= 0) {
Config.bannerJumps[index] = banner;
}
}
// 活动结束且无下一场:从 Config.bannerJumps 移除当前 banner
void _removeConfigBanner() {
Config.bannerJumps.removeWhere(
(e) => e.position == _banner.position && e.id == _banner.id);
}
// 解析后端时间字符串为本地时间;空值/占位(0001-01-01)/非法格式返回 null
DateTime? _parseDate(String? date) {
if (date == null || date.isEmpty || date.contains('0001-01-01'))
return null;
try {
return DateTime.parse(date).toLocal();
} catch (_) {
return null;
}
}
// 依据 startAt/endAt 判定活动处于 未开始 / 进行中 / 已结束
_ActStatus _activityStatus() {
final now = DateTime.now();
final start = _parseDate(_banner.startAt);
final end = _parseDate(_banner.endAt);
if (start != null && now.isBefore(start)) return _ActStatus.beforeStart;
if (end != null && !now.isBefore(end)) return _ActStatus.ended;
return _ActStatus.inProgress;
}
// 倒计时格式化:跨天显示"N天HH:MM:SS",否则"HH:MM:SS"
String _formatCountdown(Duration duration) {
final seconds = duration.inSeconds < 0 ? 0 : duration.inSeconds;
final days = seconds ~/ 86400;
final time = DateTimeUtil.formatHMS(seconds % 86400,
alwaysHour: true); // 当天内 HH:MM:SS
return days > 0 ? '$days天$time' : time;
}
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
// 内链可配到会员/金币页,来源统一记首页浮窗
onTap: () => pushToPageByLink(_banner.url,
sourcePage: PaySourcePage.homeFloatWindow),
child: SizedBox(
width: widget.width,
height: widget.height,
child: Stack(
children: [
NetworkImageLoader(
imageUrl: _banner.banner ?? '',
width: widget.width,
height: widget.height,
borderRadius: widget.radius,
fit: BoxFit.cover,
),
Positioned(
left: 0,
right: 0,
bottom: 2,
child: ValueListenableBuilder<String>(
valueListenable: _bottomText,
builder: (_, text, __) => text.isEmpty
? const SizedBox.shrink()
: Text(
text,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
}
// 浮窗活动状态:未开始 / 进行中 / 已结束
enum _ActStatus { beforeStart, inProgress, ended }
/// 是否用 [HomeFloatBanner] 渲染该 banner:仅 position==3 的浮窗位、有图、且非「抽奖」项
bool isHomeFloatBanner(BannerJumpEntity model, int position) {
if (position != 3) return false;
if (model.banner?.isNotEmpty != true) return false;
return !(model.title == '抽奖' && model.position == null);
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'home_activity_float.dart';
import 'swiper_floating_widget.dart';
class HomeFloatWidget extends StatelessWidget {
const HomeFloatWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 预售 + 支付分层浮窗(编排 + 轮播统一在 HomeActivityFloat
const HomeActivityFloat(),
SwiperFloatingWidget(
position: 3,
width: 64,
height: 64,
margin: EdgeInsets.only(top: 16),
),
],
);
}
}
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../../config/config.dart';
import '../../../hj_model/splash/domain_source_model.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/widget/card_swiper/src/swiper.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../mine/mine_vip/pay_order_source.dart';
import '../../pre_sale/pre_sale_entry.dart';
import '../../pre_sale/pre_sale_provider.dart';
import 'home_float_banner.dart';
/// 首页浮窗入口:聚合 Config.bannerJumps 中指定 position 的 banner + 抽奖入口,轮播展示。
/// banner 命中 [isHomeFloatBanner] 用带倒计时的 [HomeFloatBanner],抽奖用 [LuckyDrawButton],其余普通图。
class SwiperFloatingWidget extends StatefulWidget {
// 必填:浮窗位置标识(对应 bannerJumps 的 position
final int position;
// ===== 尺寸 / 外观 =====
final double? width;
final double? height;
final double? radius;
final EdgeInsetsGeometry? margin;
// ===== 行为 =====
final int? autoPlayDuration; // 轮播间隔(毫秒)
final ValueChanged<BannerJumpEntity>? onItemClick; // 自定义点击;不传则走默认 pushToPageByLink
const SwiperFloatingWidget({
required this.position,
super.key,
this.width,
this.height,
this.radius,
this.margin,
this.autoPlayDuration = 5000,
this.onItemClick,
});
@override
State<SwiperFloatingWidget> createState() => _SwiperFloatingWidgetState();
}
class _SwiperFloatingWidgetState extends State<SwiperFloatingWidget> {
int _currentIndex = 0; // 当前轮播页下标
// 当前可见的浮窗项:本 position 下有图的 banner +(开启时)抽奖入口
List<BannerJumpEntity> get _visibleBanners {
final banners = Config.bannerJumps
.where((e) => e.position == widget.position && e.banner?.isNotEmpty == true)
.toList();
if (presaleProvider.luckyDrawH5?.isNotEmpty == true) {
banners.add(BannerJumpEntity(title: '抽奖'));
}
return banners;
}
// 是否抽奖入口项(title=抽奖 且无 position,借此与真实 banner 区分)
bool _isLuckyDraw(BannerJumpEntity model) => model.title == '抽奖' && model.position == null;
// Swiper 的 ValueKeyposition + 抽奖态 + 各 banner id,内容变化时换 key 强制重建
String _buildSwiperKey(List<BannerJumpEntity> banners) {
var key = 'home_float_${widget.position}';
if (presaleProvider.luckyDrawH5?.isNotEmpty == true) key += '_lucky';
for (final banner in banners) {
if (_isLuckyDraw(banner)) continue;
key += '_banner${banner.id ?? ''}';
}
return key;
}
@override
Widget build(BuildContext context) {
final banners = _visibleBanners; // 每帧只构建一次,key/itemBuilder/onTap 共用同一快照
if (banners.isEmpty) return const SizedBox();
final itemW = widget.width ?? 64.0;
final itemH = widget.height ?? 64.0;
final radius = widget.radius ?? 0.0;
return Container(
width: widget.width,
height: widget.height,
margin: widget.margin,
child: Swiper(
key: ValueKey(_buildSwiperKey(banners)),
index: _currentIndex,
autoplay: banners.length > 1,
autoplayDelay: widget.autoPlayDuration ?? 5000,
loop: banners.length != 1,
itemCount: banners.length,
itemBuilder: (_, index) => _buildItem(banners[index], itemW, itemH, radius),
onTap: (index) => _onItemTap(banners[index]),
onIndexChanged: (index) {
if (mounted) setState(() => _currentIndex = index);
},
),
);
}
// 单项:抽奖入口 / 带倒计时的活动浮窗 / 普通图,统一右对齐
Widget _buildItem(BannerJumpEntity model, double width, double height, double radius) {
Widget child;
if (_isLuckyDraw(model)) {
child = const LuckyDrawButton();
} else if (isHomeFloatBanner(model, widget.position)) {
child = HomeFloatBanner(
banner: model,
width: width,
height: height,
radius: radius,
onBannerUpdated: () {
if (mounted) setState(() {});
},
);
} else {
child = NetworkImageLoader(
imageUrl: model.banner ?? '',
width: width,
height: height,
borderRadius: radius,
fit: BoxFit.cover,
);
}
return Align(alignment: Alignment.centerRight, child: child);
}
// 点击:抽奖/活动浮窗自行处理点击(此处跳过),其余走自定义回调或默认跳转
void _onItemTap(BannerJumpEntity ad) {
if (_isLuckyDraw(ad)) return;
if (isHomeFloatBanner(ad, widget.position)) return;
if (widget.onItemClick != null) {
widget.onItemClick!.call(ad);
} else {
// 内链可配到会员/金币页,来源统一记首页浮窗
pushToPageByLink(
ad.url,
sourcePage: PaySourcePage.homeFloatWindow,
);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:provider/provider.dart';
import '../../alert/aw_permission_alert.dart';
import '../../hj_utils/screen.dart';
import '../../tools_base/global_store/store.dart';
import 'home_drawer.dart';
import 'home_main_logic.dart';
import 'home_main_page.dart';
import 'search_page/widget/common_search_widget.dart';
class DarkHomePage extends StatefulWidget {
final String? defaultId;
const DarkHomePage({super.key, this.defaultId});
@override
State<DarkHomePage> createState() => _DarkHomePageState();
}
class _DarkHomePageState extends State<DarkHomePage> {
late final logic =
HomeMainLogic(isDarkStyle: true, darkDefaultId: widget.defaultId);
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xff050f17),
child: GetBuilder<HomeMainLogic>(
init: logic,
tag: "HomeMainLogic_dark",
builder: (_) {
return Stack(
fit: StackFit.expand,
children: [
Scaffold(
//openEndDrawer 靠这个 key 拿 ScaffoldState,不挂 key 点右上角菜单没反应
key: logic.scaffoldKey,
backgroundColor: Colors.black,
endDrawer: HSHomeDrawer(homeLogic: logic),
body: logic.isLoading
? const LoadingCenterWidget()
: Column(
children: [
Container(
margin: EdgeInsets.only(top: screen.paddingTop),
height: kToolbarHeight,
//必须传 logic,右上角菜单的 onTap 是 logic?.openEndDrawer()
child: CommonSearchBarView(logic: logic),
),
Expanded(
child: HomeTabView(
key: ValueKey(logic.tabViewKey),
logic: logic,
isDarkStyle: true,
),
),
],
),
),
Consumer<GlobalStore>(builder: (_, store, __) {
if (store.isAWVIP) return const SizedBox.shrink();
return const Positioned.fill(
child: AwPermissionAlert(),
);
}),
],
);
},
),
);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 小字筛选栏:文字 + 竖线分隔,用在标签/专题/搜索结果/短剧等列表页顶部
class DividerTabBar extends StatelessWidget {
final List<String> titles;
final int selectIndex;
final Function(int)? callback;
final AlignmentGeometry? alignment;
const DividerTabBar(
this.titles, {
super.key,
this.selectIndex = 0,
this.callback,
this.alignment,
});
@override
Widget build(BuildContext context) {
return Container(
alignment: alignment ?? Alignment.centerLeft,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < titles.length; i++) _buildMenuButton(i),
],
),
);
}
Widget _buildMenuButton(int index) {
final isLast = index == titles.length - 1;
return InkWell(
enableFeedback: false,
onTap: () => callback?.call(index),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
titles[index],
style: TextStyle(
fontSize: 16.sp,
color: index == selectIndex ? const Color(0xE5FFFFFF) : const Color(0x73FFFFFF),
),
),
//分隔竖线跟在每项后面,最后一项不带
if (!isLast)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
"|",
style: TextStyle(fontSize: 14, color: Colors.white.withValues(alpha: 0.05)),
),
),
],
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/sliver_delegate.dart';
import '../home_sub_module/home_tab_section_logic.dart';
import '../widget/home_sort_menu_view.dart';
import 'video_simple_cell.dart';
/// 亚模块底部的「猜你喜欢」:标题 + 吸顶排序栏 + 两列网格
class GuessLikeSliver extends StatelessWidget {
final AllSection model;
final int? sortIndex;
final HomeTabSectionLogic? logic;
const GuessLikeSliver(this.model, {super.key, this.sortIndex, this.logic});
@override
Widget build(BuildContext context) {
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.only(left: 12),
child: Text(
"猜你喜欢",
style: TextStyle(
fontSize: 18,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w600,
),
),
),
),
SliverPersistentHeader(
pinned: true,
floating: true,
delegate: MySliverDelegate(
maxHeight: 42,
minHeight: 42,
childBuildHandler: (_, offset, overlaps, child) {
return Container(
color:
(offset >= 0) ? AppColors.primaryColor : Colors.transparent,
child: child,
);
},
child: Container(
alignment: Alignment.centerLeft,
padding: EdgeInsets.only(left: 12),
child: HomeSortMenuView(
["最多收藏", "最新上架", "最多观看"],
gapChar: "|",
selectIndex: sortIndex ?? 0,
callback: (index) {
if (index != sortIndex) {
logic?.sortGuessLikeExchange(index);
}
},
),
),
),
),
if (model.allVideoInfo == null)
SliverToBoxAdapter(
child: SizedBox(height: 300, child: LoadingCenterWidget()),
)
else if (model.allVideoInfo!.isEmpty)
SliverToBoxAdapter(
child: SizedBox(height: 300, child: CErrorWidget()),
)
else
SliverPadding(
padding: EdgeInsets.only(left: 12, right: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 8,
childAspectRatio: 168 / 154,
),
delegate: SliverChildBuilderDelegate(
(context, index) =>
VideoSimpleCell(videoModel: model.allVideoInfo![index]),
childCount: model.allVideoInfo!.length,
),
),
),
],
);
}
}
@@ -0,0 +1,490 @@
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../tools_base/debug_log.dart';
import '../special_topic_detail/special_topics_detail_page.dart';
import 'home_section_title.dart';
import 'video_hor_cell.dart';
import 'video_simple_cell.dart';
// OneLargeAndFourSmall = 101 or 0 // 0 一大四小
// HScroll15 = 104 // 1 1.5左右滑动(横)
// HScroll25 = 105 // 2 2.5左右滑动(横)
// FourGrid = 102 // 3 四宫格(横acg+影视共用)
// SixGrid = 103 // 4 六宫格(横)
// HListGrid = 106 // 5 小列表 (横)
// HListBigGrid = 107 // 6 大列表 (横)
// VerticalScroll25 = 205 // 7 2.5左右滑动(竖ACG)
// VerticalFourGrid = 201 // 8 四宫格(竖ACG)
// VerticalSixGrid = 202 // 9 六宫格(竖ACG)
// VerticalNineGrid = 203 // 10 九宫格(竖ACG)
// VerticalListGrid = 204 // 11 小列表 (竖ACG)
// GuessYouLike = 301 // 12 猜你喜欢
enum HomeSectionShowType {
oneLargeAndFourSmall(101, canExchange: true),
hScroll15(104),
hScroll25(105),
fourGrid(102, canExchange: true),
sixGrid(103, canExchange: true),
hListGrid(106),
hListBigGrid(107),
verticalScroll25(205),
verticalFourGrid(201),
verticalSixGrid(202),
verticalNineGrid(203),
verticalListGrid(204),
guessYouLike(301),
;
final int value;
final bool canExchange; //底部是否挂「更多片源 / 更换一批」
const HomeSectionShowType(this.value, {this.canExchange = false});
}
//首页专题 section:按 showType 排版的内容 + (部分样式)底部换一批 + 间距分割线
class HomeSectionCell extends StatefulWidget {
final AllSection section;
const HomeSectionCell(this.section, {super.key});
@override
State<HomeSectionCell> createState() => _HomeSectionCellState();
}
class _HomeSectionCellState extends State<HomeSectionCell> {
//value→枚举 O(1) 查找,避免每次 build 线性遍历 values
static final Map<int?, HomeSectionShowType> _typeMap = {
for (final t in HomeSectionShowType.values) t.value: t,
};
AllSection get section => widget.section;
bool _exchanging = false; //换一批请求中
@override
Widget build(BuildContext context) {
//未知 showType 排版兜底一大四小,但不挂换一批(与原先按 101/102/103 判断保持一致)
final style = _typeMap[section.showType];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
_body(style),
//101/102/103 底部挂「更多片源 / 更换一批」,其余样式只留间距
if (style?.canExchange == true)
Padding(
padding: const EdgeInsets.only(bottom: 24), child: _exchangeBar())
else
12.sizeBoxH,
//section 间的分割线跟着 cell 走,外面只管往列表里塞
Container(
margin: const EdgeInsets.only(bottom: 12, left: 16, right: 16),
height: 1,
color: const Color(0x19FFFFFF),
),
],
);
}
Widget _body(HomeSectionShowType? style) {
switch (style) {
case HomeSectionShowType.hScroll15:
return SectionOneHalfFiveHor(section);
case HomeSectionShowType.hScroll25:
return SectionTwoHalfFiveHV(section);
case HomeSectionShowType.fourGrid:
return SectionGridViewHV(section, maxCount: 4);
case HomeSectionShowType.sixGrid:
return SectionGridViewHV(section, maxCount: 6);
case HomeSectionShowType.hListGrid:
return SectionListView(section);
case HomeSectionShowType.hListBigGrid:
return SectionListView(section, isBigStyle: true);
case HomeSectionShowType.verticalScroll25:
return SectionTwoHalfFiveHV(section, isVertical: true);
case HomeSectionShowType.verticalFourGrid:
return SectionGridViewHV(
section,
maxCount: 4,
aspectRatio: 168 / 266,
textLines: 1,
);
case HomeSectionShowType.verticalSixGrid:
return SectionGridViewHV(
section,
maxCount: 6,
aspectRatio: 111 / 190,
crossAxisCount: 3,
textLines: 1,
);
case HomeSectionShowType.verticalNineGrid:
return SectionGridViewHV(
section,
maxCount: 9,
aspectRatio: 111 / 190,
crossAxisCount: 3,
textLines: 1,
);
case HomeSectionShowType.verticalListGrid:
return SectionGridViewSmall(section);
default:
return SectionOneBigFourSmall(section);
}
}
//底部两颗胶囊:更多片源(跳专题详情) / 更换一批(换数据)
Widget _exchangeBar() {
return Container(
height: 36,
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Row(
children: [
Expanded(
child: _ExchangeButton(
title: '更多片源',
onTap: () => Get.to(SpecialTopicsDetailPage(section),
preventDuplicates: false),
),
),
16.sizeBoxW,
Expanded(
child: _ExchangeButton(
title: '更换一批', loading: _exchanging, onTap: _exchange),
),
],
),
);
}
//换一批:只替换本 section 的视频(仅重建本 cell),失败保持原数据不动
Future<void> _exchange() async {
if (_exchanging) return;
final id = section.sectionID;
setState(() => _exchanging = true);
try {
final resp = await VidService.sectionVideoExchange(id);
//请求飞行中列表可能被刷新,cell 按 index 复用后 section 已换人,结果不能再往回写
if (resp.videos?.isNotEmpty == true && section.sectionID == id)
section.allVideoInfo = resp.videos;
} catch (e) {
debugLog(e);
}
if (mounted) setState(() => _exchanging = false);
}
}
//「更多片源 / 更换一批」胶囊按钮
class _ExchangeButton extends StatelessWidget {
final String title;
final bool loading;
final VoidCallback onTap;
const _ExchangeButton(
{required this.title, required this.onTap, this.loading = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: const Color(0x1AFFFFFF),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//转圈占位 16、常态 4,保持文字位置不跳
if (loading)
const SizedBox(
width: 16,
height: 16,
child: CupertinoActivityIndicator(
radius: 8, color: Colors.white))
else
const SizedBox(width: 4),
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 14,
fontWeight: FontWeight.w400),
),
],
),
),
);
}
}
//一大四小
class SectionOneBigFourSmall extends StatelessWidget {
final AllSection section;
const SectionOneBigFourSmall(this.section, {super.key});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
height: 235,
padding: EdgeInsets.symmetric(horizontal: 12),
child: VideoSimpleCell(
videoModel: section.allVideoInfo!.first,
textLines: 1,
),
),
12.sizeBoxH,
GridView.builder(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 168 / 154,
),
itemCount: min(4, section.allVideoInfo!.length - 1),
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index + 1];
return VideoSimpleCell(videoModel: model);
},
),
],
);
}
}
//1.5横
class SectionOneHalfFiveHor extends StatelessWidget {
final AllSection section;
const SectionOneHalfFiveHor(this.section, {super.key});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
height: 202,
padding: EdgeInsets.only(left: 12),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: section.allVideoInfo!.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return Container(
height: 202,
width: 280,
margin: EdgeInsets.only(right: 8),
child: VideoSimpleCell(videoModel: model, textLines: 1),
);
},
),
)
],
);
}
}
//2.5横/竖
class SectionTwoHalfFiveHV extends StatelessWidget {
final AllSection section;
final bool isVertical; // true 竖, false 横
const SectionTwoHalfFiveHV(this.section,
{super.key, this.isVertical = false});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
height: isVertical ? 218 : 146,
padding: EdgeInsets.only(left: 12),
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final width = (constraints.maxWidth - 2 * 8) / 2.5;
return SizedBox(
height: isVertical ? 218 : 146,
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: section.allVideoInfo!.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return Container(
height: 146,
width: width,
margin: EdgeInsets.only(right: 8),
child: VideoSimpleCell(
videoModel: model,
textLines: isVertical ? 1 : 2,
),
);
},
),
);
},
),
)
],
);
}
}
//四/六/九宫格横/竖 (默认四宫格)
class SectionGridViewHV extends StatelessWidget {
final AllSection section;
final int maxCount;
final double aspectRatio;
final int crossAxisCount;
final int textLines;
const SectionGridViewHV(
this.section, {
super.key,
this.maxCount = 4,
this.aspectRatio = 168 / 154,
this.crossAxisCount = 2,
this.textLines = 2,
});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
GridView.builder(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12),
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: aspectRatio,
),
itemCount: min(maxCount, section.allVideoInfo!.length),
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return VideoSimpleCell(videoModel: model, textLines: textLines);
},
),
],
);
}
}
//列表横(大小横)
class SectionListView extends StatelessWidget {
final AllSection section;
final bool isBigStyle; // true 大横, false 小横
const SectionListView(this.section, {super.key, this.isBigStyle = false});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
ListView.separated(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 12),
shrinkWrap: true,
itemCount: min(3, section.allVideoInfo!.length),
separatorBuilder: (context, index) => 12.sizeBoxH,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
if (isBigStyle) {
return SizedBox(
height: 230,
child: VideoSimpleCell(videoModel: model, textLines: 1),
);
} else {
return SizedBox(
height: 96,
child: VideoHorCell(videoModel: model),
);
}
},
),
],
);
}
}
//小列表(竖)
class SectionGridViewSmall extends StatelessWidget {
final AllSection section;
const SectionGridViewSmall(this.section, {super.key});
@override
Widget build(BuildContext context) {
if (section.allVideoInfo?.isNotEmpty != true)
return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HomeSectionTitle(section),
Container(
padding: EdgeInsets.only(left: 12),
height: 232,
child: GridView.builder(
padding: EdgeInsets.zero,
scrollDirection: Axis.horizontal,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 16,
crossAxisSpacing: 8,
childAspectRatio: 110 / 256,
),
itemCount: section.allVideoInfo!.length,
itemBuilder: (context, index) {
VideoModel model = section.allVideoInfo![index];
return VideoHorCell(
videoModel: model,
imageWidth: 82,
);
},
),
),
],
);
}
}
@@ -0,0 +1,42 @@
import 'package:flutter/cupertino.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../special_topic_detail/special_topics_detail_page.dart';
/// 专题标题行:标题 + 右箭头,点整行进专题详情
class HomeSectionTitle extends StatelessWidget {
final AllSection? section;
const HomeSectionTitle(this.section, {super.key});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.fromLTRB(12, 0, 12, 12),
child: GestureDetector(
onTap: () =>
Get.to(SpecialTopicsDetailPage(section), preventDuplicates: false),
child: Row(
children: [
Expanded(
child: Text(
section?.sectionName ?? "",
maxLines: 1,
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
12.sizeBoxW,
Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
],
),
),
);
}
}
@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import '../../../config/config.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/free_play_manager.dart';
/// 封面右上角的售卖角标:免费试看 / 金币 / VIP,三者互斥,都不满足就不占位。
/// 展不展示还受 ping 下发的 freeMark / coinMark / vipMark 三个开关控制
class LevelMarkChip extends StatelessWidget {
final VideoModel? videoModel;
const LevelMarkChip(this.videoModel, {super.key});
@override
Widget build(BuildContext context) {
if (videoModel?.freeArea == true) return const SizedBox();
// 免费试看:ping 免费类角标总开关 freeMark + 实时试看权益(次数用尽即不再展示)
if (Config.freeMark && FreePlayManager().canShowFreeTrialBadge(videoModel)) {
return _chip("免费试看", const Color(0xff141414), const [Color(0xff35DEBC), Color(0xff22BB9C)]);
}
final isCoin = videoModel?.isCoinVideo() == true;
if (isCoin && Config.coinMark) {
return _chip("金币", const Color(0xff8B3E00), const [Color(0xffFFE580), Color(0xffFACC15)]);
}
if (!isCoin && Config.vipMark) {
return _chip("VIP", const Color(0xff141414), const [Color(0xffF8F3AE), Color(0xffDDAC44)]);
}
return const SizedBox();
}
Widget _chip(String text, Color textColor, List<Color> gradientColors) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(2),
gradient: LinearGradient(colors: gradientColors),
),
child: Text(
text,
style: TextStyle(color: textColor, fontSize: 10, height: 1.6),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../../hj_model/splash/domain_source_model.dart';
/// 金刚区:亚模块顶部那排图标入口,横向可滑,一屏露 5.5 个。
/// 配置来自 /domain 下发的 jgArea,按 mid == 亚模块 id 匹配;没配就整块不占位
class QuickEntryRow extends StatelessWidget {
final ModuleData? tabModel;
const QuickEntryRow(this.tabModel, {super.key});
@override
Widget build(BuildContext context) {
final dataArr =
(Config.jgArea ?? []).where((e) => e.mid == tabModel?.id).toList();
if (dataArr.isEmpty) return const SizedBox.shrink();
return Container(
height: 62,
margin: const EdgeInsets.fromLTRB(12, 0, 0, 12),
child: LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 5 * 16) / 5.5;
final height = width * 48 / 58;
return SizedBox(
height: height,
child: ListView.builder(
itemCount: dataArr.length,
padding: EdgeInsets.zero,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) =>
_buildItem(dataArr[index], width),
),
);
},
),
);
}
Widget _buildItem(JGAreaModel model, double width) {
return Container(
height: 62,
width: width,
margin: const EdgeInsets.only(right: 16),
child: InkWell(
enableFeedback: false,
onTap: () => pushToPageByLink(model.linkUrl),
child: Column(
children: [
Expanded(
child: Container(
alignment: Alignment.center,
child: AspectRatio(
aspectRatio: 1,
child: NetworkImageLoader(imageUrl: model.img ?? ""),
),
),
),
3.sizeBoxH,
Text(
model.name ?? "",
maxLines: 1,
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
),
),
);
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import '../../../hj_model/video_model.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'package:hgdj/extension/extensions.dart';
/// 抖音样式的视频 item:封面 + 底部角标 + 标题 +(可选)标签/评论数。
/// 封面占满父级剩余高度,外层必须给约束(网格 / 固定高容器)
class TiktokSimpleCell extends StatelessWidget {
final VideoModel? videoModel;
final int textLines;
final bool isShowBottom; // true 显示底部标签和评论数
final bool isShowTime; // true 封面右下角显示时长,false 显示图集数
const TiktokSimpleCell({
super.key,
this.videoModel,
this.textLines = 2,
this.isShowTime = false,
this.isShowBottom = true,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildCover()),
Container(
padding: const EdgeInsets.only(top: 4),
//末尾补个换行,让不满两行的标题也占满高度,卡片不会参差
child: Text(
"${videoModel?.title ?? ""}\n",
maxLines: textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9), fontSize: 12),
),
),
if (isShowBottom) _buildBottom(),
],
),
);
}
void _onTap() {
if (videoModel?.isRandomAd() == true) {
pushToPageByLink(videoModel?.randomAdsInfo?.href);
return;
}
pushToVideoPage(videoModel: videoModel);
}
//封面:图 + 底部渐变角标条
Widget _buildCover() {
return Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(imageUrl: videoModel?.cover ?? "", borderRadius: 4),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(4)),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.6)
],
),
),
child: _buildCoverBadge(),
),
),
],
);
}
//角标条:左播放数,右边给时长或图集数
Widget _buildCoverBadge() {
return Row(
children: [
Image.asset("eye_white.webp".commonImgPath, width: 16),
3.sizeBoxW,
Text(
videoModel?.playCount?.countStr ?? "0",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Colors.white, fontSize: 10, height: 1.6),
),
const Spacer(),
if (isShowTime)
Text(
videoModel?.playTime?.hmsStr ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 10),
)
else ...[
Image.asset("tuji_icon.webp".communityPath, width: 16),
2.sizeBoxW,
Text(
'${videoModel?.seriesCover?.length ?? 0}',
textAlign: TextAlign.justify,
style: const TextStyle(color: Colors.white, fontSize: 10),
),
],
2.sizeBoxW,
],
);
}
//标签 + 评论数
Widget _buildBottom() {
final tagName = videoModel?.tags?.firstOrNull?.name ?? "";
return Container(
padding: const EdgeInsets.only(top: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//标签可能是很长的脏数据(id)Flexible 才能让 ellipsis 生效,否则会撑爆 Row
Flexible(
child: tagName.isEmpty
? const SizedBox.shrink()
: Container(
padding:
const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
tagName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0x73FFFFFF), fontSize: 10),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
"评论${videoModel?.commentCount?.countStr ?? 0}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0x73FFFFFF), fontSize: 10),
),
),
],
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'level_mark_chip.dart';
import 'package:hgdj/extension/extensions.dart';
/// 横版视频 item:左封面(带售卖角标) + 右标题/收藏数,用在专题的小列表样式
class VideoHorCell extends StatelessWidget {
final VideoModel? videoModel;
final double imageWidth;
const VideoHorCell({super.key, this.videoModel, this.imageWidth = 150});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: Row(
children: [
Stack(
children: [
NetworkImageLoader(
width: imageWidth,
height: double.infinity,
borderRadius: 4,
imageUrl: videoModel?.cover ?? "",
),
Positioned(
right: 6,
top: 6,
// 监听免费次数变化:会话内试看用尽时角标实时刷新,不再残留「免费试看」
child: ValueListenableBuilder<int>(
valueListenable: FreePlayManager().revision,
builder: (_, __, ___) => LevelMarkChip(videoModel),
),
),
],
),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${videoModel?.title ?? ""}\n",
maxLines: 2,
style: TextStyle(
color: Colors.white,
fontSize: 12,
height: 1.2,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
4.sizeBoxH,
Text(
"收藏:${videoModel?.collectCount?.countStr ?? "0"}",
maxLines: 2,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 12,
),
),
],
),
),
],
),
);
}
void _onTap() {
if (videoModel?.isRandomAd() == true) {
pushToPageByLink(videoModel?.randomAdsInfo?.href);
return;
}
pushToVideoPage(videoModel: videoModel);
}
}
@@ -0,0 +1,471 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/banner/ads_item.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import '../../../hj_model/splash/ads_model.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/free_play_manager.dart';
import '../../../tools_base/banner/ads_banner_widget.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'level_mark_chip.dart';
/// 竖版视频 item:封面(角标/售卖标/暗网遮罩) + 标题 +(可选)标签/评论数。
/// 命中广告数据时整体换成 [VideoSimpleAdsCell]
class VideoSimpleCell extends StatelessWidget {
final VideoModel? videoModel;
final BorderRadius? imgBorderRadius;
final GestureTapCallback? onTap;
final int textLines;
final bool isFromHY; // 黄油风格
final bool isShowBottom; // true 显示底部标签和评论数
final bool isFromSearch;
final double titleFontSize; // 标题字号,小卡场景传小值
final double coverFontSize; // 封面底部播放量/时长字号,小卡场景传小值避免溢出
final String? coverRightText; // 封面右下角文案,默认时长;短剧传「共N集」
final bool showLevelIcon; // 封面右上角「金币/VIP/免费试看」角标;短剧按集卖,整部剧标一个对不上,传 false
final ValueChanged<TagsBean>? onTagTap; // 左下角标签点击;短剧跳标签详情
const VideoSimpleCell({
super.key,
this.videoModel,
this.imgBorderRadius,
this.textLines = 2,
this.onTap,
this.isFromHY = false,
this.isShowBottom = true,
this.isFromSearch = false,
this.titleFontSize = 12,
this.coverFontSize = 10,
this.coverRightText,
this.showLevelIcon = true,
this.onTagTap,
});
// 暗网风格遮罩:搜索来源且命中暗标签
bool get isDarkStyle => isFromSearch && (videoModel?.isDarkTag ?? false);
// 暗网内容且非 VIP,需引导开通才能观看
bool get _needVipUnlock => isDarkStyle && !globalStore.isAWVIP;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap ?? _onTap,
child: videoModel?.isAdsArr() == true
? VideoSimpleAdsCell(
videoModel: videoModel,
textLines: textLines,
)
: _buildContent(),
);
}
void _onTap() {
if (videoModel?.isRandomAd() == true) {
pushToPageByLink(videoModel?.randomAdsInfo?.href);
return;
}
if (_needVipUnlock) {
pushToWalletPage(vipId: Config.darkWebVipId);
return;
}
pushToVideoPage(videoModel: videoModel);
}
Widget _buildContent() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Expanded(child: _buildCoverItem()),
Container(
padding: const EdgeInsets.only(top: 4),
//末尾补个换行,让不满两行的标题也占满高度,卡片不会参差
child: Text(
"${videoModel?.title ?? ""}\n",
maxLines: textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: titleFontSize),
),
),
if (isShowBottom) _buildBottomWidget(),
],
);
}
Widget _buildCoverItem() {
return Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: videoModel?.cover ?? "",
imgBorderRadius: imgBorderRadius,
borderRadius: 4,
blur: _needVipUnlock,
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 24,
padding: EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.vertical(bottom: Radius.circular(4)),
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.6),
],
),
),
child: isFromHY ? _buildCoverHYBottom() : _buildCoverNormalBottom(),
),
),
if (showLevelIcon)
Positioned(
right: 6,
top: 6,
// 监听免费次数变化:会话内试看用尽时角标实时刷新,不再残留「免费试看」
child: ValueListenableBuilder<int>(
valueListenable: FreePlayManager().revision,
builder: (_, __, ___) => LevelMarkChip(videoModel),
),
),
if (_needVipUnlock)
Container(
color: Colors.black.withValues(alpha: 0.1),
alignment: Alignment.center,
padding: EdgeInsets.only(bottom: 8),
child: EasyRichText(
'需要开通 ${Config.darkWebVipName}\n即可观看',
textAlign: TextAlign.center,
defaultStyle: TextStyle(
fontSize: 10,
color: Colors.white,
height: 1.6,
),
patternList: [
EasyRichTextPattern(
targetString: Config.darkWebVipName,
style: TextStyle(color: Color(0xffF68804)),
),
],
),
),
],
);
}
Widget _buildCoverNormalBottom() {
return Row(
children: [
Image.asset('circle_play.webp'.videoPath, width: 16),
1.sizeBoxW,
Text(
videoModel?.playCount?.countStr ?? "0",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white, fontSize: coverFontSize, height: 1.6),
),
Spacer(),
Text(
coverRightText ?? videoModel?.playTime?.hmsStr ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: coverFontSize,
),
),
],
);
}
Widget _buildCoverHYBottom() {
return Row(
children: [
Image.asset("eye_white.webp".commonImgPath, width: 16),
1.sizeBoxW,
Text(
videoModel?.pageViewCount?.countStr ?? "0",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white, fontSize: 10, height: 1.6),
),
Spacer(),
],
);
}
Widget _buildBottomWidget() {
if (isFromHY) {
final tagDesc =
videoModel?.tags?.map((e) => e.name ?? "").join("·") ?? "";
return Container(
padding: EdgeInsets.only(top: 3),
child: Text(
tagDesc,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 10,
),
),
);
} else {
return Container(
padding: EdgeInsets.only(top: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//标签可能是很长的脏数据(id)Flexible 才能让 ellipsis 生效,否则会撑爆 Row
Flexible(
child: videoModel?.tags?.isNotEmpty == true
? GestureDetector(
onTap: onTagTap == null
? null
: () => onTagTap!(videoModel!.tags!.first),
child: Container(
padding:
EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
videoModel?.tags?.first.name ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 10,
),
),
),
)
: const SizedBox.shrink(),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: Text(
"评论${videoModel?.commentCount?.countStr ?? 0}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 10,
),
),
),
],
),
);
}
}
}
//视频穿插广告
class VideoSimpleAdsCell extends StatefulWidget {
final VideoModel? videoModel;
final int textLines;
const VideoSimpleAdsCell({
super.key,
this.videoModel,
this.textLines = 2,
});
@override
State<VideoSimpleAdsCell> createState() => _VideoSimpleAdsCellState();
}
class _VideoSimpleAdsCellState extends State<VideoSimpleAdsCell> {
VideoModel? get videoModel => widget.videoModel;
int adIndex = 0;
List<AdsInfoModel>? get adsInfoArr =>
videoModel?.adsInfoArr; //?? mediaInfo?.adsInfoArr;
AdsInfoModel? get adsModel => videoModel?.randomAdsInfo;
// 广告点击:跳落地页
void _onAdTap() {
final ad = adsInfoArr![adIndex];
pushToPageByLink(ad.href);
}
@override
Widget build(BuildContext context) {
if (adsModel != null) {
return _buildSimpleAD(adsModel);
} else if (adsInfoArr?.isNotEmpty == true) {
return _buildMuliAds();
} else {
return SizedBox();
}
}
Widget _buildMuliAds() {
return InkWell(
enableFeedback: false,
onTap: _onAdTap,
child: LayoutBuilder(
builder: (ctx, constraints) {
// isHor true: 大横屏广告
bool isHor = (screen.screenWidth - 36) <= constraints.maxWidth;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AdsBannerWidget(
adsInfoArr,
autoPlayMs: 3000,
borderRadius: 4,
onIndexChanged: (index) {
adIndex = index;
if (mounted) setState(() {});
},
),
),
),
Container(
padding: EdgeInsets.only(top: 4),
// 广告轮播切到下一条时,标题做渐入渐出切换
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
layoutBuilder: (current, previous) => Stack(
alignment: Alignment.centerLeft,
children: [...previous, if (current != null) current],
),
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: child,
),
child: Text(
isHor
? adsInfoArr![adIndex].title?.trim() ?? ""
: "${adsInfoArr![adIndex].title?.trim() ?? ""}\n",
key: ValueKey(adIndex), // adIndex 变化触发切换动画
maxLines: widget.textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
),
if (!isHor)
Container(
padding: const EdgeInsets.only(top: 3),
child: Text(
"",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
);
},
),
);
}
Widget _buildSimpleAD(AdsInfoModel? adModel) {
return InkWell(
enableFeedback: false,
onTap: _onAdTap,
child: LayoutBuilder(
builder: (ctx, constraints) {
// isHor true: 大横屏广告
bool isHor = (screen.screenWidth - 32) <= constraints.maxWidth;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
AdsItem(
adInfo: adModel!,
borderRadius: 4,
showType: AdShowType.img,
),
Positioned(
top: 0,
left: 0,
child: Container(
width: 34,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [
Color.fromRGBO(255, 235, 58, 1),
Color.fromRGBO(255, 235, 58, 1),
]),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
)),
child: Text(
"广告",
style: TextStyle(
fontSize: 11,
color: Color.fromRGBO(0, 0, 0, 1),
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
Container(
padding: const EdgeInsets.only(top: 4),
child: Text(
isHor
? videoModel?.title ?? ""
: "${videoModel?.title ?? ""}\n",
maxLines: widget.textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
if (!isHor)
Container(
padding: const EdgeInsets.only(top: 3),
child: Text(
"",
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
);
},
),
);
}
}
+187
View File
@@ -0,0 +1,187 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../../hj_model/home/plate_model.dart';
import '../../hj_utils/screen.dart';
import 'home_drawer_logic.dart';
import 'home_main_logic.dart';
class HSHomeDrawer extends StatelessWidget {
final HomeMainLogic homeLogic;
const HSHomeDrawer({super.key, required this.homeLogic});
@override
Widget build(BuildContext context) {
return GetBuilder<HomeDrawerLogic>(
init: HomeDrawerLogic(homeLogic),
builder: (logic) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => logic.goBack(),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: SizedBox(
width: screen.screenWidth,
height: screen.screenHeight,
child: Align(
alignment: Alignment.topRight,
child: Container(
width: 212,
height: screen.screenHeight,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 18),
clipBehavior: Clip.antiAlias,
decoration: const BoxDecoration(color: Colors.black),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
32.sizeBoxH,
_headerRow(logic),
4.sizeBoxH,
Text(
'长按拖动排序',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 12,
),
),
18.sizeBoxH,
Expanded(child: _buildGridContent(logic)),
],
),
),
),
),
),
);
},
);
}
Widget _headerRow(HomeDrawerLogic logic) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'导航列表',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
InkWell(enableFeedback: false,
onTap: logic.resetToDefault,
child: Container(
padding: const EdgeInsets.fromLTRB(6, 2, 0, 6),
child: Text(
'恢复默认',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 12,
),
),
),
),
],
);
}
//抽屉列表:Stack + AnimatedPositioned,排序变化时 item 平滑滑到新位置
Widget _buildGridContent(HomeDrawerLogic logic) {
const cross = 2; // 列数
const spacing = 12.0; // 行列间距
const ratio = 84 / 40; // item 宽高比
return LayoutBuilder(
builder: (context, constraints) {
final itemW = (constraints.maxWidth - spacing * (cross - 1)) / cross;
final itemH = itemW / ratio;
final rows = (logic.dataArr.length / cross).ceil();
final totalH = rows * itemH + (rows > 0 ? rows - 1 : 0) * spacing;
return SingleChildScrollView(
child: SizedBox(
height: totalH,
child: Stack(
children: [
for (int i = 0; i < logic.dataArr.length; i++) _positionedItem(logic, i, itemW, itemH, spacing, cross),
],
),
),
);
},
);
}
//单个 item 的定位 + 交换补间动画(key 用 id 保证身份稳定,位置变化才会补间)
Widget _positionedItem(
HomeDrawerLogic logic,
int index,
double w,
double h,
double spacing,
int cross,
) {
final tabModel = logic.dataArr[index];
final isSelected = logic.curTagData?.id == tabModel.id;
final row = index ~/ cross;
final col = index % cross;
return AnimatedPositioned(
key: ValueKey(tabModel.id),
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
left: col * (w + spacing),
top: row * (h + spacing),
width: w,
height: h,
child: _buildDraggableItem(logic, tabModel, isSelected, w, h),
);
}
Widget _buildDraggableItem(HomeDrawerLogic logic, ModuleData tabModel, bool isSelected, double w, double h) {
return DragTarget<ModuleData>(
onAcceptWithDetails: (details) => logic.reorderItem(details.data, tabModel),
builder: (context, candidateData, rejectedData) {
final itemBody = Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: isSelected ? const Color(0xfff68804) : const Color(0x1AFFFFFF),
),
child: GestureDetector(
onTap: () => logic.goBack(selectedModel: tabModel),
child: Text(
tabModel.moduleName ?? '',
style: const TextStyle(fontSize: 14, color: Colors.white),
),
),
);
return LongPressDraggable<ModuleData>(
data: tabModel,
hapticFeedbackOnStart: false, // 关掉默认轻震,用下面更明显的震动
onDragStarted: () => HapticFeedback.mediumImpact(), // 长按触发时震一下
feedback: Material(
color: Colors.transparent,
child: Container(
width: w,
height: h,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: const Color(0xffF68804), // 主题色(拖起态不透明)
),
child: Text(
tabModel.moduleName ?? '',
style: const TextStyle(fontSize: 12, color: Colors.white),
),
),
),
childWhenDragging: Opacity(opacity: 0.4, child: itemBody), // 占位:半透明
child: itemBody,
);
},
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:get/get.dart';
import '../../hj_model/home/plate_model.dart';
import 'home_main_logic.dart';
class HomeDrawerLogic extends GetxController {
final HomeMainLogic homeLogic;
HomeDrawerLogic(this.homeLogic);
ModuleData? curTagData;
List<ModuleData> dataArr = [];
@override
void onInit() {
super.onInit();
curTagData = homeLogic.tabs[homeLogic.tabCtr?.index ?? 0];
dataArr = homeLogic.tabs.map((e) => e).toList();
}
void goBack({ModuleData? selectedModel}) async {
await homeLogic.resortTabs(dataArr, selectedModel);
Get.back();
}
//恢复默认排序
void resetToDefault() {
dataArr = homeLogic.defaultList.map((e) => e).toList();
update();
goBack();
}
//拖拽换位
void reorderItem(ModuleData from, ModuleData to) {
final oldIndex = dataArr.indexOf(from);
final newIndex = dataArr.indexOf(to);
final item = dataArr.removeAt(oldIndex);
dataArr.insert(newIndex, item);
update();
}
}
+209
View File
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/alert/vip_guide/guide_manager.dart';
import 'package:hgdj/alert/vip_guide/timed_popup_manager.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import '../../alert/splash/app_center_dialog.dart';
import '../../alert/splash/notice_image_dialog.dart';
import '../../alert/splash/notice_text_dialog.dart';
import '../../alert/vip_guide/guide_bottom_sheet.dart';
import '../../alert/vip_guide/guide_home_dialog.dart';
import '../../config/config.dart';
import '../../hj_model/home/plate_model.dart';
import '../../tools_base/debug_log.dart';
import '../../tools_base/module_sort_manager.dart';
import '../mine/mine_vip/mine_charge_vip_page.dart';
import '../mine/mine_vip/pay_order_source.dart';
import '../mine/widgets/gradient_text.dart';
import '../pre_sale/limit_time_provider.dart';
class HomeMainLogic extends GetxController {
final bool isDarkStyle;
final String? darkDefaultId; // 暗网跳转到指定的tab使用
HomeMainLogic({
this.isDarkStyle = false,
this.darkDefaultId,
});
//开屏弹窗链每次启动只跑一次
static bool _adShown = false;
bool isLoading = true;
int tabViewKey = 0; //排序变了就 +1 换掉 HomeTabView 的 key,强制重建 TabController
int _initIndex = 0; // 注意⚠️:暗网进入,有可能通过内链指定亚模块进入,index通过defaultId 处理
//合并本地排序后的 tab 列表,_loadTabs 里算一次
List<ModuleData> tabs = [];
final scaffoldKey = GlobalKey<ScaffoldState>();
//tabCtr 由 HomeTabView 在自身 vsync 下创建并 dispose,这里只持引用驱动 tab 切换;排序时置 null 触发重建
TabController? tabCtr;
String get _sortKey => "_localSortKey_Tab_ID_cache_$isDarkStyle";
int get selectedTabIndex => tabCtr?.index ?? _initIndex;
//服务端下发的原始顺序(抽屉「恢复默认」用)
List<ModuleData> get defaultList {
if (isDarkStyle) return Config.plateModule?.deepWeb ?? [];
// 固定最新模块
return [
ModuleData(id: "-110", moduleName: "最新", type: 1, showType: 1),
...?Config.plateModule?.homePage,
];
}
@override
void onInit() {
super.onInit();
_loadTabs();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
limitTimeProvider.refresh(); //获取限时活动(放首帧后,避免 build 中 notifyListeners)
_showAdChain();
});
}
void openEndDrawer() => scaffoldKey.currentState?.openEndDrawer();
void gotoTabId(String tagDataId) {
final index = tabs.indexWhere((e) => e.id == tagDataId);
if (index >= 0) tabCtr?.index = index;
}
/// 应用抽屉里的新排序:顺序没变只切 tab,变了就落盘并换 key 重建 TabView
Future<void> resortTabs(List<ModuleData> dataArr, ModuleData? tagData) async {
final curTagData = tagData ?? tabs[selectedTabIndex];
bool isSameArr = true;
int selectIndex = 0;
//抽屉只重排不增删,长度必然一致;逐项比名字,顺带定位选中项的新位置
if (dataArr.length == tabs.length) {
for (int i = 0; i < dataArr.length; i++) {
if (dataArr[i].moduleName != tabs[i].moduleName) isSameArr = false;
if (dataArr[i].moduleName == curTagData.moduleName) selectIndex = i;
}
}
if (isSameArr) {
tabCtr?.index = selectIndex;
return;
}
await ModuleSortManager().saveLocalData(dataArr, _sortKey);
tabViewKey++;
tabs = dataArr;
_initIndex = selectIndex;
tabCtr = null; //旧 State 即将销毁,先断引用,由新 State 重新赋值
update();
}
//合并本地排序与服务端 tab,顺带算好初始选中的 index
Future<void> _loadTabs() async {
try {
tabs = await ModuleSortManager().mergeLocalSortWithServer(
serverTabs: defaultList,
key: _sortKey,
);
if (darkDefaultId?.isNotEmpty == true) {
// 初始化指定模块(一般暗网模块用)
final index = tabs.indexWhere((e) => e.id == darkDefaultId);
if (index >= 0) _initIndex = index;
} else if (!isDarkStyle) {
//默认不显示最新模块,选中「热门」(index 1);但 tab 不足 2 个时回退到 0,避免越界
_initIndex = (tabs.first.moduleName == "最新" && tabs.length > 1) ? 1 : 0;
}
} catch (e) {
debugLog(e);
}
isLoading = false;
update();
}
/// 首页开屏弹窗链:系统提示 → 图片公告 → 文字公告 → 预售 → 支付分层 → VIP 引导。
/// 逐个 await 串行弹,每次启动只跑一次
Future<void> _showAdChain() async {
if (_adShown) return;
_adShown = true;
if (AdManager().showAbTestAd) {
await _showAppDialog(); //系统提示
await _showImageDialog(); //图片公告
await _showWordDialog(); //文字公告
}
await PreSaleProvider().showAlert(); //预售弹窗
await _showPayDialog(); //支付分层首页弹窗
await _showVipGuide(); //首页吸底 VIP 引导(全链优先级最后)
}
/// 支付分层首页弹窗:有 homePage 图就弹,倒计时由 lastDiscountTime 自行判断。
/// 点海报跳会员中心,这里 await 到用户从会员页返回,否则链条后面的吸底引导会弹在会员页上
Future<void> _showPayDialog() async {
final config = MineMsgProvider().payTier?.config;
if (config == null || (config.homePage ?? '').isEmpty) return;
final goVip = await Get.dialog<bool>(GuideHomeDialog(config: config));
if (goVip != true) return;
await Get.to(() => MineChargeVipPage(
vipID: config.vipCard, sourcePage: PaySourcePage.homeUserSegment));
}
/// 首页吸底 VIP 引导:开屏弹窗链末尾弹(全链最低优先级),能不能弹由 HOME_NEW_USER / HOME_OLD_USER
/// 两个场景开关决定(后端按分层下发,最多命中一个);文案优先用下发的,没配走默认。
/// 走 TimedPopupManager 与其它引导共用互斥;每次启动只弹一次(_showAdChain 由静态 _adShown 保证)。
Future<void> _showVipGuide() async {
final scene = GuideManager().homeScene;
if (scene == null) return;
final config = GuideManager().configOf(scene);
await TimedPopupManager().trigger(
canShow: () => GuideManager().canShow(scene),
onShow: () => GuideBottomSheet.show(
scene: scene,
title: Text(
config?.title?.isNotEmpty == true ? config!.title! : 'VIP影片免费试看',
style: const TextStyle(
color: Colors.white, fontSize: 19, fontWeight: FontWeight.w600),
),
subtitle: GradientText(
config?.description?.isNotEmpty == true
? config!.description!
: '开通会员 查看完整影片',
gradient:
const LinearGradient(colors: [Colors.white, Color(0xffEFD394)]),
style: const TextStyle(
color: Colors.white, fontSize: 15, fontWeight: FontWeight.w500),
),
),
);
}
///显示公告信息(9宫格应用广告)
Future<void> _showAppDialog() async {
final appList = AdManager().adsByType(3);
if (appList.isEmpty) return;
await Get.dialog(AppCenterDialog(appList: appList));
}
///显示图片公告(上下双排,每次弹两张)
Future<void> _showImageDialog() async {
final adsList = AdManager().adsByType(46);
for (int i = 0; i < adsList.length; i += 2) {
await Get.dialog(
SystemImagesDialog(ads: adsList.skip(i).take(2).toList()));
}
}
//文字公告
Future<void> _showWordDialog() async {
for (final wordBean in AdManager().announceList) {
await Get.dialog(
wordBean.type == 0
? NoticeTextDialog(
content: wordBean.content.toString(), wordBean: wordBean)
: NoticeImageDialog(
model:
AdsInfoModel(cover: wordBean.cover, href: wordBean.href)),
);
}
}
}
+219
View File
@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/home/provider/home_update_marker_provider.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:provider/provider.dart';
import '../cartoon/cartoon_sub_detail_page.dart';
import '../main_page/provider/bottom_bar_provider.dart';
import 'activity_float/home_float_widget.dart';
import 'home_drawer.dart';
import 'home_main_logic.dart';
import 'home_sub_module/home_tab_section_page.dart';
import 'home_sub_module/home_tab_sort_page.dart';
import 'search_page/widget/common_search_widget.dart';
class HomeMainPage extends StatelessWidget {
const HomeMainPage({super.key});
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xff050f17),
child: GetBuilder<HomeMainLogic>(
init: HomeMainLogic(),
tag: "HomeMainLogic",
builder: (logic) {
return Stack(
fit: StackFit.expand,
children: [
Scaffold(
key: logic.scaffoldKey,
endDrawer: HSHomeDrawer(homeLogic: logic),
body: _body(logic),
),
//右下角活动浮窗
const Positioned(right: 12, bottom: 40, child: HomeFloatWidget()),
],
);
},
),
);
}
Widget _body(HomeMainLogic logic) {
if (logic.isLoading) return const LoadingCenterWidget();
return Column(
children: [
//顶部搜索栏(状态栏 + 导航栏高度)
Container(
color: Colors.black,
padding: EdgeInsets.only(top: screen.paddingTop),
height: kToolbarHeight + screen.paddingTop,
child: CommonSearchBarView(logic: logic),
),
//排序变化时 tabViewKey 变,整个 TabView 连 TabController 一起重建
Expanded(
child: HomeTabView(key: ValueKey(logic.tabViewKey), logic: logic)),
],
);
}
}
class HomeTabView extends StatefulWidget {
final HomeMainLogic logic;
final bool isDarkStyle;
const HomeTabView({
super.key,
required this.logic,
this.isDarkStyle = false,
});
@override
State<HomeTabView> createState() => _HomeTabViewState();
}
class _HomeTabViewState extends State<HomeTabView>
with SingleTickerProviderStateMixin {
HomeMainLogic get logic => widget.logic;
bool get isDarkStyle => widget.isDarkStyle;
late final TabController tabCtr;
@override
void initState() {
super.initState();
final count = logic.tabs.length;
tabCtr = TabController(
// 防止 tab 数量不足时 initialIndex 越界导致断言崩溃
initialIndex: count > 0 ? logic.selectedTabIndex.clamp(0, count - 1) : 0,
length: count,
vsync: this,
);
tabCtr.addListener(_onTabChange);
logic.tabCtr = tabCtr;
}
@override
void dispose() {
// controller 在本 State vsync 创建,归本 State 释放(排序换 key 会重建 State)
tabCtr.removeListener(_onTabChange);
tabCtr.dispose();
// 换 key 重建时新 State 可能已把自己的 ctr 写进 logic,只清掉自己那一份
if (identical(logic.tabCtr, tabCtr)) logic.tabCtr = null;
super.dispose();
}
void _onTabChange() {
if (tabCtr.indexIsChanging) return;
if (isDarkStyle) {
DarkwebBottomProvider().setScrollController(tabCtr.index);
return;
}
HomeBottomProvider().setScrollController(tabCtr.index);
final tabs = logic.tabs;
if (tabCtr.index < tabs.length && tabs[tabCtr.index].id == '-110') {
// 进入「最新」:清 Tab 红点;默认排序即「今日最新」,一并视为已查看
HomeUpdateMarkerProvider().markHomeLatestViewed();
HomeUpdateMarkerProvider().markTodayLatestViewed();
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
height: 48.h,
color: Colors.black,
child: _tabBar(),
),
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
if (isDarkStyle) Image.asset("aw_bg.webp".homePath),
TabBarView(
controller: tabCtr,
children: [
for (int i = 0; i < logic.tabs.length; i++) _tabPage(i)
],
),
],
),
),
],
);
}
Widget _tabBar() {
return Consumer<HomeUpdateMarkerProvider>(
builder: (_, marker, __) {
return TabBar(
tabAlignment: TabAlignment.start,
tabs: logic.tabs.map((e) {
//「最新」有更新时右上角挂红点
final showDot =
!isDarkStyle && e.id == '-110' && marker.showHomeLatestDot;
return Container(
padding: EdgeInsets.symmetric(horizontal: 14.w),
alignment: Alignment.center,
child: Stack(
clipBehavior: Clip.none,
children: [
Text(e.moduleName ?? ''),
if (showDot)
Positioned(
right: -8,
top: -2,
child: HomeUpdateMarkerProvider.buildRedDot(),
),
],
),
);
}).toList(),
isScrollable: true,
padding: const EdgeInsets.only(bottom: 2),
labelPadding: EdgeInsets.zero,
unselectedLabelStyle: const TextStyle(fontSize: 14),
unselectedLabelColor: const Color(0x73FFFFFF),
labelStyle:
const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
labelColor:
isDarkStyle ? const Color(0xff810906) : const Color(0xE5FFFFFF),
indicator: CustomIndicator(
height: 2,
width: 16.w,
color: const Color(0xfff68804),
offsetY: 4,
),
controller: tabCtr,
);
},
);
}
/// 按 module type/showType/isACG 分发到对应子页,统一 keepAlive
Widget _tabPage(int index) {
final module = logic.tabs[index];
if (module.type == 1 || module.type == 3) {
//showType==2 走分区聚合页,其余走排序列表页
return module.showType == 2
? HomeTabSectionPage(index, module, isDarkStyle: isDarkStyle)
.keepAlive
: HomeTabSortPage(index, module, isDarkStyle: isDarkStyle).keepAlive;
}
if (module.isACG) {
return CartoonSubDetailPage(tagData: module, type: MediaStyle.Cartoon)
.keepAlive;
}
return HomeTabSortPage(index, module, isDarkStyle: isDarkStyle).keepAlive;
}
}
@@ -0,0 +1,56 @@
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_model/cartoon_media_info.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_model/video_model.dart';
import '../../main_page/provider/bottom_bar_provider.dart';
/// 首页各 tab 逻辑基类:分页状态 + 列表数据 + 滚动/刷新控制器
/// 子类 HomeTabSortLogic(排序 tab/ HomeTabSectionLogic(专题 tab)复用
class HomeTabBaseLogic extends GetxController {
// ===== 外部传入 =====
final ModuleData tabModel; // tab 配置
final int index; // tab 下标
final bool isDarkStyle; // 是否暗网风格
HomeTabBaseLogic(this.index, this.tabModel, {this.isDarkStyle = false});
// ===== 状态 =====
int currentPage = 1;
bool isLoadingData = true;
// ===== 数据 =====
ModuleDetailModel? dataSource;
// ===== Controller =====
// scrollCtr 注册进单例 BottomProvider(持引用 + 加监听 + scrollToTop),生命周期比本类长;
// 本类不 dispose,否则单例再操作已释放的控制器会崩(交给单例托管,谁最终使用谁负责)
final ScrollController scrollCtr = ScrollController();
// refreshCtr 由页面 pullYsRefresh 的 onInit 注入、CustomRefreshView 负责释放,本类只持引用
RefreshController? refreshCtr;
// ===== 派生 getter =====
bool get isShowAd => tabModel.pureVersion != true; // 纯净版不展示广告
List<CartoonMediaInfo> get allMedia => dataSource?.allMediaInfo ?? [];
List<VideoModel> get allVideo => dataSource?.allVideoInfo ?? [];
List<AllSection> get allSection => dataSource?.allSection ?? [];
List<VideoModel> get chosenVideoInfo => dataSource?.chosenVideoInfo ?? []; // 精选视频
@override
void onInit() {
super.onInit();
_registerScrollController();
}
// 把本 tab 的滚动控制器注册到底部栏 Provider,联动「回到顶部」按钮与吸顶态
void _registerScrollController() {
if (isDarkStyle) {
DarkwebBottomProvider().setScrollController(index, controller: scrollCtr);
} else {
HomeBottomProvider().setScrollController(index, controller: scrollCtr);
}
}
}
@@ -0,0 +1,136 @@
import 'package:hgdj/tools_base/ad_manager.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../tools_base/debug_log.dart';
import '../../../tools_base/global_store/store.dart';
import 'home_tab_base_logic.dart';
class HomeTabSectionLogic extends HomeTabBaseLogic {
HomeTabSectionLogic(super.index, super.tabModel, {super.isDarkStyle});
bool lastIsGuessLike = false; // true 最后一个是猜你喜欢
//猜你喜欢当前选中的 tab **下标**,与 GuessLikeSliver 的标题一一对应:
//0 最多收藏 / 1 最新上架 / 2 最多观看
int guessLikeSortType = 0;
//上面的下标 → 后端 sort 值:最多收藏=0 最新上架=1 最多观看=3。
//标题在 GuessLikeSliver 里、值在这里,两处顺序必须一致,加减一项就会整体错位
List<int> gLikeSortParam = [0, 1, 3];
int guessLikePage = 1;
@override
void onReady() {
initData();
super.onReady();
}
void sortGuessLikeExchange(int value) {
if (value == guessLikeSortType) {
return;
}
if (lastIsGuessLike) {
allSection.last.allVideoInfo = null;
}
guessLikeSortType = value;
update();
_loadGuessLike(page: 1, sortType: guessLikeSortType);
}
void initData() async {
isLoadingData = true;
update();
_loadData();
}
void refreshData() {
_loadData();
}
void loadMoreData() {
if (lastIsGuessLike) {
_loadGuessLike(page: guessLikePage + 1, sortType: guessLikeSortType);
} else {
_loadData(page: currentPage + 1);
}
}
void _loadData({int page = 1, int size = 5}) async {
try {
ModuleDetailModel? retResp = await VidService.getModuleDetail(
tabModel.id ?? '',
pageNumber: page,
pageSize: size);
currentPage = page;
retResp?.allSection
?.removeWhere((element) => element.allVideoInfo?.isEmpty ?? true);
if (currentPage == 1) {
dataSource = retResp;
} else {
dataSource?.allSection?.addAll(retResp?.allSection ?? []);
}
//ab测试
for (AllSection section in (dataSource?.allSection ?? [])) {
globalStore.filterShowType(section.allVideoInfo ?? []);
}
if (isShowAd) {
AdManager().insertSectionAds(
dataSource?.allSection ?? [],
AdManager().adsByType(5),
adGap: 2,
);
}
if (retResp?.hasNext == true) {
lastIsGuessLike = false;
refreshCtr?.loadComplete();
} else {
if (retResp?.allSection?.last.isGuessLike == true) {
lastIsGuessLike = true;
guessLikeSortType = 0;
guessLikePage = 1;
if (dataSource?.allSection?.last.isAdsArr() == true) {
dataSource?.allSection?.removeLast();
}
retResp?.allSection?.last.allVideoInfo = null;
update();
await _loadGuessLike();
} else {
lastIsGuessLike = false;
refreshCtr?.loadNoData();
}
}
} catch (e) {
debugLog(e);
refreshCtr?.loadComplete();
}
refreshCtr?.refreshCompleted();
isLoadingData = false;
update();
}
Future _loadGuessLike({int page = 1, int size = 10, int sortType = 0}) async {
try {
AllSection lastSection = allSection.last;
int sortValue = gLikeSortParam[sortType];
AllSection? retModel = await VidService.getGuessLike(
page, size, tabModel.id ?? "", sortValue);
if (sortType != guessLikeSortType) {
return;
}
guessLikePage = page;
lastSection.allVideoInfo ??= [];
if (lastSection.allVideoInfo?.isNotEmpty == true && page == 1) {
lastSection.allVideoInfo?.clear();
}
lastSection.allVideoInfo?.addAll(retModel?.allVideoInfo ?? []);
retModel?.hasNext == false
? refreshCtr?.loadNoData()
: refreshCtr?.loadComplete();
} catch (e) {
refreshCtr?.loadComplete();
}
refreshCtr?.refreshCompleted();
update();
}
}
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../tools_base/banner/ads_grid_view_widget.dart';
import '../../../tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import '../home_cell_style/guess_like_sliver.dart';
import '../home_cell_style/quick_entry_row.dart';
import '../home_cell_style/home_section_cell.dart';
import 'home_tab_section_logic.dart';
/// 海角样式
class HomeTabSectionPage extends StatefulWidget {
final ModuleData tabModel;
final bool isDarkStyle;
final int tabIndex;
const HomeTabSectionPage(
this.tabIndex,
this.tabModel, {
super.key,
this.isDarkStyle = false,
});
@override
State<HomeTabSectionPage> createState() => _HomeTabSectionPageState();
}
class _HomeTabSectionPageState extends State<HomeTabSectionPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<HomeTabSectionLogic>(
init: HomeTabSectionLogic(widget.tabIndex, widget.tabModel,
isDarkStyle: widget.isDarkStyle),
tag: uniqueTag,
builder: (logic) {
return Container(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onLoading: (ctr) => logic.loadMoreData(),
onRefresh: (ctr) => logic.refreshData(),
child: CustomScrollView(
controller: logic.scrollCtr,
slivers: [
if (logic.isShowAd)
SliverToBoxAdapter(
child: AdsGridViewWidget(
4,
accordingAdsType: true,
padding: EdgeInsets.fromLTRB(12, 0, 12, 12),
),
),
SliverToBoxAdapter(child: QuickEntryRow(widget.tabModel)),
if (logic.isLoadingData)
SliverToBoxAdapter(
child: Container(
height: 300,
child: LoadingCenterWidget(),
),
)
else if (logic.allSection.isEmpty)
SliverToBoxAdapter(
child: Container(
height: 300,
child: CErrorWidget(
retryOnTap: () => logic.initData(),
),
),
)
else
..._buildContent(logic),
],
),
),
);
},
);
}
List<Widget> _buildContent(HomeTabSectionLogic logic) {
return [
SliverToBoxAdapter(child: SizedBox(height: 12)),
SliverList.builder(
itemBuilder: (BuildContext context, int index) {
AllSection section = logic.allSection[index];
if (section.isAdsArr()) {
return AdsGridViewWidget(
-1,
adsArr: section.adsInfoArr ?? [],
padding: EdgeInsets.fromLTRB(12, 0, 12, 16),
);
} else if (logic.lastIsGuessLike &&
index == logic.allSection.length - 1) {
return SizedBox(); // 最后一个猜你喜欢列表单独处理
} else if (section.allVideoInfo?.isNotEmpty == true) {
//换一批、间距、分割线都在 HomeSectionCell 里
return HomeSectionCell(section);
} else {
return SizedBox();
}
},
itemCount: logic.allSection.length,
),
if (logic.lastIsGuessLike)
GuessLikeSliver(
logic.allSection.last,
sortIndex: logic.guessLikeSortType,
logic: logic,
),
];
}
}
@@ -0,0 +1,186 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/home/video_list_model.dart';
import 'package:hgdj/hj_page/home/provider/home_update_marker_provider.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:uuid/uuid.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../tools_base/ad_manager.dart';
import '../../../tools_base/debug_log.dart';
import '../../../tools_base/global_store/store.dart';
import 'home_tab_base_logic.dart';
const Interval = 4;
class HomeTabSortLogic extends HomeTabBaseLogic {
HomeTabSortLogic(super.index, super.tabModel, {super.isDarkStyle});
//普通排序:后端没配就用这套默认
List<SortTab<int>> get _sortTabs =>
tabModel.sortTabs ??
const [
SortTab('热门推荐', 2),
SortTab('最新上架', 1),
SortTab('最新热评', 9),
SortTab('最多收藏', 7),
];
List<String> get sortTitles => _sortTabs.map((e) => e.name).toList();
// 初值跟第一个 tab 走:HomeSortHeader 的 TabController 默认选中 index 0,而后端可能用 top 把别的排序项置顶,
// 写死 2 就会出现「高亮第一个 tab、请求发的却是本月最热」的错位(连带影响精选视频展示、随机刷新判断)
late int moduleSort = _sortTabs.first.sort; // 1: 最新发布 2:本月最热 9:最新热评 7:最多收藏
//"最新"风格固定排序
static const _lastestSortTabs = [
SortTab('今日最新', 1),
SortTab('本周最热', 2),
SortTab('本月最热', 4),
SortTab('年度最热', 5),
];
List<String> get lastestSortTitles =>
_lastestSortTabs.map((e) => e.name).toList();
int lastestModuleSort = 1;
// 一排几个:haiJiaoStyle.defaultShow 0-一排两个(网格) 1-一排一个(单列),缺省按网格
late final RxBool isGridStyle = (tabModel.haiJiaoStyle?.defaultShow != 1).obs;
RxBool isSortMenuInTop = false.obs;
bool get isNewestStyle => tabModel.id == '-110';
// 请求序号:每发一次 +1,响应回来时序号已变说明期间切了排序 / 又发了新请求,旧响应直接丢弃。
// 否则先发后回的结果会盖掉新排序的列表,或把分页偏移打乱(如上一页的 addAll 追加到刷新后的列表尾部)
int _reqSeq = 0;
// 当前排序项是否走随机刷新接口:只认后端下发的 refreshMode,不匹配标题、不硬编码 sort 值
// 暗网亚模块产品上就不做随机,别删这个判断——后端对没配 refreshMode 的历史数据会把
// val=2 兜底成 RANDOM_TOP_N,只靠后端配置挡不住
bool get _isRandomRefresh {
if (isNewestStyle || isDarkStyle) return false;
return tabModel.sortRuleOf(moduleSort)?.isRandomRefresh == true;
}
void initData() async {
isLoadingData = true;
update();
_loadData(sortValue: isNewestStyle ? lastestModuleSort : moduleSort);
}
@override
void onReady() {
initData();
super.onReady();
}
void sortMenuEvent(int value) {
if (isNewestStyle) {
lastestModuleSort = _lastestSortTabs[value].sort;
if (lastestModuleSort == 1) {
HomeUpdateMarkerProvider().markTodayLatestViewed();
}
} else {
moduleSort = _sortTabs[value].sort;
}
isLoadingData = true;
update();
_loadData(sortValue: isNewestStyle ? lastestModuleSort : moduleSort);
}
void onTooleAction() {
isGridStyle.value = !isGridStyle.value;
update();
}
void refreshData() {
// 随机刷新排序项每次主动下拉都换新 token,后端据此重排候选池;
// 网络层自动重试沿用同一 URL(token 不变),拿到的顺序与首次一致,不会跳序
_loadData(
sortValue: isNewestStyle ? lastestModuleSort : moduleSort,
refreshToken: _isRandomRefresh ? const Uuid().v4() : null,
);
}
void loadMoreData() {
_loadData(
page: currentPage + 1,
sortValue: isNewestStyle ? lastestModuleSort : moduleSort);
}
// 每页固定 30:随机刷新接口也传同一个 size,分页偏移才对齐不重叠
// [refreshToken] 非空 = 走热门随机刷新接口,只换视频列表
void _loadData(
{int page = 1,
int size = 30,
int sortValue = 0,
String? refreshToken}) async {
final seq = ++_reqSeq;
try {
bool hasNext = false;
if (isNewestStyle) {
VideoListResp? retResp = await VidService.getNewestModule(
sortType: sortValue,
pageNumber: page,
pageSize: size,
);
if (seq != _reqSeq) return; // 已被更新的请求取代,本次结果作废
currentPage = page;
if (currentPage == 1) {
dataSource = ModuleDetailModel(allVideoInfo: retResp?.videos ?? []);
} else {
dataSource?.allVideoInfo?.addAll(retResp?.videos ?? []);
}
hasNext = retResp?.hasNext ?? false;
} else if (refreshToken != null && dataSource != null) {
// 热门随机刷新:接口只回 allVideoInfo,专题/精选/漫画等结构沿用上次结果,不能整个换掉 dataSource
// 首屏还没成功过(dataSource 为空)时不走这里,让它走下面的常规接口拿全量结构
final retResp = await VidService.refreshRandomModule(
tabModel.id ?? '',
moduleSort: sortValue,
refreshToken: refreshToken,
pageSize: size,
);
if (seq != _reqSeq) return; // 已被更新的请求取代,本次结果作废
// 刷新接口的 hasNext 恒为 true(随机只换第一页,到底没到底由后续常规分页说了算),取不到按 true 兜底
hasNext = retResp?.hasNext ?? true;
final list = retResp?.allVideoInfo;
// 请求失败或响应没带 allVideoInfo 都算这次刷新没成:列表和页码原样留着,
// 别把用户正看着的内容清空、也别把分页偏移打乱(后端真没内容会回空数组,那才该清)
if (list != null) {
dataSource?.allVideoInfo = list;
currentPage = 1; // 刷新成功才重置页码,下次上拉从 pageNumber=2 开始
}
} else {
ModuleDetailModel? retResp = await VidService.getModuleDetail(
tabModel.id ?? '',
moduleSort: sortValue,
pageNumber: page,
pageSize: size);
if (seq != _reqSeq) return; // 已被更新的请求取代,本次结果作废
currentPage = page;
if (currentPage == 1) {
dataSource = retResp;
} else {
dataSource?.allVideoInfo?.addAll(retResp?.allVideoInfo ?? []);
dataSource?.allMediaInfo?.addAll(retResp?.allMediaInfo ?? []);
}
hasNext = retResp?.hasNext ?? false;
}
//ab测试
globalStore.filterShowType(dataSource?.allVideoInfo ?? []);
dataSource?.allVideoInfo?.removeWhere((element) => element.isRandomAd());
if (isShowAd) {
AdManager().insertGroupAds(
dataSource?.allVideoInfo ?? [], AdManager().adsByType(11),
adGap: 6);
}
hasNext ? refreshCtr?.loadComplete() : refreshCtr?.loadNoData();
} catch (e) {
debugLog(e);
if (seq != _reqSeq) return; // 旧请求失败别去动刷新态,新请求还在跑
refreshCtr?.loadComplete();
}
refreshCtr?.refreshCompleted();
isLoadingData = false;
update();
}
}
@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import '../../../hj_model/cartoon_media_info.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/sliver_delegate.dart';
import '../../../tools_base/banner/ads_grid_view_widget.dart';
import '../../../tools_base/refresh/pull_refresh.dart';
import '../../cartoon/acg_widget_item.dart';
import '../home_cell_style/quick_entry_row.dart';
import '../home_cell_style/video_simple_cell.dart';
import 'home_tab_sort_logic.dart';
import 'widget/sort_header.dart';
import 'widget/special_topics_view.dart';
/// 海角样式
class HomeTabSortPage extends StatefulWidget {
final ModuleData tabModel;
final bool isDarkStyle;
final int tabIndex;
const HomeTabSortPage(
this.tabIndex,
this.tabModel, {
super.key,
this.isDarkStyle = false,
});
@override
State<HomeTabSortPage> createState() => _HomeTabSortPageState();
}
class _HomeTabSortPageState extends State<HomeTabSortPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<HomeTabSortLogic>(
init: HomeTabSortLogic(widget.tabIndex, widget.tabModel,
isDarkStyle: widget.isDarkStyle),
tag: uniqueTag,
builder: (logic) {
return Stack(
children: [
Container(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onLoading: (ctr) => logic.loadMoreData(),
onRefresh: (ctr) => logic.refreshData(),
child: CustomScrollView(
controller: logic.scrollCtr,
slivers: [
SliverToBoxAdapter(
child: 6.sizeBoxH,
),
if (logic.isShowAd && !logic.isNewestStyle)
SliverToBoxAdapter(
child: AdsGridViewWidget(
4,
accordingAdsType: true,
padding: EdgeInsets.fromLTRB(12, 0, 12, 12),
),
),
SliverToBoxAdapter(child: QuickEntryRow(widget.tabModel)),
SliverToBoxAdapter(
child: SpecialTopicsView(
logic.allSection,
module: widget.tabModel,
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
isDarkStyle: widget.isDarkStyle,
),
),
if (widget.tabModel.haiJiaoStyle?.sortShow == 1 ||
logic.isNewestStyle)
SliverPersistentHeader(
pinned: true,
delegate: MySliverDelegate(
maxHeight: 42,
minHeight: 42,
callTop: (shrinkOffset) {
if (shrinkOffset > 0) {
WidgetsBinding.instance
.addPostFrameCallback((timeStamp) {
logic.isSortMenuInTop.value = false;
});
}
if (shrinkOffset <= 0) {
WidgetsBinding.instance
.addPostFrameCallback((timeStamp) {
logic.isSortMenuInTop.value = true;
});
}
},
child: Obx(
() {
Color? bgColor;
if (widget.isDarkStyle) {
if (logic.isSortMenuInTop.value) {
bgColor = Colors.transparent;
} else {
bgColor = Colors.black;
}
}
return HomeSortHeader(
isLatest: logic.isNewestStyle,
labelPadding:
const EdgeInsets.symmetric(horizontal: 10),
tabs: logic.isNewestStyle
? logic.lastestSortTitles
: logic.sortTitles,
bgColor: bgColor,
onSort: logic.sortMenuEvent,
rightWidget: logic.isNewestStyle
? null
: _buildSortStyleMenu(logic),
);
},
),
),
),
_buildContent(logic),
],
),
),
),
],
);
},
);
}
Widget _buildSortStyleMenu(HomeTabSortLogic logic) {
if (widget.tabModel.isACG) return SizedBox();
return Container(
padding: EdgeInsets.only(right: 12),
child: InkWell(
enableFeedback: false,
onTap: logic.onTooleAction,
child: Row(
children: [
Text(
'切换',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: .6),
),
),
2.sizeBoxW,
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Image.asset(
logic.isGridStyle.value
? 'list_style.webp'.homePath
: 'grid_style.webp'.homePath,
key: ValueKey(logic.isGridStyle.value),
width: 18,
),
),
],
),
),
);
}
Widget _buildContent(HomeTabSortLogic logic) {
if (logic.isLoadingData) {
return SliverFillRemaining(
child: LoadingCenterWidget(),
);
} else if (logic.allVideo.isEmpty && logic.allMedia.isEmpty) {
return SliverFillRemaining(
child: CErrorWidget(
retryOnTap: () => logic.initData(),
),
);
} else {
if (widget.tabModel.isACG) {
return SliverPadding(
padding: EdgeInsets.fromLTRB(16, 10, 16, 0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 5,
childAspectRatio: 111 / 194, //子控件宽高比
),
delegate: SliverChildBuilderDelegate(
(context, index) {
CartoonMediaInfo info = logic.allMedia[index];
return AcgItemWidget(info: info);
},
childCount: logic.allMedia.length,
),
),
);
} else {
if (widget.tabModel.haiJiaoStyle?.showChosenVideo == 1 &&
logic.chosenVideoInfo.isNotEmpty &&
logic.moduleSort == 2) {
//热门推荐,每个6个插入一个精品视频数据
return SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
sliver: SliverMainAxisGroup(
slivers: _buildChosenVideoStyle(logic),
),
);
} else {
return SliverPadding(
padding: EdgeInsets.fromLTRB(16, 10, 16, 0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: logic.isGridStyle.value ? 2 : 1,
mainAxisSpacing: logic.isGridStyle.value ? 12 : 14,
crossAxisSpacing: 7,
childAspectRatio:
logic.isGridStyle.value ? 168 / 164 : 340 / 232, //子控件宽高比
),
delegate: SliverChildBuilderDelegate(
(context, index) {
VideoModel videoModel = logic.allVideo[index];
return GestureDetector(
onTap: () async {
pushToVideoPage(videoModel: videoModel);
},
child: VideoSimpleCell(
videoModel: videoModel,
textLines: logic.isGridStyle.value ? 2 : 1,
),
);
},
childCount: logic.allVideo.length,
),
),
);
}
}
}
}
List<Widget> _buildChosenVideoStyle(HomeTabSortLogic logic) {
List<Widget> slivers = [];
int gapValue = 6;
int allIndex = logic.allVideo.length ~/ gapValue;
int leftCount = logic.allVideo.length % gapValue;
int insertIndex = 0;
for (int i = 0; i < allIndex; i++) {
List<VideoModel> subList =
logic.allVideo.sublist(i * gapValue, i * gapValue + gapValue);
slivers.add(SliverPadding(
padding: EdgeInsets.only(bottom: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: logic.isGridStyle.value ? 2 : 1,
mainAxisSpacing: logic.isGridStyle.value ? 10 : 12,
crossAxisSpacing: 7,
childAspectRatio:
logic.isGridStyle.value ? 168 / 144 : 343 / 260, //子控件宽高比
),
delegate: SliverChildBuilderDelegate(
(context, index) {
VideoModel videoModel = subList[index];
return VideoSimpleCell(
videoModel: videoModel,
);
},
childCount: subList.length,
),
),
));
if (insertIndex < logic.chosenVideoInfo.length) {
VideoModel insertVM = logic.chosenVideoInfo[insertIndex];
slivers.add(SliverToBoxAdapter(
child: Container(
padding: EdgeInsets.only(bottom: 12),
child: AspectRatio(
aspectRatio: 343 / 260,
child: VideoSimpleCell(videoModel: insertVM),
),
),
));
insertIndex++;
}
}
if (leftCount > 0) {
List<VideoModel> subList = logic.allVideo.sublist(allIndex * gapValue);
slivers.add(SliverPadding(
padding: EdgeInsets.only(bottom: 12),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: logic.isGridStyle.value ? 2 : 1,
mainAxisSpacing: logic.isGridStyle.value ? 10 : 12,
crossAxisSpacing: 7,
childAspectRatio:
logic.isGridStyle.value ? 168 / 144 : 343 / 260, //子控件宽高比
),
delegate: SliverChildBuilderDelegate(
(context, index) {
VideoModel videoModel = subList[index];
return VideoSimpleCell(
videoModel: videoModel,
);
},
childCount: subList.length,
),
),
));
}
return slivers;
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_page/home/provider/home_update_marker_provider.dart';
import 'package:provider/provider.dart';
/// 首页模块的排序切换栏(热门推荐 / 最新上架 / ...)
/// [isLatest]:「最新」页专用紧凑样式——字号更小、选中态是红色胶囊块、首个 tab 带更新红点
class HomeSortHeader extends StatefulWidget {
final ValueChanged<int> onSort;
final List<String>? tabs;
final EdgeInsets? labelPadding;
final Widget? rightWidget;
final Color? bgColor;
final bool isLatest;
const HomeSortHeader({
super.key,
required this.onSort,
this.tabs,
this.labelPadding,
this.rightWidget,
this.bgColor,
this.isLatest = false,
});
@override
State<HomeSortHeader> createState() => _HomeSortHeaderState();
}
class _HomeSortHeaderState extends State<HomeSortHeader>
with SingleTickerProviderStateMixin {
late final List<String> tabs =
widget.tabs ?? const ['热门推荐', '最新上架', '最新热评', '最多收藏'];
late final TabController tabCtr =
TabController(length: tabs.length, vsync: this);
@override
void dispose() {
tabCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: 38,
color: widget.bgColor ?? Theme.of(context).scaffoldBackgroundColor,
child: widget.rightWidget == null
? _tabBar()
: Row(children: [Expanded(child: _tabBar()), widget.rightWidget!]),
);
}
Widget _tabBar() {
final fontSize = widget.isLatest ? 12.0 : 14.0;
return TabBar(
controller: tabCtr,
isScrollable: true,
tabAlignment: TabAlignment.center,
onTap: widget.onSort,
indicatorWeight: 0,
//最新页选中态是红色胶囊块,普通页无指示器(靠字号/字重区分)
indicator: widget.isLatest
? BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3))
: const BoxDecoration(),
labelPadding: widget.isLatest
? const EdgeInsets.symmetric(horizontal: 4)
: widget.labelPadding ?? const EdgeInsets.symmetric(horizontal: 8),
labelStyle: TextStyle(
color: const Color(0xE5FFFFFF),
fontSize: fontSize,
fontWeight: FontWeight.w600,
),
unselectedLabelStyle:
TextStyle(color: const Color(0x73FFFFFF), fontSize: fontSize),
tabs: List.generate(tabs.length, (i) => _tab(i, tabs[i])),
);
}
/// 单个 tab。红点只可能落在「最新」页的首个 tab 上,
/// 所以只让它订阅 provider——其余 tab 不会因红点变化而重建
Widget _tab(int index, String label) {
if (!widget.isLatest) return Text(label);
final text = Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: Text(label),
);
if (index != 0) return text;
return Consumer<HomeUpdateMarkerProvider>(
builder: (_, marker, __) => Stack(
clipBehavior: Clip.none,
children: [
text, // Stack 尺寸取它,有无红点 tab 宽高一致
if (marker.showTodayLatestDot)
Positioned(
right: 4,
top: 0,
child: HomeUpdateMarkerProvider.buildRedDot()),
],
),
);
}
}
@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/home/plate_model.dart';
import 'package:hgdj/hj_model/home/module_detail_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/refresh/horizontal_load_more.dart';
import '../../../../tools_base/widget/net_image_widget.dart';
import '../../../cartoon/cartoon_sectionlist_page.dart';
import '../../section_all_page/section_all_page.dart';
import '../../special_topic_detail/special_topics_detail_page.dart';
/// 首页/漫画页的专题位,样式由 [module].haiJiaoStyle.sectionStyle 决定:
/// 0-不展示 1-圆头像横滑(17岁) 2/3-原创达人(女优/网黄) 4-文字标签网格 5-带标题的圆头像横滑(图列)
class SpecialTopicsView extends StatelessWidget {
final List<AllSection>? specials;
final ModuleData? module;
final EdgeInsetsGeometry? padding;
final bool isDarkStyle;
const SpecialTopicsView(
this.specials, {
super.key,
this.module,
this.padding,
this.isDarkStyle = false,
});
int get _style => module?.haiJiaoStyle?.sectionStyle ?? 0;
@override
Widget build(BuildContext context) {
final list = specials;
if (list == null || list.isEmpty) return const SizedBox.shrink();
switch (_style) {
case 1:
return _avatarRow(list, height: 88, imgSize: 60);
case 2:
case 3:
return _actressSection(list);
case 4:
return _tagGrid(list);
case 5:
return _imageSection(list);
default:
return const SizedBox.shrink();
}
}
/// 进「原创达人」全部列表页。标题右侧的「更多」和横滑到底松手都走这里
void _gotoSectionAll() => Get.to(SectionAllPage(module?.id ?? ""));
/// 专题点击:漫画模块进漫画专区列表,其余进专题详情
/// 注:原创达人区不走这里,它固定进专题详情(见 [_actressSection]
void _onTap(AllSection model) {
if (module?.isACG == true) {
Get.to(CartoonSectionListPage(
sectionID: model.sectionID, tagName: model.sectionName));
} else {
Get.to(SpecialTopicsDetailPage(model), preventDuplicates: false);
}
}
// ========== 样式 1 / 5:圆头像横滑 ==========
Widget _avatarRow(List<AllSection> list,
{required double height, required double imgSize}) {
return Container(
margin: padding,
height: height,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: list.length,
itemBuilder: (_, i) => _avatarItem(list[i], imgSize),
),
);
}
Widget _avatarItem(AllSection model, double imgSize) {
return GestureDetector(
onTap: () => _onTap(model),
child: Container(
width: 64,
margin: const EdgeInsets.only(right: 16),
child: Column(
children: [
NetworkImageLoader(
imageUrl: model.sectionCover ?? '',
width: imgSize,
height: imgSize,
borderRadius: imgSize / 2,
),
4.sizeBoxH,
Text(
model.sectionName ?? '',
style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 12),
maxLines: 1,
),
],
),
),
);
}
/// 样式 5:标题 + 圆头像横滑。标题固定取第一个专题名
/// 注意标题不套 [padding](贴左边缘),只有下方列表有边距——与设计稿一致,别顺手加上
Widget _imageSection(List<AllSection> list) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
list.first.sectionName ?? "",
maxLines: 1,
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w500),
),
_avatarRow(list, height: 84, imgSize: 55),
],
);
}
// ========== 样式 4:文字标签网格 ==========
Widget _tagGrid(List<AllSection> list) {
return GridView.builder(
shrinkWrap: true,
padding: padding,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 10,
crossAxisSpacing: 9,
childAspectRatio: 82 / 33,
),
itemCount: list.length,
itemBuilder: (_, i) => _tagItem(list[i]),
);
}
Widget _tagItem(AllSection model) {
return GestureDetector(
onTap: () => _onTap(model),
child: Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
color:
isDarkStyle ? const Color(0x4D810906) : const Color(0x0DFFFFFF),
borderRadius: const BorderRadius.all(Radius.circular(2)),
),
child: Stack(
alignment: Alignment.center,
children: [
if (isDarkStyle)
Positioned(
top: 0,
child: Image.asset("aw_tag.webp".homePath, height: 13)),
Text(
"${model.sectionName}",
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 12,
fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (model.hot == true)
Positioned(
top: 0,
right: 0,
child: Image.asset("home_hot.webp".homePath, height: 20)),
],
),
),
);
}
// ========== 样式 2 / 3:原创达人 ==========
Widget _actressSection(List<AllSection> list) {
return Container(
margin: padding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
enableFeedback: false,
onTap: _gotoSectionAll,
child: Row(
children: [
const Expanded(
child: Text(
'原创达人',
maxLines: 1,
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w500),
),
),
10.sizeBoxW,
const Text('更多',
maxLines: 1,
style: TextStyle(color: Color(0x59FFFFFF), fontSize: 12)),
Image.asset("arrow_right_grey.webp".commonImgPath, width: 18),
],
),
),
12.sizeBoxH,
// 一屏排 5.5 个(露出半个提示可滑)+ 5 个 12 的间隔;
// 高度 = 圆头像(正方形,边长即 item 宽) + 2 间距 + 20 文字
LayoutBuilder(
builder: (_, c) {
final itemW = (c.maxWidth - 12 * 5) / 5.5;
return SizedBox(
height: itemW + 22,
// 横向「拉到底查看更多」:滑到末尾继续拉、松手跳原创达人列表页
child: HorizontalLoadMore(
onTrigger: _gotoSectionAll,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: EdgeInsets.zero,
itemCount: list.length,
separatorBuilder: (_, __) => 12.sizeBoxW,
itemBuilder: (_, i) => _actressItem(list[i], itemW),
),
),
);
},
),
],
),
);
}
Widget _actressItem(AllSection model, double itemW) {
return GestureDetector(
//达人固定进专题详情,不走 _onTap 的漫画分支
onTap: () =>
Get.to(SpecialTopicsDetailPage(model), preventDuplicates: false),
child: SizedBox(
height: double.infinity,
width: itemW,
child: Column(
children: [
NetworkImageLoader(
imageUrl: model.sectionCover ?? '',
width: itemW,
height: itemW,
borderRadius: itemW,
),
2.sizeBoxH,
Text(
model.sectionName ?? '',
style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 12),
maxLines: 1,
),
],
),
),
);
}
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_model/home/update_marker_model.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/light_model.dart';
import 'package:hgdj/hj_utils/store_keys.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
/// 首页「最新 / 今日最新」等内容更新红点
class HomeUpdateMarkerProvider with ChangeNotifier {
static final HomeUpdateMarkerProvider _instance =
HomeUpdateMarkerProvider._();
HomeUpdateMarkerProvider._();
factory HomeUpdateMarkerProvider() => _instance;
HomeUpdateMarkersResp? _markers;
String? _lastViewHomeLatestAt;
String? _lastViewTodayLatestAt;
final Map<String, String> _lastViewModuleAt = {};
bool get showHomeLatestDot =>
_isNewer(_markers?.homeLatestAt, _lastViewHomeLatestAt);
bool get showTodayLatestDot =>
_isNewer(_markers?.todayLatestAt, _lastViewTodayLatestAt);
bool showModuleDot(String? moduleId) {
if (moduleId == null || moduleId.isEmpty) return false;
return _isNewer(_latestAtOf(moduleId), _lastViewModuleAt[moduleId]);
}
/// 热门短剧专题 Tab:仅对 `dramaPage` 里的模块判断未读
bool showDramaTopicDot(String? topicId) {
if (!_isDramaPageModule(topicId)) return false;
return showModuleDot(topicId);
}
String? _latestAtOf(String moduleId) {
for (final m in _markers?.modules ?? const <HomeModuleUpdateMarker>[]) {
if (m.moduleId == moduleId) return m.latestAt;
}
return null;
}
bool _isDramaPageModule(String? id) {
if (id == null || id.isEmpty) return false;
return Config.plateModule?.dramaPage.any((e) => e.id == id) == true;
}
/// 拉取标记并与本地最后查看时间对比
Future<void> loadData() async {
try {
final resp = await CommonService.fetchUpdateMarkers();
if (resp != null) {
_markers = resp;
}
await _loadLocalLastViewed();
notifyListeners();
} catch (e) {
debugLog('HomeUpdateMarkerProvider.loadData error: $e');
}
}
Future<void> markHomeLatestViewed() async {
final at = _markers?.homeLatestAt;
if (at == null || at.isEmpty) return;
if (_lastViewHomeLatestAt == at) return;
_lastViewHomeLatestAt = at;
await lightKV.setString(StoreKeys.HOME_LATEST_LAST_VIEW_AT, at);
notifyListeners();
}
Future<void> markTodayLatestViewed() async {
final at = _markers?.todayLatestAt;
if (at == null || at.isEmpty) return;
if (_lastViewTodayLatestAt == at) return;
_lastViewTodayLatestAt = at;
await lightKV.setString(StoreKeys.HOME_TODAY_LATEST_LAST_VIEW_AT, at);
notifyListeners();
}
Future<void> markModuleViewed(String? moduleId) async {
if (moduleId == null || moduleId.isEmpty) return;
final latest = _latestAtOf(moduleId);
if (latest == null || latest.isEmpty) return;
if (_lastViewModuleAt[moduleId] == latest) return;
_lastViewModuleAt[moduleId] = latest;
await lightKV.setString(_moduleViewKey(moduleId), latest);
notifyListeners();
}
/// 8×8 红点,与消息中心一致
static Widget buildRedDot({bool visible = true}) {
if (!visible) return const SizedBox.shrink();
return Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xfff74f49),
shape: BoxShape.circle,
),
);
}
Future<void> _loadLocalLastViewed() async {
_lastViewHomeLatestAt =
await lightKV.getString(StoreKeys.HOME_LATEST_LAST_VIEW_AT);
_lastViewTodayLatestAt =
await lightKV.getString(StoreKeys.HOME_TODAY_LATEST_LAST_VIEW_AT);
_lastViewModuleAt.clear();
for (final m in _markers?.modules ?? const <HomeModuleUpdateMarker>[]) {
final id = m.moduleId;
if (id == null || id.isEmpty) continue;
final v = await lightKV.getString(_moduleViewKey(id));
if (v != null && v.isNotEmpty) {
_lastViewModuleAt[id] = v;
}
}
}
String _moduleViewKey(String moduleId) {
final uid = globalStore.meInfo?.uid ?? 0;
return '${StoreKeys.HOME_MODULE_LAST_VIEW_AT_PREFIX}${uid}_$moduleId';
}
/// 服务端最新时间晚于本地已查看时间则显示红点;本地为空且服务端有值也显示
static bool _isNewer(String? serverAt, String? localAt) {
if (serverAt == null || serverAt.isEmpty) return false;
if (localAt == null || localAt.isEmpty) return true;
final server = DateTime.tryParse(serverAt);
final local = DateTime.tryParse(localAt);
if (server == null || local == null) {
return serverAt != localAt;
}
return server.isAfter(local);
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/widgets.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/history_util.dart';
import 'search_result_page/search_result_main_page.dart';
/// 搜索主页逻辑:只管搜索框回填和搜索历史,热门排行由 RankSubPage 自己取数
class SearchMainLogic extends GetxController {
SearchMainLogic({this.initialResultType});
/// 搜索框控制器:由 logic 持有,点热搜词/历史项后能直接回填输入框
final searchTextCtr = TextEditingController();
/// 搜索历史(展示用):去重、排序、上限都由 SearchHistoryStore 负责,这里只缓存结果
final histories = <String>[];
/// 结果页默认 Tab(短剧频道进入时为 [MediaStyle.Drama]
final MediaStyle? initialResultType;
@override
void onReady() {
super.onReady();
loadHistories();
}
@override
void onClose() {
searchTextCtr.dispose();
super.onClose();
}
/// 重读搜索历史
Future<void> loadHistories() async {
final list = await HistoryUtil.searchHistories();
histories
..clear()
..addAll(list);
update(['history']);
}
/// 清空搜索历史
Future<void> clearHistories() async {
await HistoryUtil.clearSearch();
histories.clear();
update(['history']);
}
/// 提交搜索:记一条历史 → 跳结果页 → 返回后重读(用户此时才重新看到历史列表)
/// 空串由 SearchHistoryStore.save 内部忽略,不入库
Future<void> onSubmitted(String text) async {
searchTextCtr.text = text;
HistoryUtil.addSearch(text); //不 await:落盘不该挡住跳转,返回时早已写完
await Get.to(
() => SearchResultMainPage(text, initialType: initialResultType));
loadHistories();
}
}
@@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import '../../find/rank_module/rank_main_page.dart';
import '../../find/rank_module/rank_sub_page.dart';
import 'search_main_logic.dart';
import 'widget/search_app_bar.dart';
import 'widget/search_history_view.dart';
import 'widget/search_hot_tag_view.dart';
/// 搜索主页:头部是广告 / 搜索历史 / 热搜词,body 挂视频周榜,两者联动滚动
class SearchMainPage extends StatelessWidget {
/// 进入页面时预填的搜索词(可为空)
final String? searchText;
/// 结果页默认 Tab;短剧频道进入时传 [MediaStyle.Drama]
final MediaStyle? initialResultType;
const SearchMainPage({super.key, this.searchText, this.initialResultType});
@override
Widget build(BuildContext context) {
return GetBuilder<SearchMainLogic>(
init: SearchMainLogic(initialResultType: initialResultType),
builder: (logic) {
return Scaffold(
resizeToAvoidBottomInset: false,
body: Column(
children: [
SearchAppBar(
isClose: true,
searchText: searchText,
controller: logic.searchTextCtr, //由 logic 持有,点热搜词/历史项后回填输入框
onSubmitted: logic.onSubmitted,
),
Expanded(
child: NestedScrollView(
headerSliverBuilder: (_, __) => _headerSlivers(logic),
body: const RankSubPage(
pageType: MediaStyle.Video, sortType: 2), // 2 = 周榜
),
),
],
),
);
},
);
}
List<Widget> _headerSlivers(SearchMainLogic logic) {
return [
//顶部广告网格(position=32)
SliverToBoxAdapter(
child: AdsGridViewWidget(
32,
accordingAdsType: true,
padding: EdgeInsets.symmetric(vertical: 12.h, horizontal: 12.w),
),
),
//搜索历史:随增删局部刷新,不带动整页(id 要和 logic 里 update 的一致)
SliverToBoxAdapter(
child: GetBuilder<SearchMainLogic>(
id: 'history',
builder: (_) => SearchHistoryView(
histories: logic.histories,
onHistoryClick: logic.onSubmitted,
onClearAll: logic.clearHistories,
),
),
),
//热搜词
SliverToBoxAdapter(
child: SearchHotTagView(onTagClick: logic.onSubmitted)),
//「热门排行」标题行,右侧跳完整排行榜页
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 20, 12, 10),
child: Row(
children: [
const Text(
'热门排行',
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600),
),
const Spacer(),
InkWell(
enableFeedback: false,
onTap: () => Get.to(() => const RankMainPage(),
preventDuplicates: false),
child: Container(
width: 54,
height: 18,
alignment: Alignment.centerRight,
margin: const EdgeInsets.only(left: 8),
child: Row(
children: [
Text('更多',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 14)),
Icon(Icons.navigate_next,
size: 22,
color: Colors.white.withValues(alpha: 0.55)),
],
),
),
),
],
),
),
),
];
}
}
@@ -0,0 +1,99 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../../hj_model/acg/cartoon_more_list.dart';
import '../../../../hj_model/cartoon_media_info.dart';
import '../../../../tools_base/refresh/pull_refresh.dart';
import '../../../cartoon/acg_widget_item.dart';
class SearchACGResultPage extends StatefulWidget {
final String keywords;
final MediaStyle type;
SearchACGResultPage({
super.key,
required this.type,
required this.keywords,
});
@override
State<SearchACGResultPage> createState() => _SearchACGResultPageState();
}
class _SearchACGResultPageState extends State<SearchACGResultPage> {
RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放
int pageNumber = 1;
List<CartoonMediaInfo>? videoList;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_loadData();
});
}
_loadData({int page = 1, int size = 12}) async {
try {
MediaSearchListModel? retResp = await ACGService.mediaSearch(
keyword: widget.keywords,
page: page,
size: size,
kind: widget.type.searchKind);
pageNumber = page;
videoList ??= [];
if (page == 1) {
videoList?.clear();
}
videoList?.addAll(retResp?.list ?? []);
retResp?.hasNext == false
? refreshController?.loadNoData()
: refreshController?.loadComplete();
} catch (e) {
refreshController?.loadComplete();
debugLog(e);
}
refreshController?.refreshCompleted();
videoList ??= [];
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
if (videoList == null) {
return LoadingCenterWidget();
} else if (videoList?.isEmpty == true) {
return CErrorWidget(
retryOnTap: () {
videoList = null;
setState(() {});
_loadData();
},
);
} else {
return pullYsRefresh(
onInit: (ctr) => refreshController = ctr,
onLoading: (ctr) => _loadData(page: pageNumber + 1),
onRefresh: (ctr) => _loadData(),
child: GridView.builder(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 111 / 190,
),
itemCount: videoList?.length ?? 0,
itemBuilder: (context, index) {
return AcgItemWidget(info: videoList![index]);
},
),
);
}
}
}
@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/history_util.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import '../../../../tools_base/indicator/custom_tab_indicator.dart';
import '../widget/search_app_bar.dart';
import 'search_acg_result_page.dart';
import 'search_video_result_page.dart';
class SearchResultMainPage extends StatefulWidget {
final String keywords;
/// 从短剧频道进入时默认切到短剧 Tab(对应 kind=4)
final MediaStyle? initialType;
const SearchResultMainPage(this.keywords, {super.key, this.initialType});
@override
State<StatefulWidget> createState() {
return _SearchResultMainPageState();
}
}
class _SearchResultMainPageState extends State<SearchResultMainPage>
with SingleTickerProviderStateMixin {
final typeList = ['影片', '短剧', '抖音', '漫画', '动漫', '帖子', '图集'];
late final TabController tabCtr;
late String keywords = widget.keywords;
static int _tabIndexOf(MediaStyle? type) => switch (type) {
MediaStyle.Drama => 1,
MediaStyle.ShortVideo => 2,
MediaStyle.Comics => 3,
MediaStyle.Cartoon => 4,
MediaStyle.Community => 5,
MediaStyle.Pic => 6,
_ => 0,
};
@override
void initState() {
super.initState();
tabCtr = TabController(
length: typeList.length,
vsync: this,
initialIndex: _tabIndexOf(widget.initialType),
);
}
@override
void dispose() {
tabCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
SearchAppBar(
searchText: keywords,
onSubmitted: (value) {
HistoryUtil.addSearch(value); //结果页里再次搜索同样记历史
keywords = value;
setState(() {});
},
),
Container(
height: 32,
margin: EdgeInsets.symmetric(horizontal: 12),
child: TabBar(
tabAlignment: TabAlignment.fill,
tabs: typeList.map((e) {
return Container(
alignment: Alignment.center,
child: Text(e),
);
}).toList(),
labelStyle: TextStyle(fontSize: 14),
labelColor: Color(0xE5FFFFFF),
unselectedLabelStyle: TextStyle(fontSize: 14),
unselectedLabelColor: Color(0x73FFFFFF),
controller: tabCtr,
padding: EdgeInsets.zero,
isScrollable: false,
labelPadding: EdgeInsets.zero,
indicator: const CustomIndicator(
height: 4, width: 16, isGradient: true, offsetY: 4),
),
),
Expanded(
child: TabBarView(
controller: tabCtr,
children: [
SearchVideoResultPage(
key: ValueKey("0$keywords"),
keywords: keywords,
type: MediaStyle.Video)
.keepAlive,
SearchVideoResultPage(
key: ValueKey("1$keywords"),
keywords: keywords,
type: MediaStyle.Drama)
.keepAlive,
SearchVideoResultPage(
key: ValueKey("2$keywords"),
keywords: keywords,
type: MediaStyle.ShortVideo)
.keepAlive,
SearchACGResultPage(
key: ValueKey("3$keywords"),
keywords: keywords,
type: MediaStyle.Comics)
.keepAlive,
SearchACGResultPage(
key: ValueKey("4$keywords"),
keywords: keywords,
type: MediaStyle.Cartoon)
.keepAlive,
SearchVideoResultPage(
key: ValueKey("5$keywords"),
keywords: keywords,
type: MediaStyle.Community)
.keepAlive,
SearchVideoResultPage(
key: ValueKey("6$keywords"),
keywords: keywords,
type: MediaStyle.Pic)
.keepAlive,
],
),
),
],
),
);
}
}
@@ -0,0 +1,421 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/drama_media_info.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/drama/drama_detail_page.dart';
import 'package:hgdj/hj_page/drama/drama_list_page.dart';
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
import 'package:hgdj/hj_utils/api_service/search_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../../hj_model/home/video_list_model.dart';
import '../../../../tools_base/refresh/pull_refresh.dart';
import '../../../../tools_base/widget/multitap_recognizer.dart';
import '../../../cartoon/photo_gallery_item.dart';
import '../../../community/widget/community_post_widget.dart';
import '../../home_cell_style/video_simple_cell.dart';
import '../../home_cell_style/divider_tab_bar.dart';
import '../../tag/video_tag_page.dart';
//视频搜索结果排序 tab(标题与接口 sort 值绑定)
const _sortTabs = [
SortTab('最多收藏', 3),
SortTab('最新上架', 2),
SortTab('最多观看', 1),
];
class SearchVideoResultPage extends StatefulWidget {
final String keywords;
final MediaStyle type;
SearchVideoResultPage({
super.key,
required this.type,
required this.keywords,
});
@override
State<SearchVideoResultPage> createState() => _SearchVideoResultPageState();
}
class _SearchVideoResultPageState extends State<SearchVideoResultPage> {
RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放
int pageNumber = 1;
List<VideoModel>? videoList;
List<VideoModel>? tagVidList;
String? tagID;
int sortIndex = 0;
bool get _isDrama => widget.type == MediaStyle.Drama;
int get sortParam => _sortTabs[sortIndex].sort;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_loadData(sortParam);
});
}
_loadData(int sortValue, {int page = 1, int size = 12}) async {
try {
if (_isDrama) {
final retResp = await DramaService.search(
keyword: widget.keywords,
page: page,
size: size,
);
pageNumber = page;
if (page == 1) {
videoList = [];
tagVidList =
retResp?.tagMediaList.map((e) => e.toVideoModel(null)).toList() ??
[];
tagID = retResp?.tagID;
}
videoList ??= [];
videoList?.addAll(retResp?.list.map((e) => e.toVideoModel(null)) ?? []);
retResp?.hasNext != true
? refreshController?.loadNoData()
: refreshController?.loadComplete();
} else {
VideoListResp? retResp = await SearchService.searchMedia(
widget.keywords,
pageNumber: page,
pageSize: size,
realm: widget.type.searchRealm,
sortType: sortValue);
pageNumber = page;
videoList ??= [];
videoList?.addAll(retResp?.videos ?? []);
if (page == 1) {
tagVidList = retResp?.tagVidList ?? [];
tagID = retResp?.tagID;
}
retResp?.hasNext == false
? refreshController?.loadNoData()
: refreshController?.loadComplete();
}
} catch (e) {
refreshController?.loadComplete();
debugLog(e);
}
refreshController?.refreshCompleted();
videoList ??= [];
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
if (videoList == null && tagVidList == null) {
return LoadingCenterWidget();
} else if (videoList?.isEmpty == true && tagVidList?.isEmpty == true) {
return CErrorWidget(
retryOnTap: () {
videoList = null;
setState(() {});
_loadData(sortParam);
},
);
} else {
return NestedScrollView(
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
return [
SliverToBoxAdapter(
key: ValueKey(tagID),
child: _buildTagWidget(),
)
];
},
body: Column(
children: [
if (widget.type == MediaStyle.Video ||
widget.type == MediaStyle.ShortVideo)
DividerTabBar(
_sortTabs.map((e) => e.name).toList(),
selectIndex: sortIndex,
alignment: Alignment.center,
callback: (value) {
sortIndex = value;
videoList = null;
_loadData(sortParam);
setState(() {});
},
),
Expanded(
child: _buildContent(),
),
],
),
);
}
}
Widget _buildContent() {
if (videoList == null) {
return LoadingCenterWidget();
} else if (videoList?.isEmpty == true) {
return CErrorWidget(
retryOnTap: () {
videoList = null;
setState(() {});
_loadData(sortParam);
},
);
} else {
return pullYsRefresh(
onInit: (ctr) => refreshController = ctr,
onLoading: (ctr) => _loadData(sortParam, page: pageNumber + 1),
onRefresh: (ctr) => _loadData(sortParam),
child: _buildTypeList(),
);
}
}
/// 进短剧二级页;搜索列表不一定带 dramaInfo,最少用 id/标题/封面兜底
void _openDrama(VideoModel model) {
final drama = model.dramaInfo ??
(DramaMediaInfo()
..id = model.id
..title = model.title
..verticalCover = model.cover
..horizontalCover = model.cover
..totalEpisode = model.totalEpisode);
Get.to(() => DramaDetailPage(drama: drama));
}
Widget _buildTypeList() {
if (widget.type == MediaStyle.Drama) {
return GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 168 / 266,
),
itemCount: videoList?.length ?? 0,
itemBuilder: (context, index) {
final model = videoList![index];
return MultiTap(
onTap: () => _openDrama(model),
child: VideoSimpleCell(
videoModel: model,
textLines: 1,
coverRightText: model.dramaInfo?.episodeNumberStatus,
showLevelIcon: false,
isFromSearch: true,
onTap: () => _openDrama(model),
),
);
},
);
}
if (widget.type == MediaStyle.Video ||
widget.type == MediaStyle.ShortVideo) {
bool isShort = widget.type == MediaStyle.ShortVideo;
return GridView.builder(
padding: EdgeInsets.only(left: 12, right: 12),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isShort ? 3 : 2,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: isShort ? 111 / 190 : 168 / 154,
),
itemCount: videoList?.length ?? 0,
itemBuilder: (context, index) {
return VideoSimpleCell(
videoModel: videoList![index],
textLines: isShort ? 1 : 2,
isFromSearch: true,
);
},
);
} else if (widget.type == MediaStyle.Pic) {
return GridView.builder(
padding: EdgeInsets.only(left: 12, right: 12),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 111 / 190,
),
itemCount: videoList?.length ?? 0,
itemBuilder: (context, index) {
return PhotoGalleryItem(videoModel: videoList![index], textline: 1);
},
);
} else {
return ListView.builder(
padding: EdgeInsets.zero,
itemCount: videoList?.length ?? 0,
itemBuilder: (context, index) {
return CommunityPostWidget(
videoModel: videoList![index],
videoModels: videoList,
);
},
);
}
}
Widget _buildTagWidget() {
if (tagVidList?.isNotEmpty != true) return SizedBox();
if (_isDrama) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${widget.keywords} 标签内容',
style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 14),
),
12.sizeBoxH,
GridView.builder(
itemCount: min(4, tagVidList?.length ?? 0),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 168 / 266,
),
itemBuilder: (context, index) {
final videoModel = tagVidList![index];
return MultiTap(
onTap: () => _openDrama(videoModel),
child: VideoSimpleCell(
videoModel: videoModel,
textLines: 1,
coverRightText: videoModel.dramaInfo?.episodeNumberStatus,
showLevelIcon: false,
isFromSearch: true,
onTap: () => _openDrama(videoModel),
),
);
},
),
if (tagID?.isNotEmpty == true) ...[
const SizedBox(height: 12),
GestureDetector(
onTap: () => DramaListPage.toTag(
TagsBean(id: tagID, name: widget.keywords)),
child: Container(
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x1AFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('查看更多',
style: TextStyle(
color: Color(0xffEFEFEF), fontSize: 14)),
SizedBox(width: 6),
Icon(Icons.keyboard_arrow_right,
color: Color(0xffDCDCDC), size: 18),
],
),
),
),
],
const SizedBox(height: 6),
],
),
);
}
if (widget.type == MediaStyle.Video ||
widget.type == MediaStyle.ShortVideo) {
bool isShort = widget.type == MediaStyle.ShortVideo;
return Container(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"${widget.keywords} 标签内容",
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 14,
),
),
12.sizeBoxH,
GridView.builder(
itemCount: min(isShort ? 6 : 4, tagVidList?.length ?? 0),
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isShort ? 3 : 2,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: isShort ? 111 / 190 : 168 / 154,
),
itemBuilder: (context, index) {
VideoModel videoModel = tagVidList![index];
return VideoSimpleCell(
videoModel: videoModel,
textLines: isShort ? 1 : 2,
isFromSearch: true,
);
},
),
SizedBox(height: 12),
GestureDetector(
onTap: () {
Get.to(
VideoTagPage(
TagsBean(id: tagID, name: widget.keywords),
isFromSearch: true,
isShortStyle: widget.type == MediaStyle.ShortVideo,
),
preventDuplicates: false);
},
child: Container(
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color(0x1AFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"查看更多",
style: TextStyle(
color: Color(0xffEFEFEF),
fontSize: 14,
),
),
SizedBox(width: 6),
Icon(
Icons.keyboard_arrow_right,
color: Color(0xffDCDCDC),
size: 18,
),
],
),
),
),
SizedBox(height: 6),
],
),
);
}
return SizedBox();
}
}
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_model/cartoon_media_info.dart';
import '../../../hj_model/home/video_library_model.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../tools_base/debug_log.dart';
import '../../../tools_base/loading/loading_alert_widget.dart';
class VideoAllTypeLogic extends GetxController with GetSingleTickerProviderStateMixin {
VideoAllTypeLogic();
HomeVideoLibrary? homeVideoLibrary;
TimeType? firstSelectedType;
TimeType? secondSelectedType;
Tags? thirdSelectedType;
TimeType? forthSelectedType;
TimeType? fivthSelectedType;
RefreshController? refreshController;
List<VideoModel>? searchVideoList;
List<CartoonMediaInfo>? searchACGList;
Keyword? preKeyword;
int currentPage = 1;
RxBool isInitData = true.obs;
RxBool showFloatingTags = false.obs;
List<Tags>? get tags {
if (firstSelectedType?.isACG == true) {
return homeVideoLibrary?.acgTags;
} else {
return homeVideoLibrary?.vidTags;
}
}
String get selectedTagsText {
// 上滑吸顶摘要:展示分类/排序/标签/时间四个维度的当前选中值;paymentType 无入口不参与。
// 「全部」为默认兜底选项,摘要里省略避免多出一段无意义文案。
final names = [
firstSelectedType?.name,
secondSelectedType?.name,
thirdSelectedType?.name,
fivthSelectedType?.name,
];
return names.whereType<String>().where((e) => e.isNotEmpty && e != '全部').join('·');
}
Keyword get keywordValue {
Keyword keyword = Keyword();
keyword.tags = thirdSelectedType;
keyword.canvas = firstSelectedType;
keyword.orderBy = secondSelectedType;
keyword.paymentType = forthSelectedType;
keyword.timeType = fivthSelectedType;
return keyword;
}
@override
void onInit() {
super.onInit();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_loadMenuData();
});
}
void reloadMenuData() {
isInitData.value = true;
_loadMenuData();
}
void _loadMenuData() async {
try {
homeVideoLibrary = await VidService.fetchLibrary();
firstSelectedType = homeVideoLibrary?.canvas?.first;
secondSelectedType = homeVideoLibrary?.orderBy?.first;
thirdSelectedType = homeVideoLibrary?.vidTags?.first;
forthSelectedType = homeVideoLibrary?.paymentType?.first;
fivthSelectedType = homeVideoLibrary?.timeType?.first;
_loadSearchData();
} catch (e) {
debugLog(e);
}
homeVideoLibrary ??= HomeVideoLibrary();
isInitData.value = false;
update();
}
void reloadData() {
searchVideoList = null;
update();
_loadSearchData();
}
void refreshData() {
_loadSearchData();
}
void loadMoreData() {
_loadSearchData(page: currentPage + 1);
}
void menuExchangeEvent() {
update();
_loadSearchData(isSelectMenu: true);
}
void _loadSearchData({int page = 1, int size = 10, bool isSelectMenu = false}) async {
try {
if (isSelectMenu) {
LoadingAlertWidget.show();
}
Keyword keywordParam = keywordValue;
HomeVideoLibraryResult? respResult = await VidService.searchLibrary(
page,
size,
filterMenu: keywordParam,
);
if (keywordValue.modelKey != keywordParam.modelKey) {
return;
}
if (isSelectMenu) {
LoadingAlertWidget.cancel();
}
searchVideoList ??= [];
searchACGList ??= [];
currentPage = page;
if (page == 1) {
searchVideoList?.clear();
searchACGList?.clear();
}
searchVideoList?.addAll(respResult?.list ?? []);
searchACGList?.addAll(respResult?.allMediaList ?? []);
respResult?.hasNext == true ? refreshController?.loadComplete() : refreshController?.loadNoData();
} catch (e) {
if (isSelectMenu) {
LoadingAlertWidget.cancel();
}
debugLog(e);
refreshController?.loadComplete();
}
refreshController?.refreshCompleted();
searchVideoList ??= [];
update();
}
}
@@ -0,0 +1,387 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:waterfall_flow/waterfall_flow.dart';
import '../../../hj_model/home/video_library_model.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_model/video_model.dart';
import '../../../tools_base/loading/loading_center_widget.dart';
import '../../../tools_base/refresh/pull_refresh.dart';
import '../../cartoon/acg_widget_item.dart';
import '../../cartoon/photo_gallery_item.dart';
import '../../community/widget/community_post_widget.dart';
import '../home_cell_style/video_simple_cell.dart';
import 'video_all_type_logic.dart';
//片库
class VideoAllTypePage extends StatefulWidget {
const VideoAllTypePage({super.key});
@override
State<VideoAllTypePage> createState() => _VideoAllTypePageState();
}
class _VideoAllTypePageState extends State<VideoAllTypePage> {
final ScrollController scrollController = ScrollController();
@override
void dispose() {
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GetBuilder<VideoAllTypeLogic>(
init: VideoAllTypeLogic(),
builder: (logic) {
// 摘要文案随筛选变化,靠 GetBuilder 的 update() 重算;下方 Obx 只管吸顶显隐
final tagsText = logic.selectedTagsText;
return Scaffold(
appBar: AppBar(title: const Text('片库')),
body: Stack(
children: [
Obx(
() {
if (logic.isInitData.value) {
return LoadingCenterWidget();
} else if (logic.homeVideoLibrary?.isNotEmpty != true) {
return CErrorWidget(
retryOnTap: () {
logic.reloadMenuData();
},
);
} else {
return NotificationListener<ScrollNotification>(
onNotification: (scrollInfo) {
if (scrollInfo.metrics.pixels > 200) {
logic.showFloatingTags.value = true;
} else {
logic.showFloatingTags.value = false;
}
return true;
},
child: pullYsRefresh(
onInit: (ctr) => logic.refreshController = ctr,
onLoading: (_) => logic.loadMoreData(),
onRefresh: (_) => logic.refreshData(),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Column(
children: [
_buildItemMenuWidget(
"分类",
logic.homeVideoLibrary?.canvas,
logic.firstSelectedType, (data) {
if (logic.firstSelectedType?.isACG !=
data.isACG) {
logic.thirdSelectedType = null;
}
if (logic.firstSelectedType != data) {
logic.firstSelectedType = data;
} else {
logic.firstSelectedType = null;
}
logic.searchVideoList?.clear();
logic.menuExchangeEvent();
}),
_buildItemMenuWidget(
"排序",
logic.homeVideoLibrary?.orderBy,
logic.secondSelectedType, (data) {
if (logic.secondSelectedType != data) {
logic.secondSelectedType = data;
} else {
logic.secondSelectedType = null;
}
logic.menuExchangeEvent();
}),
_buildTagMenuWidget(
"标签", logic.tags, logic.thirdSelectedType,
(data) {
if (logic.thirdSelectedType != data) {
logic.thirdSelectedType = data;
} else {
logic.thirdSelectedType = null;
}
logic.menuExchangeEvent();
}),
_buildItemMenuWidget(
"时间",
logic.homeVideoLibrary?.timeType,
logic.fivthSelectedType, (data) {
if (logic.fivthSelectedType != data) {
logic.fivthSelectedType = data;
} else {
logic.fivthSelectedType = null;
}
logic.menuExchangeEvent();
}),
],
),
),
_buildTableContent(logic),
],
),
),
);
}
},
),
Obx(() {
if (!logic.showFloatingTags.value) return const SizedBox();
if (tagsText.isEmpty) return const SizedBox();
return Positioned(
top: 0,
left: 0,
right: 0,
child: GestureDetector(
onTap: () {
// 滚动到页面顶部
if (scrollController.hasClients) {
scrollController.animateTo(
0,
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: Container(
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
tagsText,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
}),
],
),
);
},
);
}
Widget _buildConditionItem(String? title, bool isSelected) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 6),
margin: EdgeInsets.only(right: 5),
alignment: Alignment.centerLeft,
child: Text(
title ?? "",
style: TextStyle(
color: isSelected ? AppColors.actionRed : Color(0x8CFFFFFF),
fontSize: 14,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
),
),
);
}
Widget _buildTitleItem(String? typeName) {
return Text(
typeName ?? "",
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildTableContent(VideoAllTypeLogic logic) {
if (logic.searchVideoList == null && logic.searchACGList == null) {
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: LoadingCenterWidget(),
),
);
} else if (logic.searchVideoList?.isNotEmpty != true &&
logic.searchACGList?.isNotEmpty != true) {
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: CErrorWidget(
retryOnTap: () {
logic.reloadData();
},
),
),
);
} else {
String typeName = logic.firstSelectedType?.name ?? "";
if (typeName == "帖子") {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return CommunityPostWidget(
videoModel: logic.searchVideoList![index],
videoModels: logic.searchVideoList,
);
},
childCount: logic.searchVideoList?.length ?? 0,
),
);
}
if (typeName == "图集") {
return _buildWaterfall(
logic, (vm) => PhotoGalleryItem(videoModel: vm, textline: 1));
}
if (typeName == "抖音" || logic.firstSelectedType?.key == "sp") {
return _buildWaterfall(
logic, (vm) => VideoSimpleCell(videoModel: vm, textLines: 1));
} else if (typeName == "动漫" || typeName == "漫画") {
return SliverPadding(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12.0,
crossAxisSpacing: 6,
childAspectRatio: 111 / 190,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
CartoonMediaInfo videoItem = logic.searchACGList![index];
return AcgItemWidget(info: videoItem);
},
childCount: logic.searchACGList?.length ?? 0,
),
),
);
} else {
return SliverPadding(
padding: EdgeInsets.fromLTRB(12, 12, 12, 0),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12.0,
crossAxisSpacing: 6,
childAspectRatio: 168 / 154,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
VideoModel videoItem = logic.searchVideoList![index];
return VideoSimpleCell(
videoModel: videoItem,
);
},
childCount: logic.searchVideoList?.length ?? 0,
),
),
);
}
}
}
//图集/抖音共用的 3 列瀑布流(仅 item 构建不同)
Widget _buildWaterfall(
VideoAllTypeLogic logic, Widget Function(VideoModel vm) itemBuilder) {
return SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
sliver: SliverWaterfallFlow(
gridDelegate: const SliverWaterfallFlowDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 6,
mainAxisSpacing: 12,
),
delegate: SliverChildBuilderDelegate(
(c, index) => AspectRatio(
aspectRatio: 111 / 190,
child: itemBuilder(logic.searchVideoList![index]),
),
childCount: logic.searchVideoList?.length ?? 0,
),
),
);
}
Widget _buildItemMenuWidget(String typeName, List<TimeType>? listData,
TimeType? selectType, Function(TimeType timeType) callBack) {
selectType ??= listData?[0];
return Container(
height: 40,
padding: EdgeInsets.only(left: 16),
child: Row(
children: [
_buildTitleItem(typeName),
SizedBox(width: 12),
Expanded(
child: ListView.builder(
itemCount: listData?.length ?? 0,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(vertical: 8),
itemBuilder: (context, index) {
TimeType data = listData![index];
bool isSelected = selectType?.name == data.name;
return InkWell(
enableFeedback: false,
onTap: () async {
callBack.call(data);
},
child: _buildConditionItem(data.name, isSelected),
);
},
),
),
],
),
);
}
Widget _buildTagMenuWidget(String typeName, List<Tags>? listData,
Tags? selectType, Function(Tags timeType) callBack) {
selectType ??= listData?[0];
return Container(
height: 40,
padding: EdgeInsets.only(left: 16),
child: Row(
children: [
_buildTitleItem(typeName),
SizedBox(width: 12),
Expanded(
child: ListView.builder(
itemCount: listData?.length ?? 0,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(vertical: 8),
itemBuilder: (context, index) {
Tags data = listData![index];
bool isSelected = selectType?.name == data.name;
return GestureDetector(
onTap: () async {
callBack.call(data);
},
child: _buildConditionItem(data.name, isSelected),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../../tools_base/widget/marquee_widget.dart';
import '../../../mine/welfare/sign_daily_page.dart';
import '../../home_main_logic.dart';
import '../search_main_page.dart';
class CommonSearchBarView extends StatefulWidget {
final HomeMainLogic? logic;
const CommonSearchBarView({super.key, this.logic});
@override
State<CommonSearchBarView> createState() => _CommonSearchBarViewState();
}
class _CommonSearchBarViewState extends State<CommonSearchBarView> {
/// 当前轮播展示的搜索热词,点击搜索框时带入搜索页
String? _searchText;
static final _hintStyle =
TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14);
@override
void initState() {
super.initState();
if (Config.searchHints.isNotEmpty) _searchText = Config.searchHints.first;
}
@override
Widget build(BuildContext context) {
return Row(
children: [
16.w.sizeBoxW,
Image.asset('place_holder_logo.webp'.commonImgPath, height: 28),
8.sizeBoxW,
Expanded(child: _buildSearchBox()),
12.sizeBoxW,
if (Config.signIcon != null && Config.signIcon!.isNotEmpty)
_buildSignIcon(),
12.sizeBoxW,
_buildMenuIcon(),
16.sizeBoxW,
],
);
}
/// 中间搜索框(热词轮播)
Widget _buildSearchBox() {
return InkWell(
enableFeedback: false,
onTap: () => Get.to(SearchMainPage(searchText: _searchText)),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xff1E1C1D),
borderRadius: BorderRadius.circular(99),
),
child: Config.searchHints.isEmpty
? Text('请输入关键字', style: _hintStyle)
: MarqueeWidget(
count: Config.searchHints.length,
onIndexChanged: (index) =>
_searchText = Config.searchHints[index],
itemBuilder: (_, index) => Container(
alignment: Alignment.centerLeft,
child: Text(Config.searchHints[index], style: _hintStyle),
),
),
),
);
}
/// 签到入口
Widget _buildSignIcon() {
return InkWell(
enableFeedback: false,
onTap: () => Get.to(SignDailyPage()),
child: NetworkImageLoader(
imageUrl: Config.signIcon,
width: 20,
borderRadius: 0,
placeHolderWidget: const SizedBox(),
),
);
}
/// 右侧菜单入口
Widget _buildMenuIcon() {
return InkWell(
enableFeedback: false,
onTap: () => widget.logic?.openEndDrawer(),
child: Image.asset('acg_menu.png'.acgImgPath,
width: 24, color: const Color(0xff989898)),
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../video_all_type_page.dart';
final class SearchAppBar extends StatefulWidget {
final Function(String keywork) onSubmitted;
final bool? isClose;
final String? searchText;
/// 外部持有的输入控制器:搜索主页由 logic 持有,点热搜词/历史项后好回填。
/// 不传则本组件自建自销——每个 SearchAppBar 各用各的,不再共享全局单例
final TextEditingController? controller;
const SearchAppBar({
super.key,
required this.onSubmitted,
this.searchText,
this.isClose,
this.controller,
});
@override
State<SearchAppBar> createState() => _SearchAppBarState();
}
class _SearchAppBarState extends State<SearchAppBar> {
late final searchTextCtr = widget.controller ?? TextEditingController();
late bool showDeleteIcon;
@override
void initState() {
super.initState();
final initText = widget.searchText ?? "";
showDeleteIcon = initText.isNotEmpty;
// 控制器不再跨路由共享,此处赋值不会 markNeedsBuild 别的路由,无需延到首帧后
searchTextCtr.text = initText;
}
@override
void dispose() {
// 只释放自己 new 的;外部传进来的归调用方管
if (widget.controller == null) searchTextCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.only(top: screen.paddingTop),
color: Colors.black,
child: Container(
height: 56,
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
GestureDetector(
onTap: () {
setState(() {
showDeleteIcon = false;
searchTextCtr.text = "";
});
Get.back();
},
child: Image.asset(
'common_back.png'.commonImgPath,
width: 22,
),
),
10.sizeBoxW,
Expanded(
child: Container(
height: 32,
padding: EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: Color(0x11FFFFFF),
borderRadius: BorderRadius.circular(20)),
child: Row(
children: [
InkWell(
enableFeedback: false,
onTap: () {
Get.to(() => VideoAllTypePage(), opaque: false);
},
child: Row(
children: [
2.sizeBoxW,
Image.asset('libary_search.webp'.homePath, width: 20),
6.sizeBoxW,
Text(
'片库',
style: TextStyle(
color: Color(0xFFF68804),
fontSize: 14,
),
),
6.sizeBoxW,
Container(
height: 12,
width: 1,
color: Colors.white.withValues(alpha: .05),
),
6.sizeBoxW,
],
),
),
Expanded(
child: TextField(
controller: searchTextCtr,
style: TextStyle(
color: Colors.white.withValues(alpha: .8),
fontSize: 14,
height: 1),
onSubmitted: (value) => widget.onSubmitted(value),
onChanged: (value) {
if (value.isNotEmpty == true) {
setState(() {
showDeleteIcon = true;
});
} else {
setState(() {
showDeleteIcon = false;
});
}
},
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: '搜索关键词',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 14,
height: 1),
border: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
),
),
),
12.sizeBoxW,
Visibility(
visible: showDeleteIcon,
child: GestureDetector(
onTap: () {
setState(() {
searchTextCtr.text = "";
showDeleteIcon = false;
});
},
child: Image.asset('search_close.webp'.homePath,
width: 18),
),
)
],
),
),
),
10.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () {
if (widget.isClose == true) {
setState(() {
showDeleteIcon = false;
});
}
if (searchTextCtr.text.isEmpty == true) {
showToast("请输入关键字");
return;
}
widget.onSubmitted(searchTextCtr.text);
},
child: Text(
"搜索",
style: TextStyle(
color: Color(0x73FFFFFF),
fontSize: 16,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,137 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
/// 搜索历史区:纯展示,数据与增删由 SearchMainLogic 持有
class SearchHistoryView extends StatefulWidget {
const SearchHistoryView({
super.key,
required this.histories,
required this.onHistoryClick,
required this.onClearAll,
});
final List<String> histories;
final Function(String keyword) onHistoryClick;
final VoidCallback onClearAll;
@override
State<SearchHistoryView> createState() => _SearchHistoryViewState();
}
class _SearchHistoryViewState extends State<SearchHistoryView> {
/// 折叠状态最多展示几条
static const _collapsedMax = 8;
bool isExpandData = false;
int get itemCount {
final total = widget.histories.length;
return isExpandData ? total : min(_collapsedMax, total);
}
@override
Widget build(BuildContext context) {
if (widget.histories.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.only(left: 12, right: 12, top: 12),
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'历史记录',
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
InkWell(
enableFeedback: false,
onTap: widget.onClearAll,
child: Container(
padding: const EdgeInsets.all(1),
child: Image.asset("history_delete.webp".homePath,
width: 18, height: 18),
),
),
],
),
12.sizeBoxH,
GridView.builder(
padding: EdgeInsets.zero,
itemCount: itemCount,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 79 / 34,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemBuilder: (_, index) =>
_buildHistoryItem(widget.histories[index]),
),
_buildMoreRecord(),
],
),
);
}
Widget _buildHistoryItem(String tag) {
return GestureDetector(
onTap: () => widget.onHistoryClick(tag),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
tag,
maxLines: 1,
style: const TextStyle(color: Color(0xE5FFFFFF), fontSize: 12),
),
),
);
}
/// 「查看 / 收起完整记录」按钮:少于 [_collapsedMax] 条时不显示
Widget _buildMoreRecord() {
if (widget.histories.length < _collapsedMax) {
return const SizedBox();
}
return Container(
padding: const EdgeInsets.only(top: 12),
alignment: Alignment.topCenter,
child: InkWell(
enableFeedback: false,
onTap: () => setState(() => isExpandData = !isExpandData),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
isExpandData ? "收起完整记录" : "查看完整记录",
style: const TextStyle(color: Color(0xff989898), fontSize: 12),
),
4.sizeBoxW,
Transform.rotate(
angle: isExpandData ? pi : 0,
child: Image.asset(
"arrow_down.png".commonImgPath,
width: 12,
color: const Color(0xffDCDCDC),
),
),
],
),
),
);
}
}
@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/screen.dart';
class SearchHotTagView extends StatelessWidget {
final Function(String keyword) onTagClick;
const SearchHotTagView({super.key, required this.onTagClick});
@override
Widget build(BuildContext context) {
final tags = Config.hotWords;
if (tags.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.only(left: 12, right: 12, top: 18),
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'大家都在搜',
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
12.h.sizeBoxH,
GridView.builder(
padding: EdgeInsets.zero,
itemCount: tags.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
childAspectRatio: 79 / 34,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemBuilder: (_, index) => _buildTagItem(tags[index]),
),
],
),
);
}
Widget _buildTagItem(String tag) {
return GestureDetector(
onTap: () => onTagClick(tag),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3),
),
child: Text(
tag,
maxLines: 1,
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 12,
),
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../community/community_tag_page/community_tag_page.dart';
class TopicItem extends StatelessWidget {
final TagsBean model;
final bool isSelect;
final Function()? onTap;
const TopicItem(
{super.key, required this.model, this.onTap, this.isSelect = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (onTap != null) {
onTap!();
} else {
Get.to(() => CommunityTagDetailPage(model: model));
}
},
child: Container(
height: 66,
padding: EdgeInsets.only(left: 12.w, right: 12.w),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
NetworkImageLoader(
imageUrl: model.coverImg ?? '',
width: 38,
height: 38,
borderRadius: 3,
),
10.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'#${model.name}',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// 4.sizeBoxH,
Text(
'${model.vidCount?.countStr}个帖子 ${model.playCount.countStr}浏览 ${model.followCount.countStr}关注',
style: TextStyle(color: Color(0x73FFFFFF), fontSize: 12),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
12.sizeBoxW,
Container(
width: 52,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isSelect ? AppColors.actionRed : Color(0x33FFFFFF),
borderRadius: BorderRadius.circular(30),
),
child: Text(
isSelect ? '已选' : "选择",
style: TextStyle(
color: isSelect ? Color(0xffffffff) : Color(0xffDCDCDC),
fontSize: 14,
),
),
),
],
),
),
);
}
}
@@ -0,0 +1,32 @@
import 'package:hgdj/tools_base/base_list_controller.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../../../hj_utils/api_service/vid_service.dart';
class SectionAllLogic extends ListBaseLogic<AllSection> {
final String id;
SectionAllLogic(this.id);
@override
void onInit() {
super.onInit();
loadData();
}
//isRefresh 下拉刷新/加载更多;showLoading 重试时先清空显示 loading
void loadData({bool isRefresh = true, bool showLoading = false}) {
if (showLoading) {
dataList = null;
update();
}
fetchData(isRefresh: isRefresh, fetch: _fetch);
}
Future<(List<AllSection>?, bool)> _fetch(int page) async {
final resp =
await VidService.fetchSectionAll(id, pageNumber: page, pageSize: 20);
//hasNext 为 null 时按"有更多"处理(保持原 == false 判断语义)
return (resp?.list, resp?.hasNext != false);
}
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../special_topic_detail/special_topics_detail_page.dart';
import 'section_all_logic.dart';
class SectionAllPage extends StatelessWidget {
final String id;
const SectionAllPage(this.id, {super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<SectionAllLogic>(
init: SectionAllLogic(id),
builder: (logic) => Scaffold(
appBar: AppBar(title: Text("原创达人")),
body: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onRefresh: (ctr) => logic.loadData(),
onLoading: (ctr) => logic.loadData(isRefresh: false),
child: () {
if (logic.isLoading) {
return LoadingCenterWidget();
} else if (logic.isEmptyData) {
return CErrorWidget(
retryOnTap: () => logic.loadData(showLoading: true),
);
} else {
final list = logic.dataList!;
return ListView.builder(
padding: EdgeInsets.fromLTRB(16, 12, 16, 0),
itemExtent: 62, //item 固定高 50 + 底部 margin 12,跳过逐项测量
itemCount: list.length,
itemBuilder: (context, index) {
AllSection model = list[index];
return InkWell(
enableFeedback: false,
onTap: () {
Get.to(SpecialTopicsDetailPage(model),
preventDuplicates: false);
},
child: Container(
margin: EdgeInsets.only(bottom: 12),
height: 50,
child: Row(
children: [
NetworkImageLoader(
imageUrl: model.sectionCover,
width: 50,
height: 50,
borderRadius: 50,
),
12.sizeBoxW,
Expanded(
child: Text(
model.sectionName ?? "",
maxLines: 1,
style: TextStyle(
color: Color(0xffFFFFFF),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
12.sizeBoxW,
Icon(Icons.keyboard_arrow_right,
color: Colors.white, size: 16),
],
),
),
);
},
);
}
}(),
),
),
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/home/module_detail_model.dart';
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
import 'package:hgdj/tools_base/ad_manager.dart';
import '../home_cell_style/home_section_cell.dart';
/// 分页/刷新/加载态/防重入/异常兜底都交给 ListBaseLogic,本类只管排序切换与广告插入
class SpecialTopicsLogic extends ListBaseLogic<VideoModel> {
final AllSection? originModel;
SpecialTopicsLogic(this.originModel);
// ========== 配置 ==========
static const _sortTabs = [
SortTab('热门推荐', 'like'),
SortTab('最新上架', 'new'),
SortTab('最新热评', 'comment'),
];
/// 走竖图布局的 showType(后端 2xx 一档)
static const _verticalTypes = {
HomeSectionShowType.verticalScroll25,
HomeSectionShowType.verticalFourGrid,
HomeSectionShowType.verticalSixGrid,
HomeSectionShowType.verticalNineGrid,
HomeSectionShowType.verticalListGrid,
};
List<String> get sortTitles => _sortTabs.map((e) => e.name).toList();
// ========== 状态 ==========
int sort = 0;
/// 广告只取一次,之后每页复用(只表示「取过」,取到空也算取过)
bool _adsLoaded = false;
final List<AdsInfoModel> _adsList = [];
// ========== Controllers ==========
late final TabController tabCtr =
TabController(length: _sortTabs.length, vsync: this);
// ========== 派生 getter ==========
String get title => originModel?.sectionName ?? '';
/// 竖向 cell 布局(封面 168:266 竖图)— 否则横向 cell191:174
/// late finaloriginModel 不会变,避免 textLines 在 itemBuilder 里被每个 cell 触发一次线性查找
late final bool isVertical = _verticalTypes.contains(
HomeSectionShowType.values.firstWhere(
(e) => e.value == originModel?.showType,
orElse: () => HomeSectionShowType.oneLargeAndFourSmall,
),
);
int get crossAxisCount => isVertical ? 3 : 2;
double get childAspectRatio => isVertical ? (168 / 266) : (191 / 174);
int get textLines => isVertical ? 1 : 2;
// ========== 生命周期 ==========
@override
void onReady() {
super.onReady();
loadData();
}
@override
void onClose() {
tabCtr.dispose();
super.onClose();
}
// ========== 公开方法 ==========
/// [isRefresh] 刷新(回第 1 页并清空)/ 加载更多;[showLoading] 切排序时先转圈
/// 广告要等基类把新页并入 dataList 后再按全量列表重排,所以 await 完再插
Future<void> loadData(
{bool isRefresh = true, bool showLoading = false}) async {
if (showLoading) {
dataList = null; // 置空即 isLoading
update();
}
await fetchData(isRefresh: isRefresh, fetch: _fetch);
_insertAds();
update();
}
// ========== 私有方法 ==========
Future<(List<VideoModel>?, bool)> _fetch(int page) async {
final res = await VidService.fetchSectionVideos(
originModel?.sectionID ?? '',
pageNumber: page,
sortType: _sortTabs[sort].sort,
);
// hasNext 为 null 按「没有更多」处理,保持原 == true 的判断语义
return (res?.videos, res?.hasNext == true);
}
/// 往列表里插广告。AdManager().adsByType 是纯内存读、无网络请求,
/// 整条链同步即可。insertGroupAds 内部先清旧广告位再重排,全量列表上重复调用是安全的。
void _insertAds() {
if (!_adsLoaded) {
_adsList
..clear()
..addAll(AdManager().adsByType(8));
_adsLoaded = true;
}
final list = dataList;
if (_adsList.isEmpty || list == null) return;
// 必须用 videoArrVideoSimpleCell 只认 isAdsArr()adsInfoArr);
// 用 .video 塞的是 randomAdsInfocell 不识别会渲染成空白视频(91PORN 占位 + 评论 null
AdManager().insertGroupAds(list, _adsList, adGap: 8);
}
}
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import '../../../hj_model/home/module_detail_model.dart';
import '../home_cell_style/video_simple_cell.dart';
import 'special_topics_detail_logic.dart';
class SpecialTopicsDetailPage extends StatelessWidget {
final AllSection? originModel;
const SpecialTopicsDetailPage(this.originModel, {super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<SpecialTopicsLogic>(
init: SpecialTopicsLogic(originModel),
builder: (logic) => Scaffold(
appBar: AppBar(title: Text(logic.title)),
body: Column(
children: [
_sortBar(logic),
Expanded(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onRefresh: (_) => logic.loadData(),
onLoading: (_) => logic.loadData(isRefresh: false),
child: _content(logic),
),
),
],
),
),
);
}
Widget _sortBar(SpecialTopicsLogic logic) {
final titles = logic.sortTitles;
return Container(
height: 32,
margin: const EdgeInsets.symmetric(horizontal: 32, vertical: 6),
child: TabBar(
controller: logic.tabCtr,
tabAlignment: TabAlignment.fill,
isScrollable: false,
labelPadding: EdgeInsets.zero,
padding: EdgeInsets.zero,
labelStyle: const TextStyle(fontSize: 14),
labelColor: const Color(0xE5FFFFFF),
unselectedLabelStyle: const TextStyle(fontSize: 14),
unselectedLabelColor: const Color(0x73FFFFFF),
indicator: const BoxDecoration(),
onTap: (index) {
logic.sort = index;
logic.loadData(showLoading: true);
},
tabs: [
for (var i = 0; i < titles.length; i++)
_sortTab(titles[i], isLast: i == titles.length - 1),
],
),
);
}
Widget _sortTab(String title, {required bool isLast}) {
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: isLast ? Colors.transparent : const Color(0x12FFFFFF),
width: 1,
),
),
),
child: Text(title),
);
}
/// loading / 空态 / 视频网格
Widget _content(SpecialTopicsLogic logic) {
if (logic.isLoading) return const LoadingCenterWidget();
if (logic.isEmptyData) return const CErrorWidget();
final list = logic.dataList!; // 上面两个判空已保证非空
return GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 10),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: logic.crossAxisCount,
crossAxisSpacing: 7,
mainAxisSpacing: 12,
childAspectRatio: logic.childAspectRatio,
),
itemCount: list.length,
itemBuilder: (context, index) => SizedBox.expand(
child: VideoSimpleCell(
videoModel: list[index],
textLines: logic.textLines,
),
),
);
}
}
@@ -0,0 +1,73 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_model/cartoon_media_info.dart';
//专题版排序(sort: 收藏3 上架1 喜欢2)
const _sectionSortTabs = [
SortTab('最多收藏', 3),
SortTab('最新上架', 1),
SortTab('最多喜欢', 2),
];
//普通版排序(sort: 收藏3 上架2 喜欢1)
const _normalSortTabs = [
SortTab('最多收藏', 3),
SortTab('最新上架', 2),
SortTab('最多喜欢', 1),
];
class CartoonTagLogic extends GetxController {
final String? sId;
final bool isSection; // true: 专题
CartoonTagLogic(this.sId, this.isSection);
int page = 1;
bool isLoading = true;
RefreshController? refreshController;
List<CartoonMediaInfo> dataSource = [];
int sortIndex = 0;
List<SortTab<int>> get _sortTabs =>
isSection ? _sectionSortTabs : _normalSortTabs;
List<String> get sorts => _sortTabs.map((e) => e.name).toList();
@override
void onInit() {
fetchPageData();
super.onInit();
}
fetchPageData({bool isRefresh = true, bool showLoading = false}) async {
if (isRefresh) {
page = 1;
}
if (showLoading) {
isLoading = true;
update();
}
final res = await ACGService.getMoreCartoon(
page, 12, _sortTabs[sortIndex].sort, sId ?? '');
if (isRefresh) {
refreshController?.refreshCompleted();
dataSource.clear();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
isLoading = false;
update();
}
Future<TagsBean?> fetchTagDetail() async {
final res = await ACGService.fetchMediaTag(sId ?? '');
if (res != null) {
update();
}
return null;
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import '../../cartoon/acg_widget_item.dart';
import '../home_cell_style/divider_tab_bar.dart';
import 'cartoon_tag_logic.dart';
class CartoonTagPage extends StatefulWidget {
final String title;
final String? sId;
const CartoonTagPage({
super.key,
this.title = '',
this.sId,
});
@override
State<CartoonTagPage> createState() => _CartoonTagPageState();
}
class _CartoonTagPageState extends State<CartoonTagPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<CartoonTagLogic>(
init: CartoonTagLogic(widget.sId, false),
tag: uniqueTag,
builder: (controller) {
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.black,
title: Text(widget.title),
),
body: Column(
children: [
Container(
padding: EdgeInsets.only(left: 16),
child: DividerTabBar(
controller.sorts,
selectIndex: controller.sortIndex,
alignment: Alignment.center,
callback: (value) {
controller.sortIndex = value;
controller.fetchPageData(showLoading: true);
},
),
),
Expanded(
child: pullYsRefresh(
onRefresh: (_) => controller.fetchPageData(),
onLoading: (_) => controller.fetchPageData(isRefresh: false),
onInit: (ctr) => controller.refreshController = ctr,
child: () {
if (controller.isLoading) return LoadingCenterWidget();
if (controller.dataSource.isEmpty) return CErrorWidget();
return GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 6,
mainAxisSpacing: 12,
crossAxisCount: 3,
childAspectRatio: 111 / 190,
),
itemCount: controller.dataSource.length,
itemBuilder: (BuildContext context, int index) {
return AcgItemWidget(
info: controller.dataSource[index]);
},
);
}(),
),
)
],
),
);
},
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/tag_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
//视频标签页排序 tab(标题与接口 sortType 绑定)
const _sortTabs = [
SortTab('最多收藏', 2),
SortTab('最新上架', 1),
SortTab('最新热评', 5),
];
class VideoTagLogic extends ListBaseLogic<VideoModel> {
final TagsBean? tagModel;
final bool isShortStyle;
VideoTagLogic(this.tagModel, {this.isShortStyle = false});
int sortIndex = 0;
List<String> get sortTitles => _sortTabs.map((e) => e.name).toList();
@override
void onInit() {
super.onInit();
loadData();
}
//isRefresh 下拉刷新/加载更多;showLoading 切换排序时先清空显示 loading
void loadData({bool isRefresh = true, bool showLoading = false}) {
if (showLoading) {
dataList = null;
update();
}
fetchData(isRefresh: isRefresh, fetch: _fetch);
}
Future<(List<VideoModel>?, bool)> _fetch(int page) async {
final res = await TagService.fetchList(
tagModel?.id,
page: page,
size: 12,
sortType: _sortTabs[sortIndex].sort,
newsType: isShortStyle ? "SHORT" : "SP",
);
return (res?.videos, res?.hasNext ?? false);
}
Future<TagsBean?> fetchTagDetail() async {
final res = await TagService.fetchInfo(tagModel?.id ?? '');
if (res != null) {
tagModel?.name = res.name;
update();
}
return res;
}
}
+103
View File
@@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/global_store/store.dart';
import '../home_cell_style/video_simple_cell.dart';
import '../home_cell_style/divider_tab_bar.dart';
import 'video_tag_logic.dart';
class VideoTagPage extends StatefulWidget {
final TagsBean? model;
final bool isShortStyle;
final bool isHYStyle; // 黄油
final bool isFromSearch;
const VideoTagPage(
this.model, {
super.key,
this.isShortStyle = false,
this.isHYStyle = false,
this.isFromSearch = false,
});
@override
State<VideoTagPage> createState() => _VideoTagPageState();
}
class _VideoTagPageState extends State<VideoTagPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<VideoTagLogic>(
init: VideoTagLogic(widget.model, isShortStyle: widget.isShortStyle),
tag: uniqueTag,
builder: (logic) {
return Scaffold(
appBar: AppBar(title: Text(logic.tagModel?.name ?? '')),
body: Column(
children: [
Container(
padding: EdgeInsets.only(left: 16),
child: DividerTabBar(
logic.sortTitles,
selectIndex: logic.sortIndex,
alignment: Alignment.center,
callback: (value) {
logic.sortIndex = value;
logic.loadData(showLoading: true);
},
),
),
Expanded(
child: () {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.isEmptyData) return CErrorWidget();
final list = logic.dataList!;
return pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onRefresh: (_) => logic.loadData(),
onLoading: (_) => logic.loadData(isRefresh: false),
child: GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisSpacing: 6,
mainAxisSpacing: 12,
crossAxisCount: 2,
childAspectRatio:
widget.isShortStyle ? 168 / 266 : 168 / 154,
),
itemCount: list.length,
itemBuilder: (BuildContext context, int index) {
VideoModel model = list[index];
return VideoSimpleCell(
videoModel: model,
textLines:
(widget.isShortStyle || widget.isHYStyle) ? 1 : 2,
isFromHY: widget.isHYStyle,
isFromSearch: widget.isFromSearch,
onTap: () {
if (model.isDarkTag && !globalStore.isAWVIP) {
pushToWalletPage(vipId: Config.darkWebVipId);
} else {
pushToVideoPage(
videoModel: model, videoArr: list);
}
},
);
},
),
);
}(),
),
],
),
);
},
);
}
}
@@ -0,0 +1,101 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import '../../../hj_model/acg/cartoon_more_list.dart';
import '../../../hj_model/cartoon_media_info.dart';
typedef WidgetBuilder = Widget Function();
/// 将点赞/收藏/关注事件同步到 [VideoModel](列表与详情可共用同一引用)
void applyVideoModelCollectStatus(VideoModel? model, CollectStatusModel event) {
if (model == null || event.id?.isNotEmpty != true || model.id != event.id) {
return;
}
if (event.isCollected != null) {
model.vidStatus?.hasCollected = event.isCollected;
}
if (event.isLiked != null) {
model.vidStatus?.hasLiked = event.isLiked;
if (event.likeCountDelta != null) {
model.likeCount = max(0, (model.likeCount ?? 0) + event.likeCountDelta!);
}
}
if (event.isFollowed != null && event.uid == model.publisher?.uid) {
model.publisher?.hasFollowed = event.isFollowed;
}
}
class CollectStatusWrapper extends StatefulWidget {
final WidgetBuilder builder;
final AllMediaInfo? allMediaInfo;
final CartoonMediaInfo? mediaInfo;
final VideoModel? videoModel;
final TagsBean? tagModel;
const CollectStatusWrapper({
required this.builder,
super.key,
this.mediaInfo,
this.allMediaInfo,
this.videoModel,
this.tagModel,
});
@override
State<StatefulWidget> createState() {
return _CollectStatusWrapperState();
}
}
class _CollectStatusWrapperState extends State<CollectStatusWrapper> {
late StreamSubscription subscription;
@override
void initState() {
super.initState();
subscription = eventBus.on<CollectStatusModel>(_listenCallback);
}
void _listenCallback(CollectStatusModel model) {
String? obId = widget.allMediaInfo?.id ??
widget.mediaInfo?.id ??
widget.videoModel?.id ??
widget.tagModel?.id;
//收藏和点赞逻辑
if (obId == model.id && obId?.isNotEmpty == true) {
if (model.isCollected != null) {
widget.allMediaInfo?.mediaStatus?.hasCollected = model.isCollected;
widget.mediaInfo?.mediaStatus?.hasCollected = model.isCollected;
widget.videoModel?.vidStatus?.hasCollected = model.isCollected;
widget.tagModel?.hasCollected = model.isCollected;
setState(() {});
} else if (model.isLiked != null) {
widget.allMediaInfo?.mediaStatus?.hasLiked = model.isLiked;
widget.mediaInfo?.mediaStatus?.hasLiked = model.isLiked;
widget.videoModel?.vidStatus?.hasLiked = model.isLiked;
setState(() {});
} else {}
}
// 用户关注逻辑
if (model.isFollowed != null &&
model.uid == widget.videoModel?.publisher?.uid) {
widget.videoModel?.publisher?.hasFollowed = model.isFollowed;
setState(() {});
}
}
@override
Widget build(BuildContext context) {
return widget.builder();
}
@override
void dispose() {
subscription.cancel();
eventBus.off(subscription);
super.dispose();
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class HomeSortMenuView extends StatefulWidget {
final List<String> titleArr;
final int selectIndex;
final Function(int)? callback;
final String gapChar;
const HomeSortMenuView(this.titleArr, {super.key, this.selectIndex = 0, this.callback, this.gapChar = '/'});
@override
State<StatefulWidget> createState() {
return _HomeSortMenuViewState();
}
}
class _HomeSortMenuViewState extends State<HomeSortMenuView> {
int selectIndex = 0;
@override
void initState() {
selectIndex = widget.selectIndex;
super.initState();
}
@override
void didUpdateWidget(covariant HomeSortMenuView oldWidget) {
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
List<Widget> widgetArr = [];
for (int i = 0; i < widget.titleArr.length; i++) {
widgetArr.add(_buildMenuButton(widget.titleArr[i], i == selectIndex, i));
}
return Row(
mainAxisSize: MainAxisSize.min,
children: widgetArr,
);
}
Widget _buildMenuButton(String text, bool isSelected, int index) {
bool isLast = index == (widget.titleArr.length - 1);
return InkWell(enableFeedback: false,
onTap: () {
selectIndex = index;
setState(() {});
widget.callback?.call(index);
},
child: Container(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
text,
style: TextStyle(
fontSize: 14,
color: isSelected ? Color(0xE5FFFFFF) : const Color(0x73FFFFFF),
),
),
const SizedBox(width: 12),
if (isLast)
const SizedBox(width: 12)
else
Text(
widget.gapChar,
style: TextStyle(
fontSize: 14,
color: Color(0x73FFFFFF),
),
),
const SizedBox(width: 12),
],
),
),
);
}
@override
void dispose() {
super.dispose();
}
}
@@ -0,0 +1,564 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_utils/api_service/vid_service.dart';
import '../../../routers/jump_router.dart';
class HomeTxtMarQuee extends StatefulWidget {
final double stepOffset;
//首页读取type=1,暗网读取type=2,发现读取type=4,社区读取type=3,我的界面读取type=0
final int typeValue;
final EdgeInsetsGeometry? margin;
final bool showClose;
final double? fontSize;
final double? iconSize;
final double? height;
final EdgeInsets? padding;
final double borderRadius;
HomeTxtMarQuee({
this.stepOffset = 1,
this.typeValue = 0,
this.margin,
this.showClose = true,
this.fontSize,
this.iconSize,
this.height,
this.padding,
this.borderRadius = 0,
super.key,
});
@override
State<StatefulWidget> createState() {
return HomeTxtMarQueeState();
}
}
class HomeTxtMarQueeState extends State<HomeTxtMarQuee>
with SingleTickerProviderStateMixin {
List<MarqueeModel>? marquees;
bool isShow = true;
// 关闭收起动画(value 1→0:高度收起 + 淡出)
late final AnimationController closeAniCtr = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
value: 1,
);
@override
void initState() {
super.initState();
marquees = Config.marquees;
if (marquees == null) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_loadData();
});
}
}
_loadData() async {
await VidService.fetchAnnounce(widget.typeValue);
marquees = Config.marquees;
if (mounted) setState(() {});
}
// 点关闭先播收起动画,结束后再移除
void _close() {
closeAniCtr.reverse().then((_) {
if (mounted) setState(() => isShow = false);
});
}
@override
void dispose() {
closeAniCtr.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (marquees == null || marquees!.isEmpty || !isShow) return SizedBox();
return FadeTransition(
opacity: closeAniCtr,
child: _marqueeBar(),
);
}
Widget _marqueeBar() {
return Container(
height: widget.height ?? 36,
alignment: Alignment.centerLeft,
margin: widget.margin,
padding: widget.padding ??
EdgeInsets.only(left: 14, right: 14, top: 8, bottom: 8),
decoration: BoxDecoration(
color: Color(0xCC000000),
borderRadius: BorderRadius.circular(widget.borderRadius),
),
child: Row(
children: [
Image.asset(
"icon_broadcast.png".commonImgPath,
width: widget.iconSize ?? 20,
),
6.sizeBoxW,
Expanded(
child: InfiniteMarquee(
stepOffset: widget.stepOffset,
itemBuilder: (BuildContext context, int index) {
final idx = index % marquees!.length;
final model = marquees![idx];
return InkWell(
enableFeedback: false,
onTap: () => pushToPageByLink(model.url),
child: Text(
model.content ?? '',
style: TextStyle(
color: Color(0xff989898),
fontSize: widget.fontSize ?? 12,
height: 1.4),
),
);
},
),
),
if (widget.showClose) ...[
6.sizeBoxW,
GestureDetector(
onTap: _close,
child: Icon(Icons.close, size: 18, color: Color(0xffDCDCDC)),
)
],
],
),
);
}
}
// 无限滚动list
class InfiniteMarquee extends StatefulWidget {
/// 每次移动步长,默认0.5,越大越快
final double stepOffset;
/// 自定义内容
final IndexedWidgetBuilder itemBuilder;
InfiniteMarquee({
super.key,
this.stepOffset = 0.5,
required this.itemBuilder,
});
@override
State<InfiniteMarquee> createState() => _InfiniteMarqueeState();
}
class _InfiniteMarqueeState extends State<InfiniteMarquee> {
// 执行动画的controller
InfiniteScrollController? _controller;
// 定时器timer
Timer? _timer;
// 定时器时间
Duration duration = Duration(milliseconds: 30);
// 手势打断定时器
bool timerStop = false;
// 执行位移开始的偏移量
double _offset = 0.0;
@override
void initState() {
super.initState();
_controller = InfiniteScrollController(initialScrollOffset: _offset);
_startScrollTimer();
}
/// 开启定时器
_startScrollTimer() {
_timer = Timer.periodic(duration, (timer) {
_autoScroll();
});
}
/// 自动滚动
_autoScroll() {
double newOffset = (_controller?.offset ?? 0) + widget.stepOffset;
if (timerStop == false) {
_offset = newOffset;
_controller?.jumpTo(_offset);
}
}
@override
void dispose() {
_timer?.cancel();
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
/// 监听滚动
return InfiniteListView.separated(
scrollDirection: Axis.horizontal,
controller: _controller,
itemBuilder: widget.itemBuilder,
physics: NeverScrollableScrollPhysics(),
separatorBuilder: (BuildContext context, int index) =>
SizedBox(width: Get.width - 100),
anchor: 0,
);
}
}
class InfiniteListView extends StatefulWidget {
/// See [ListView.builder]
const InfiniteListView.builder({
super.key,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.controller,
this.physics,
this.padding,
this.itemExtent,
required this.itemBuilder,
this.itemCount,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.addSemanticIndexes = true,
this.cacheExtent,
this.anchor = 0.0,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
}) : separatorBuilder = null;
/// See [ListView.separated]
const InfiniteListView.separated({
super.key,
this.scrollDirection = Axis.vertical,
this.reverse = false,
this.controller,
this.physics,
this.padding,
required this.itemBuilder,
required this.separatorBuilder,
this.itemCount,
this.addAutomaticKeepAlives = true,
this.addRepaintBoundaries = true,
this.addSemanticIndexes = true,
this.cacheExtent,
this.anchor = 0.0,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
this.clipBehavior = Clip.hardEdge,
}) : itemExtent = null;
/// See: [ScrollView.scrollDirection]
final Axis scrollDirection;
/// See: [ScrollView.reverse]
final bool reverse;
/// See: [ScrollView.controller]
final InfiniteScrollController? controller;
/// See: [ScrollView.physics]
final ScrollPhysics? physics;
/// See: [BoxScrollView.padding]
final EdgeInsets? padding;
/// See: [ListView.builder]
final IndexedWidgetBuilder itemBuilder;
/// See: [ListView.separated]
final IndexedWidgetBuilder? separatorBuilder;
/// See: [SliverChildBuilderDelegate.childCount]
final int? itemCount;
/// See: [ListView.itemExtent]
final double? itemExtent;
/// See: [ScrollView.cacheExtent]
final double? cacheExtent;
/// See: [ScrollView.anchor]
final double anchor;
/// See: [SliverChildBuilderDelegate.addAutomaticKeepAlives]
final bool addAutomaticKeepAlives;
/// See: [SliverChildBuilderDelegate.addRepaintBoundaries]
final bool addRepaintBoundaries;
/// See: [SliverChildBuilderDelegate.addSemanticIndexes]
final bool addSemanticIndexes;
/// See: [ScrollView.dragStartBehavior]
final DragStartBehavior dragStartBehavior;
/// See: [ScrollView.keyboardDismissBehavior]
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// See: [ScrollView.restorationId]
final String? restorationId;
/// See: [ScrollView.clipBehavior]
final Clip clipBehavior;
@override
_InfiniteListViewState createState() => _InfiniteListViewState();
}
class _InfiniteListViewState extends State<InfiniteListView> {
InfiniteScrollController? _controller;
InfiniteScrollController get _effectiveController =>
widget.controller ?? _controller!;
@override
void initState() {
super.initState();
if (widget.controller == null) {
_controller = InfiniteScrollController();
}
}
@override
void didUpdateWidget(InfiniteListView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.controller == null && oldWidget.controller != null) {
_controller = InfiniteScrollController();
} else if (widget.controller != null && oldWidget.controller == null) {
_controller!.dispose();
_controller = null;
}
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final List<Widget> slivers = _buildSlivers(context, negative: false);
final List<Widget> negativeSlivers = _buildSlivers(context, negative: true);
final AxisDirection axisDirection = _getDirection(context);
final scrollPhysics =
widget.physics ?? const AlwaysScrollableScrollPhysics();
return Scrollable(
axisDirection: axisDirection,
controller: _effectiveController,
physics: scrollPhysics,
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return Builder(builder: (BuildContext context) {
/// Build negative [ScrollPosition] for the negative scrolling [Viewport].
final state = Scrollable.of(context);
final negativeOffset = _InfiniteScrollPosition(
physics: scrollPhysics,
context: state,
initialPixels: -offset.pixels,
keepScrollOffset: _effectiveController.keepScrollOffset,
negativeScroll: true,
);
/// Keep the negative scrolling [Viewport] positioned to the [ScrollPosition].
offset.addListener(() {
negativeOffset._forceNegativePixels(offset.pixels);
});
/// Stack the two [Viewport]s on top of each other so they move in sync.
return Stack(
children: <Widget>[
Viewport(
axisDirection: flipAxisDirection(axisDirection),
anchor: 1.0 - widget.anchor,
offset: negativeOffset,
slivers: negativeSlivers,
cacheExtent: widget.cacheExtent,
),
Viewport(
axisDirection: axisDirection,
anchor: widget.anchor,
offset: offset,
slivers: slivers,
cacheExtent: widget.cacheExtent,
),
],
);
});
},
);
}
AxisDirection _getDirection(BuildContext context) {
return getAxisDirectionFromAxisReverseAndDirectionality(
context, widget.scrollDirection, widget.reverse);
}
List<Widget> _buildSlivers(BuildContext context, {bool negative = false}) {
final itemExtent = widget.itemExtent;
final padding = widget.padding ?? EdgeInsets.zero;
return <Widget>[
SliverPadding(
padding: negative
? padding - EdgeInsets.only(bottom: padding.bottom)
: padding - EdgeInsets.only(top: padding.top),
sliver: (itemExtent != null)
? SliverFixedExtentList(
delegate: negative
? negativeChildrenDelegate
: positiveChildrenDelegate,
itemExtent: itemExtent,
)
: SliverList(
delegate: negative
? negativeChildrenDelegate
: positiveChildrenDelegate,
),
)
];
}
SliverChildDelegate get negativeChildrenDelegate {
return SliverChildBuilderDelegate(
(BuildContext context, int index) {
final separatorBuilder = widget.separatorBuilder;
if (separatorBuilder != null) {
final itemIndex = (-1 - index) ~/ 2;
return index.isOdd
? widget.itemBuilder(context, itemIndex)
: separatorBuilder(context, itemIndex);
} else {
return widget.itemBuilder(context, -1 - index);
}
},
childCount: widget.itemCount,
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
);
}
SliverChildDelegate get positiveChildrenDelegate {
final separatorBuilder = widget.separatorBuilder;
final itemCount = widget.itemCount;
return SliverChildBuilderDelegate(
(separatorBuilder != null)
? (BuildContext context, int index) {
final itemIndex = index ~/ 2;
return index.isEven
? widget.itemBuilder(context, itemIndex)
: separatorBuilder(context, itemIndex);
}
: widget.itemBuilder,
childCount: separatorBuilder == null
? itemCount
: (itemCount != null ? max(0, itemCount * 2 - 1) : null),
addAutomaticKeepAlives: widget.addAutomaticKeepAlives,
addRepaintBoundaries: widget.addRepaintBoundaries,
);
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
.add(EnumProperty<Axis>('scrollDirection', widget.scrollDirection));
properties.add(FlagProperty('reverse',
value: widget.reverse, ifTrue: 'reversed', showName: true));
properties.add(DiagnosticsProperty<ScrollController>(
'controller', widget.controller,
showName: false, defaultValue: null));
properties.add(DiagnosticsProperty<ScrollPhysics>('physics', widget.physics,
showName: false, defaultValue: null));
properties.add(DiagnosticsProperty<EdgeInsetsGeometry>(
'padding', widget.padding,
defaultValue: null));
properties.add(
DoubleProperty('itemExtent', widget.itemExtent, defaultValue: null));
properties.add(
DoubleProperty('cacheExtent', widget.cacheExtent, defaultValue: null));
}
}
/// Same as a [ScrollController] except it provides [ScrollPosition] objects with infinite bounds.
class InfiniteScrollController extends ScrollController {
/// Creates a new [InfiniteScrollController]
InfiniteScrollController({
super.initialScrollOffset,
super.keepScrollOffset,
super.debugLabel,
});
@override
ScrollPosition createScrollPosition(ScrollPhysics physics,
ScrollContext context, ScrollPosition? oldPosition) {
return _InfiniteScrollPosition(
physics: physics,
context: context,
initialPixels: initialScrollOffset,
keepScrollOffset: keepScrollOffset,
oldPosition: oldPosition,
debugLabel: debugLabel,
);
}
}
class _InfiniteScrollPosition extends ScrollPositionWithSingleContext {
_InfiniteScrollPosition({
required super.physics,
required super.context,
super.initialPixels,
super.keepScrollOffset,
super.oldPosition,
super.debugLabel,
this.negativeScroll = false,
});
final bool negativeScroll;
void _forceNegativePixels(double value) {
super.forcePixels(-value);
}
@override
void saveScrollOffset() {
if (!negativeScroll) {
super.saveScrollOffset();
}
}
@override
void restoreScrollOffset() {
if (!negativeScroll) {
super.restoreScrollOffset();
}
}
@override
double get minScrollExtent => double.negativeInfinity;
@override
double get maxScrollExtent => double.infinity;
}
@@ -0,0 +1,104 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart'; // sizeBoxH/sizeBoxW 扩展
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import '../../community/publish_page/publish_page.dart';
enum PublishEntry {
community(40, 40);
final double width;
final double height;
const PublishEntry(this.width, this.height);
String realPath() => 'publish.png'.homePath;
}
class PublishButton extends StatelessWidget {
final PublishEntry entry;
const PublishButton({super.key, this.entry = PublishEntry.community});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Get.bottomSheet(CommunityPublishBottomSheet(),
isScrollControlled: true, isDismissible: true),
child: Image.asset(
entry.realPath(),
width: entry.width.w,
height: entry.height.h,
),
);
}
}
class CommunityPublishBottomSheet extends StatelessWidget {
const CommunityPublishBottomSheet({super.key});
@override
Widget build(BuildContext context) {
return Container(
// 底部加系统安全区,避免手势导航条遮住按钮(如 vivo iQOO 13)
padding: EdgeInsets.only(
left: 18.w,
right: 18.w,
top: 18,
bottom: 18 + Get.mediaQuery.padding.bottom),
decoration: BoxDecoration(
color: AppColors.primaryColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(13))),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandleBar(),
18.sizeBoxH,
Text(
'选择发布类型',
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
fontWeight: FontWeight.w900),
),
40.h.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_publishItem('publish_img.webp', '图片', PublishType.homeImg),
40.sizeBoxW,
_publishItem('publish_video.webp', '视频', PublishType.homeVideo),
40.sizeBoxW,
_publishItem(
'publish_img_text.webp', '图文', PublishType.homeImgText),
],
),
],
),
);
}
Widget _publishItem(String img, String label, PublishType type) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
Get.back();
Get.to(() => PublishPage(type: type));
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(img.communityPath, width: 45.w, height: 45.w),
4.sizeBoxH,
Text(label,
style:
TextStyle(color: const Color(0xffa3a2a2), fontSize: 18.sp)),
],
),
);
}
}
@@ -0,0 +1,79 @@
import 'dart:math';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/comment/comment_list_res.dart';
import 'package:hgdj/routers/jump_router.dart';
class QuickSearchView extends StatefulWidget {
final List<CommentLink> dataSource;
const QuickSearchView(this.dataSource, {super.key});
@override
State<StatefulWidget> createState() {
return _QuickSearchViewState();
}
}
class _QuickSearchViewState extends State<QuickSearchView> {
CommentLink? model;
final realQuickSearchs = <CommentLink>[];
@override
void initState() {
super.initState();
realQuickSearchs.addAll(widget.dataSource.where((e) => e.type == 2));
loadData();
}
void loadData() {
if (model == null && realQuickSearchs.isNotEmpty) {
if (realQuickSearchs.length > 1) {
final index = Random().nextInt(realQuickSearchs.length);
model = realQuickSearchs[index];
} else {
model = realQuickSearchs.first;
}
}
}
@override
void didUpdateWidget(covariant QuickSearchView oldWidget) {
loadData();
super.didUpdateWidget(oldWidget);
}
@override
Widget build(BuildContext context) {
if (model == null) return SizedBox();
return GestureDetector(
onTap: () {
pushToPageByLink(model?.link ?? '', arguments: {'id': model?.id});
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: EasyRichText(
'${model?.title ?? ''}',
defaultStyle: TextStyle(color: Colors.white, fontSize: 14),
patternList: [
EasyRichTextPattern(
targetString: model?.searchKeyword ?? '',
style: TextStyle(
color: Color(0xffDB361F), fontSize: 14, height: 1))
],
),
),
Image.asset(
'hot_search.png'.communityPath,
width: 9,
height: 9,
),
],
),
);
}
}
@@ -0,0 +1,285 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:provider/provider.dart';
import '../../mine/mine_vip/mine_charge_coin_page.dart';
import '../../mine/widgets/gradient_text.dart';
class SupportUPAlert extends StatefulWidget {
final bool isPublish;
final int? selectCoin;
const SupportUPAlert({super.key, this.isPublish = false, this.selectCoin});
@override
State<SupportUPAlert> createState() => _SupportUPAlertState();
}
class _SupportUPAlertState extends State<SupportUPAlert> {
Rx<int?> selectValue = Rx<int?>(0);
final textEidtCtr = TextEditingController();
final focusN = FocusNode();
final coins = ['10', '20', '30', '40', '50'];
var enable = false.obs;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
globalStore.refreshWallet().then((value) => checkEnable());
});
selectValue.value = widget.selectCoin;
final contain = coins.contains(widget.selectCoin.toString());
if (!contain)
textEidtCtr.text =
widget.selectCoin == null ? '' : widget.selectCoin.toString();
checkEnable();
}
checkEnable() {
if (widget.isPublish) {
enable.value = true;
} else {
final gold = globalStore.wallet?.amount ?? 0;
if (gold == 0)
enable.value = false;
else {
final selectCoin = selectValue.value ?? 0;
if (selectCoin == 0)
enable.value = true;
else {
enable.value = gold >= selectCoin;
}
}
}
}
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
color: Color(0xff0F0F0F),
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: _buildContent(),
);
}
_buildContent() {
return Container(
height: 420,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
Container(
width: 60,
margin: EdgeInsets.symmetric(vertical: 18),
height: 6,
decoration: BoxDecoration(
color: Color(0x33FFFFFF),
borderRadius: BorderRadius.circular(4),
),
),
Text(
widget.isPublish ? '设置价格' : '为喜欢的UP主加油',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 20,
fontWeight: FontWeight.w500),
),
18.sizeBoxH,
Container(
padding: EdgeInsets.symmetric(horizontal: 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LayoutBuilder(builder: (_, cons) {
final width_ = (cons.maxWidth - 48) / 4;
return Wrap(
spacing: 12,
runSpacing: 12,
children:
['10', '20', '30', '40', '50', '60', '70', '80'].map(
(e) {
final gold = int.tryParse(e);
return Obx(() {
final select = gold == (selectValue.value ?? 0);
return GestureDetector(
onTap: gold == null
? null
: () {
selectValue.value = gold;
focusN.unfocus();
textEidtCtr.clear();
checkEnable();
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: (gold != null && select)
? AppColors.actionRed
: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(10),
border: (gold != null && select)
? Border.all(color: AppColors.actionRed)
: Border.all(color: Color(0x1AFFFFFF))),
width: width_,
height: width_ * 1.1,
child: () {
if (gold != null)
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'community_coin.webp'.communityPath,
width: 20,
height: 20,
),
12.sizeBoxH,
GradientText(
'$gold金币',
style: TextStyle(
fontSize: 12,
),
gradient: LinearGradient(
colors: select
? [
Color(0xffFFE8BE),
Color(0xffE6B764)
]
: [
Colors.white
.withValues(alpha: .55),
Colors.white
.withValues(alpha: .55),
],
),
)
],
);
}(),
),
);
});
},
).toList(),
);
}),
20.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text("自定义", style: TextStyle(color: Color(0x8CFFFFFF))),
Container(
margin: EdgeInsets.symmetric(horizontal: 5, vertical: 0),
width: 60,
decoration: BoxDecoration(
color: Color(0x33FFFFFF),
borderRadius: BorderRadius.circular(6)),
child: TextField(
controller: textEidtCtr,
focusNode: focusN,
textAlign: TextAlign.center,
onChanged: (value) {
selectValue.value = int.tryParse(value);
checkEnable();
},
keyboardType: TextInputType.number,
maxLength: 4,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp('[0-9]'))
],
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500),
decoration: InputDecoration(
border: InputBorder.none,
counterText: '',
hintText: '100',
isDense: true,
contentPadding: EdgeInsets.zero,
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: .6),
fontSize: 12),
),
),
),
Text("金币", style: TextStyle(color: Color(0x8CFFFFFF))),
],
),
20.sizeBoxH,
Consumer<GlobalStore>(builder: (_, store, __) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!widget.isPublish) ...[
Text(
'钱包余额:${store.wallet?.amount ?? 0}',
style: TextStyle(
color: Colors.white.withValues(alpha: .6),
fontSize: 12,
height: 14 / 12),
),
16.sizeBoxH,
],
Obx(() {
if (enable.value)
return Row(
children: [
Expanded(
child: GestureDetector(
onTap: () =>
Get.back(result: selectValue.value),
child: Container(
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
child: Text(
widget.isPublish ? '确定设置' : '立即打赏',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500),
),
),
),
),
],
);
return GestureDetector(
onTap: () => Get.to(MineChargeCoinPage()),
child: Container(
height: 38,
margin: EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3)),
child: Text(
'余额不足 前往充值',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
);
}),
],
);
})
],
),
)
],
),
);
}
}
+56
View File
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/user_center_page/user_center_page.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
class UserAvatar extends StatelessWidget {
final double size;
final bool showVip;
final Publisher? model;
final bool isCircle;
final double? bigVsize;
final bool showBorder;
final GestureTapCallback? onTap;
const UserAvatar({
super.key,
this.size = 52,
this.showVip = false,
this.model,
this.isCircle = true,
this.bigVsize,
this.showBorder = false,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
border: showBorder
? Border.all(color: Color(0xffDB361F), width: 2)
: null,
borderRadius: BorderRadius.circular(100)),
child: Stack(
alignment: Alignment.center,
children: [
GestureDetector(
onTap: onTap ??
() {
Get.to(() => UserCenterPage(uid: model?.uid ?? 0),
preventDuplicates: false);
},
child: NetworkImageLoader(
imageUrl: model?.portrait ?? '',
width: size - (showBorder ? 4 : 0),
height: size - (showBorder ? 4 : 0),
borderRadius: isCircle ? size / 2 : 0,
),
),
],
),
);
}
}
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class UserNameView extends StatelessWidget {
final String? name;
final bool? isVip;
final bool? isOfficial;
final double fontSize;
final FontWeight fontWeight;
final Color? nameColor;
const UserNameView({
super.key,
this.name,
this.isVip,
this.isOfficial,
this.fontSize = 14,
this.fontWeight = FontWeight.w500,
this.nameColor,
});
@override
Widget build(BuildContext context) {
if (name?.isEmpty ?? true) return SizedBox.shrink();
return Text(
name ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: nameColor ?? Color(0xff9A9A9A),
fontSize: fontSize,
fontWeight: fontWeight,
),
);
}
}