初始化

This commit is contained in:
谢宇宁
2026-09-15 15:44:13 +07:00
commit a23ba685e9
1065 changed files with 101122 additions and 0 deletions
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/splash/domain_source_model.dart';
import 'package:hgdj/hj_page/main_page/provider/msg_provider.dart';
import 'package:hgdj/hj_page/mine/mine_vip/mine_charge_vip_page.dart';
import 'package:hgdj/hj_page/mine/mine_vip/pay_order_source.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../tools_base/widget/card_swiper/src/swiper.dart';
import '../../pre_sale/pre_sale_entry.dart';
import '../../pre_sale/pre_sale_provider.dart';
/// 浮窗容器的位置锚点:支付分层弹窗关闭时缩小飞向它(见 GuideHomeDialog)。
/// 挂在容器而不是分层浮窗本身——轮播时子项会轮到屏幕外,落点得是稳定的右下角
final homeFloatKey = GlobalKey();
/// 首页右下角浮窗入口
/// 展示优先级:预售浮窗 + 分层浮窗,两者都在则轮播(预售在前);都没有则不显示
class HomeActivityFloat extends StatelessWidget {
const HomeActivityFloat({super.key});
@override
Widget build(BuildContext context) {
// 预售(PreSaleProvider)、分层(MineMsgProvider)配置变化时重建
return Consumer2<PreSaleProvider, MineMsgProvider>(
builder: (_, presale, msg, __) {
final tier = msg.payTier?.config;
// 按优先级收集可展示的浮窗:预售在前、分层在后
final floats = <Widget>[
//预售浮窗:展示判断走项目统一的 canShowEnter,点击进预售页(入口图/动画由 PreSaleEntry 内部处理)
if (presale.canShowEnter) const PreSaleEntry(),
//支付分层浮窗:有 homePageFlot 图就展示
if ((tier?.homePageFlot ?? '').isNotEmpty)
_LayeredFloatView(config: tier!),
];
if (floats.isEmpty) return const SizedBox();
// 单个直接贴右下角;预售 + 分层并存则轮播
return KeyedSubtree(
key: homeFloatKey,
child: floats.length == 1
? Align(alignment: Alignment.bottomRight, child: floats.first)
: _FloatCarousel(items: floats),
);
},
);
}
}
/// 支付分层浮窗:homePageFlot 海报图(自带倒计时框)+ 底部叠加 lastDiscountTime 倒计时文字
/// 点击跳会员中心并自动选中 config.vipCard 对应会员卡
class _LayeredFloatView extends StatelessWidget {
final PayTierConfig config;
const _LayeredFloatView({required this.config});
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
//点击跳会员中心,vipID 让其自动选中对应卡
onTap: () => Get.to(() => MineChargeVipPage(
vipID: config.vipCard, sourcePage: PaySourcePage.homeFloatWindow)),
child: SizedBox(
width: 60,
height: 66,
child: Stack(
fit: StackFit.expand,
children: [
//分层海报图(后端加密图)
NetworkImageLoader(
imageUrl: config.homePageFlot ?? '',
fit: BoxFit.fill,
borderRadius: 0),
//倒计时文字:监听 tick 每秒刷新(过期自动收起),框由后端图自带,位置不对就调 bottom
Positioned(
left: 0,
right: 0,
bottom: 2,
child: ValueListenableBuilder<int>(
valueListenable: MineMsgProvider().tick,
builder: (_, __, ___) => config.hasDiscountCountdown
? Center(
child: Text(
'${config.discountHour}:${config.discountMin}:${config.discountSec}',
style: const TextStyle(
color: Colors.white,
fontSize: 8.5,
fontWeight: FontWeight.w600),
),
)
: const SizedBox.shrink(),
),
),
],
),
),
);
}
}
/// 预售 + 分层浮窗轮播(复用项目 Swiper,4s 自动切换;预售在前、分层在后)
class _FloatCarousel extends StatelessWidget {
final List<Widget> items;
const _FloatCarousel({required this.items});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 70,
height: 124,
child: Swiper(
//内容数量变化时换 key 强制重建,避免 index 越界
key: ValueKey('home_float_${items.length}'),
autoplay: true,
autoplayDelay: 4000,
loop: true,
itemCount: items.length,
itemBuilder: (_, index) =>
Align(alignment: Alignment.bottomRight, child: items[index]),
),
);
}
}
@@ -0,0 +1,242 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import '../../../config/config.dart';
import '../../../hj_model/splash/domain_source_model.dart';
import '../../../hj_utils/api_service/common_service.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../mine/mine_vip/pay_order_source.dart';
/// 首页红包雨/限时活动浮窗:展示倒计时,活动结束后 ping 刷新下一场
class HomeFloatBanner extends StatefulWidget {
final BannerJumpEntity banner;
final double width;
final double height;
final double radius;
/// banner 被 ping 刷新/移除后回调,通知父级(轮播)重建
final VoidCallback? onBannerUpdated;
const HomeFloatBanner({
super.key,
required this.banner,
required this.width,
required this.height,
this.radius = 0,
this.onBannerUpdated,
});
@override
State<HomeFloatBanner> createState() => _HomeFloatBannerState();
}
class _HomeFloatBannerState extends State<HomeFloatBanner> {
// ===== 数据 =====
late BannerJumpEntity _banner; // 当前展示的 banner(活动结束后可能被 ping 刷新替换)
// ===== 状态标志 =====
bool _endedPingRequested = false; // 本场活动结束后是否已请求过刷新(避免重复 ping)
bool _isPinging = false; // 是否正在 ping(防重入)
// ===== 计时器 & 局部刷新 =====
Timer? _timer; // 倒计时类活动每秒刷新用
// 底部文案(倒计时 / "立即参与")。每秒只更新它,交 ValueListenableBuilder 局部刷新文字,
// 图片(NetworkImageLoader)不参与每秒重建(仿 PreSaleProvider.deadline 的局部刷新思路)
final ValueNotifier<String> _bottomText = ValueNotifier('');
// 是否倒计时类活动(countdownType==1 才展示倒计时并轮询结束)
bool get _isCountdownActivity => _banner.countdownType == 1;
@override
void initState() {
super.initState();
_banner = widget.banner;
_refreshBottomText();
_restartTimer();
}
@override
void didUpdateWidget(covariant HomeFloatBanner oldWidget) {
super.didUpdateWidget(oldWidget);
// banner 换了(父级传入新数据):重置结束标记、刷新文案并按新类型重启计时器
if (oldWidget.banner != widget.banner) {
_banner = widget.banner;
_endedPingRequested = false;
_refreshBottomText();
_restartTimer();
}
}
@override
void dispose() {
_timer?.cancel();
_bottomText.dispose();
super.dispose();
}
// 按当前 banner 类型启停计时器:仅倒计时类活动才每秒刷新
void _restartTimer() {
_timer?.cancel();
_timer = null;
if (_isCountdownActivity) {
_timer =
Timer.periodic(const Duration(seconds: 1), (_) => _onTimerTick());
}
}
// 每秒 tick:活动结束时 ping 一次拿下一场,其余情况只更新底部文案(局部刷新,不重建图片)
void _onTimerTick() {
if (!mounted) return;
if (_activityStatus() == _ActStatus.ended && !_endedPingRequested) {
_endedPingRequested = true;
_fetchLatestBanner();
}
_refreshBottomText();
}
// 重算底部文案:进行中"立即参与"、未开始显示距开始倒计时、已结束为空。
// 只写 ValueNotifier,触发 ValueListenableBuilder 局部刷新
void _refreshBottomText() {
if (!_isCountdownActivity) {
_bottomText.value = '';
return;
}
final start = _parseDate(_banner.startAt);
_bottomText.value = switch (_activityStatus()) {
_ActStatus.inProgress => '立即参与',
_ActStatus.beforeStart =>
start == null ? '' : _formatCountdown(start.difference(DateTime.now())),
_ActStatus.ended => '',
};
}
// 活动结束后拉最新 banner:有新场次则替换并写回 Config,无则从 Config 移除
Future<void> _fetchLatestBanner() async {
final id = _banner.id;
if (id == null || id.isEmpty || _isPinging) return;
_isPinging = true;
try {
final latest = await CommonService.pingBanner(id);
if (!mounted) return;
if (latest != null && latest.banner?.isNotEmpty == true) {
_banner = latest;
_updateConfigBanner(latest);
_endedPingRequested = false;
} else {
_removeConfigBanner();
}
widget.onBannerUpdated?.call();
} catch (_) {
} finally {
_isPinging = false;
if (mounted) {
_refreshBottomText();
setState(() {});
}
}
}
// 用新 banner 替换 Config.bannerJumps 里同位置同 id 的旧数据
void _updateConfigBanner(BannerJumpEntity banner) {
final index = Config.bannerJumps.indexWhere(
(e) => e.position == _banner.position && e.id == _banner.id);
if (index >= 0) {
Config.bannerJumps[index] = banner;
}
}
// 活动结束且无下一场:从 Config.bannerJumps 移除当前 banner
void _removeConfigBanner() {
Config.bannerJumps.removeWhere(
(e) => e.position == _banner.position && e.id == _banner.id);
}
// 解析后端时间字符串为本地时间;空值/占位(0001-01-01)/非法格式返回 null
DateTime? _parseDate(String? date) {
if (date == null || date.isEmpty || date.contains('0001-01-01'))
return null;
try {
return DateTime.parse(date).toLocal();
} catch (_) {
return null;
}
}
// 依据 startAt/endAt 判定活动处于 未开始 / 进行中 / 已结束
_ActStatus _activityStatus() {
final now = DateTime.now();
final start = _parseDate(_banner.startAt);
final end = _parseDate(_banner.endAt);
if (start != null && now.isBefore(start)) return _ActStatus.beforeStart;
if (end != null && !now.isBefore(end)) return _ActStatus.ended;
return _ActStatus.inProgress;
}
// 倒计时格式化:跨天显示"N天HH:MM:SS",否则"HH:MM:SS"
String _formatCountdown(Duration duration) {
final seconds = duration.inSeconds < 0 ? 0 : duration.inSeconds;
final days = seconds ~/ 86400;
final time = DateTimeUtil.formatHMS(seconds % 86400,
alwaysHour: true); // 当天内 HH:MM:SS
return days > 0 ? '$days天$time' : time;
}
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
// 内链可配到会员/金币页,来源统一记首页浮窗
onTap: () => pushToPageByLink(_banner.url,
sourcePage: PaySourcePage.homeFloatWindow),
child: SizedBox(
width: widget.width,
height: widget.height,
child: Stack(
children: [
NetworkImageLoader(
imageUrl: _banner.banner ?? '',
width: widget.width,
height: widget.height,
borderRadius: widget.radius,
fit: BoxFit.cover,
),
Positioned(
left: 0,
right: 0,
bottom: 2,
child: ValueListenableBuilder<String>(
valueListenable: _bottomText,
builder: (_, text, __) => text.isEmpty
? const SizedBox.shrink()
: Text(
text,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
}
// 浮窗活动状态:未开始 / 进行中 / 已结束
enum _ActStatus { beforeStart, inProgress, ended }
/// 是否用 [HomeFloatBanner] 渲染该 banner:仅 position==3 的浮窗位、有图、且非「抽奖」项
bool isHomeFloatBanner(BannerJumpEntity model, int position) {
if (position != 3) return false;
if (model.banner?.isNotEmpty != true) return false;
return !(model.title == '抽奖' && model.position == null);
}
@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'home_activity_float.dart';
import 'swiper_floating_widget.dart';
class HomeFloatWidget extends StatelessWidget {
const HomeFloatWidget({super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// 预售 + 支付分层浮窗(编排 + 轮播统一在 HomeActivityFloat
const HomeActivityFloat(),
SwiperFloatingWidget(
position: 3,
width: 64,
height: 64,
margin: EdgeInsets.only(top: 16),
),
],
);
}
}
@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../../config/config.dart';
import '../../../hj_model/splash/domain_source_model.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/widget/card_swiper/src/swiper.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../mine/mine_vip/pay_order_source.dart';
import '../../pre_sale/pre_sale_entry.dart';
import '../../pre_sale/pre_sale_provider.dart';
import 'home_float_banner.dart';
/// 首页浮窗入口:聚合 Config.bannerJumps 中指定 position 的 banner + 抽奖入口,轮播展示。
/// banner 命中 [isHomeFloatBanner] 用带倒计时的 [HomeFloatBanner],抽奖用 [LuckyDrawButton],其余普通图。
class SwiperFloatingWidget extends StatefulWidget {
// 必填:浮窗位置标识(对应 bannerJumps 的 position
final int position;
// ===== 尺寸 / 外观 =====
final double? width;
final double? height;
final double? radius;
final EdgeInsetsGeometry? margin;
// ===== 行为 =====
final int? autoPlayDuration; // 轮播间隔(毫秒)
final ValueChanged<BannerJumpEntity>? onItemClick; // 自定义点击;不传则走默认 pushToPageByLink
const SwiperFloatingWidget({
required this.position,
super.key,
this.width,
this.height,
this.radius,
this.margin,
this.autoPlayDuration = 5000,
this.onItemClick,
});
@override
State<SwiperFloatingWidget> createState() => _SwiperFloatingWidgetState();
}
class _SwiperFloatingWidgetState extends State<SwiperFloatingWidget> {
int _currentIndex = 0; // 当前轮播页下标
// 当前可见的浮窗项:本 position 下有图的 banner +(开启时)抽奖入口
List<BannerJumpEntity> get _visibleBanners {
final banners = Config.bannerJumps
.where((e) => e.position == widget.position && e.banner?.isNotEmpty == true)
.toList();
if (presaleProvider.luckyDrawH5?.isNotEmpty == true) {
banners.add(BannerJumpEntity(title: '抽奖'));
}
return banners;
}
// 是否抽奖入口项(title=抽奖 且无 position,借此与真实 banner 区分)
bool _isLuckyDraw(BannerJumpEntity model) => model.title == '抽奖' && model.position == null;
// Swiper 的 ValueKeyposition + 抽奖态 + 各 banner id,内容变化时换 key 强制重建
String _buildSwiperKey(List<BannerJumpEntity> banners) {
var key = 'home_float_${widget.position}';
if (presaleProvider.luckyDrawH5?.isNotEmpty == true) key += '_lucky';
for (final banner in banners) {
if (_isLuckyDraw(banner)) continue;
key += '_banner${banner.id ?? ''}';
}
return key;
}
@override
Widget build(BuildContext context) {
final banners = _visibleBanners; // 每帧只构建一次,key/itemBuilder/onTap 共用同一快照
if (banners.isEmpty) return const SizedBox();
final itemW = widget.width ?? 64.0;
final itemH = widget.height ?? 64.0;
final radius = widget.radius ?? 0.0;
return Container(
width: widget.width,
height: widget.height,
margin: widget.margin,
child: Swiper(
key: ValueKey(_buildSwiperKey(banners)),
index: _currentIndex,
autoplay: banners.length > 1,
autoplayDelay: widget.autoPlayDuration ?? 5000,
loop: banners.length != 1,
itemCount: banners.length,
itemBuilder: (_, index) => _buildItem(banners[index], itemW, itemH, radius),
onTap: (index) => _onItemTap(banners[index]),
onIndexChanged: (index) {
if (mounted) setState(() => _currentIndex = index);
},
),
);
}
// 单项:抽奖入口 / 带倒计时的活动浮窗 / 普通图,统一右对齐
Widget _buildItem(BannerJumpEntity model, double width, double height, double radius) {
Widget child;
if (_isLuckyDraw(model)) {
child = const LuckyDrawButton();
} else if (isHomeFloatBanner(model, widget.position)) {
child = HomeFloatBanner(
banner: model,
width: width,
height: height,
radius: radius,
onBannerUpdated: () {
if (mounted) setState(() {});
},
);
} else {
child = NetworkImageLoader(
imageUrl: model.banner ?? '',
width: width,
height: height,
borderRadius: radius,
fit: BoxFit.cover,
);
}
return Align(alignment: Alignment.centerRight, child: child);
}
// 点击:抽奖/活动浮窗自行处理点击(此处跳过),其余走自定义回调或默认跳转
void _onItemTap(BannerJumpEntity ad) {
if (_isLuckyDraw(ad)) return;
if (isHomeFloatBanner(ad, widget.position)) return;
if (widget.onItemClick != null) {
widget.onItemClick!.call(ad);
} else {
// 内链可配到会员/金币页,来源统一记首页浮窗
pushToPageByLink(
ad.url,
sourcePage: PaySourcePage.homeFloatWindow,
);
}
}
}