初始化
This commit is contained in:
@@ -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 的 ValueKey:position + 抽奖态 + 各 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 竖图)— 否则横向 cell(191:174)
|
||||
/// late final:originModel 不会变,避免 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;
|
||||
// 必须用 videoArr:VideoSimpleCell 只认 isAdsArr()(adsInfoArr);
|
||||
// 用 .video 塞的是 randomAdsInfo,cell 不识别会渲染成空白视频(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]);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
})
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user