初始化
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user