初始化

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,43 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'col_his_buy_page.dart';
import 'col_his_buy_sub_page.dart';
/// 收藏/购买/历史/喜欢共用:四种用途只差 tab 列表和能不能编辑
class ColHisBuyLogic extends GetxController with GetSingleTickerProviderStateMixin {
final PageType type;
ColHisBuyLogic(this.type);
//喜欢没有小说
late final List<LoadDataType> tabs = [
LoadDataType.video,
LoadDataType.short,
LoadDataType.drama,
LoadDataType.post,
LoadDataType.comics,
LoadDataType.cartoon,
LoadDataType.pictures,
if (type != PageType.like) LoadDataType.novel,
];
late final tabCtr = TabController(length: tabs.length, vsync: this);
//编辑态:列表项右上角挂删除按钮
bool isEdit = false;
//已购没有删除接口,不给编辑入口
bool get canEdit => type != PageType.buy;
void toggleEdit() {
isEdit = !isEdit;
update();
}
@override
void onClose() {
tabCtr.dispose();
super.onClose();
}
}
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import '../../../hj_utils/widget_util.dart';
import 'col_his_buy_logic.dart';
import 'col_his_buy_sub_page.dart';
/// 一个页面壳四种用途,每种钉一个独立 route。
/// 这些名字没注册进 AppRoutes,只能走 [ColHisBuyPage.to],不能 Get.toNamed
enum PageType {
collect('我的收藏', '/MineCollectPage'),
buy('我的购买', '/MineBuyPage'),
history('历史记录', '/MineHistoryPage'),
like('我的喜欢', '/MineLikePage');
final String title;
final String route;
const PageType(this.title, this.route);
}
/// 收藏 / 购买 / 历史 / 喜欢
class ColHisBuyPage extends StatelessWidget {
final PageType type;
const ColHisBuyPage(this.type, {super.key});
static Future<T?>? to<T>(PageType type) =>
Get.to(() => ColHisBuyPage(type), routeName: type.route);
@override
Widget build(BuildContext context) {
return GetBuilder<ColHisBuyLogic>(
init: ColHisBuyLogic(type),
builder: (logic) {
return Scaffold(
appBar: AppBar(
title: Text(type.title),
actions: [
//已购没有删除接口,不给编辑入口
if (logic.canEdit)
GestureDetector(
onTap: logic.toggleEdit,
child: Padding(
padding: EdgeInsets.only(right: 16),
child: Text(
logic.isEdit ? '完成' : '编辑',
style: textStyle(14, Color(0xff666666), FontWeight.w400),
),
),
),
],
),
body: Column(
children: [
//分类 tab
Container(
color: Get.theme.appBarTheme.backgroundColor,
child: TabBar(
labelPadding: EdgeInsets.zero,
padding: EdgeInsets.only(left: 8.w),
tabAlignment: TabAlignment.start,
indicator: CustomIndicator(
width: 16,
height: 3,
isGradient: true,
),
indicatorWeight: 1,
unselectedLabelStyle: TextStyle(
color: Color(0xffcccccc), fontSize: 14, height: 1),
labelStyle: TextStyle(
color: Colors.white,
fontSize: 14,
height: 1,
fontWeight: FontWeight.w500,
),
isScrollable: true,
tabs: logic.tabs
.map(
(e) => Padding(
padding: EdgeInsets.symmetric(
horizontal: 12.w, vertical: 8.h),
child: Text(e.title),
),
)
.toList(),
controller: logic.tabCtr,
),
),
//各分类列表
Expanded(
child: TabBarView(
controller: logic.tabCtr,
children: logic.tabs
.map((e) => ColHisBuySubPage(e,
pageType: type, isEdit: logic.isEdit)
.keepAlive)
.toList(),
),
)
],
),
);
},
);
}
}
@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/cartoon_media_info.dart';
import 'package:hgdj/hj_model/drama_media_info.dart';
import 'package:hgdj/hj_model/list_base_model.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/acg_service.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/history_util.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
import 'package:hgdj/tools_base/cache/history/drama_resume_store.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import '../../cartoon/acg_widget_item.dart';
import '../../community/widget/community_post_widget.dart';
import '../../community/widget/pic_simple_cell.dart';
import '../../drama/drama_detail_page.dart';
import '../../home/home_cell_style/tiktok_simple_cell.dart';
import '../../home/home_cell_style/video_simple_cell.dart';
import '../widgets/stage_cell_widget.dart';
import 'col_his_buy_page.dart';
import 'col_his_buy_sub_page.dart';
/// 用途 × 分类 决定用哪个 logic:短剧四种用途同一套 DramaInfo 列表,其余按用途分
ColHisBuySubLogic instanceLogic(PageType pageType, LoadDataType loadType) {
if (loadType == LoadDataType.drama) return DramaLogic(loadType, pageType);
return switch (pageType) {
PageType.collect => CollectLogic(loadType),
PageType.buy =>
loadType.isAcg ? BuyAcgLogic(loadType) : BuyVideoLogic(loadType),
PageType.history => HistoryLogic(loadType),
PageType.like => LikeLogic(loadType),
};
}
/// 收藏/购买/历史/喜欢的子列表基类:分页刷新和 item 渲染都在这,子类只管取数和删除
abstract class ColHisBuySubLogic extends ListBaseLogic {
final LoadDataType loadType;
ColHisBuySubLogic(this.loadType);
@override
void onReady() {
super.onReady();
loadData();
}
void loadData({bool isRefresh = true}) =>
fetchData(isRefresh: isRefresh, fetch: fetch);
/// 返回 (本页数据, 是否还有下一页),页码由基类算好传进来
Future<(List?, bool)> fetch(int page);
/// 本列表是否支持删除。false 时编辑态不挂删除按钮,免得点了没反应
bool get canDelete => true;
/// 取消收藏 / 删除一条,不支持的列表不用覆写
Future<void> deleteAt(int index) async {}
Widget itemAt(int index) {
final model = dataList![index];
return switch (loadType) {
LoadDataType.video => VideoSimpleCell(videoModel: model),
LoadDataType.game =>
VideoSimpleCell(videoModel: model, isFromHY: true, textLines: 1),
LoadDataType.short => TiktokSimpleCell(
videoModel: model,
textLines: 1,
isShowBottom: true,
isShowTime: true),
LoadDataType.pictures => PicSimpleCell(videoModel: model, textLines: 1),
LoadDataType.post => CommunityPostWidget(videoModel: model),
LoadDataType.collections => SimpleCollectionsItem(model),
LoadDataType.comics ||
LoadDataType.cartoon ||
LoadDataType.novel =>
AcgItemWidget(info: model),
//短剧卡片右下角挂集数状态,点进去续播
LoadDataType.drama => VideoSimpleCell(
videoModel: model,
textLines: 1,
coverRightText: (model as VideoModel).dramaInfo?.episodeNumberStatus,
showLevelIcon: false,
onTap: () => _openDrama(model),
),
};
}
/// 进短剧二级页,不指定集数(有续播位置就接着上次播)。
/// 退回来重拉一次列表:历史按最后观看时间排,刚看的这部要跳到最前;
/// 收藏/喜欢那几个列表也可能在二级页里被改过状态
Future<void> _openDrama(VideoModel model) async {
await Get.to(() => DramaDetailPage(drama: model.dramaInfo));
if (isClosed) return; // 二级页开着时用户退了本页
loadData();
}
}
/// 短剧的收藏/已购/喜欢/历史:接口不同但返回的都是 DramaInfo 列表,
/// 统一转成 VideoModel 交给和热门短剧橱窗同一个卡片渲染
class DramaLogic extends ColHisBuySubLogic {
final PageType pageType;
DramaLogic(super.loadType, this.pageType);
static const int _pageSize = 20;
@override
Future<(List?, bool)> fetch(int page) async {
//历史记录只在本地:播放时写进 dramaResume 那张表,按最后观看时间倒序
final list = pageType == PageType.history
? (await dramaResume.history(page: page, pageSize: _pageSize))
.map((e) => e.drama!)
.toList()
: (await _api(page))?.list ?? [];
//本地和接口都不看 hasNext,满页就当还有
return (
list.map((e) => e.toVideoModel(null)).toList(),
list.length >= _pageSize
);
}
Future<ListBaseModel<DramaMediaInfo>?> _api(int page) => switch (pageType) {
PageType.buy => DramaService.fetchPurchased(page, size: _pageSize),
PageType.like => DramaService.fetchLikes(page, size: _pageSize),
_ => DramaService.fetchFavorites(page, size: _pageSize),
};
//已购没有删除接口,编辑态不给删除按钮;历史是本地表,能删
@override
bool get canDelete => pageType != PageType.buy;
@override
Future<void> deleteAt(int index) async {
if (!canDelete) return;
final id = (dataList![index] as VideoModel).dramaInfo?.id ?? '';
switch (pageType) {
case PageType.history:
await dramaResume.erase(id);
case PageType.collect:
await ACGService.deleteBookshelf(id);
default:
await CommonService.cancelLike(id, 'drama');
}
dataList!.removeAt(index);
update();
}
}
/// 浏览历史,全部读本地库
class HistoryLogic extends ColHisBuySubLogic {
HistoryLogic(super.loadType);
static const int _pageSize = 20;
@override
Future<(List?, bool)> fetch(int page) async {
final List list = loadType.isAcg
? await HistoryUtil.fetch<CartoonMediaInfo>(loadType.historyType,
page: page, pageSize: _pageSize)
: await HistoryUtil.fetch<VideoModel>(loadType.historyType,
page: page, pageSize: _pageSize);
//满页说明还有更多,不满即到底
return (list, list.length >= _pageSize);
}
@override
Future<void> deleteAt(int index) async {
final model = dataList![index];
if (model is! VideoModel && model is! CartoonMediaInfo) {
debugLog('删除类型未处理');
return;
}
await HistoryUtil.delete(model, loadType.historyType);
dataList!.remove(model);
update();
}
}
/// 已购的漫画/动漫/小说
class BuyAcgLogic extends ColHisBuySubLogic {
BuyAcgLogic(super.loadType);
@override
Future<(List?, bool)> fetch(int page) async {
final res = await ACGService.fetchAcgBuyData<CartoonMediaInfo>(page, 12,
mediaType: loadType.apiType);
return (res?.list, res?.hasNext ?? false);
}
}
/// 已购的影视/短视频/帖子/图集
class BuyVideoLogic extends ColHisBuySubLogic {
BuyVideoLogic(super.loadType);
@override
Future<(List?, bool)> fetch(int page) async {
final res = await MineService.fetchBuyVideo(
newsType: loadType.apiType, page: page, size: 12);
return (res?.list, res?.hasNext ?? false);
}
}
/// 我的喜欢,取数和取消都是同一套 like 接口
class LikeLogic extends ColHisBuySubLogic {
LikeLogic(super.loadType);
@override
Future<(List?, bool)> fetch(int page) async {
final res = await MineService.fetchLikes(
likeType: loadType.apiType, page: page, size: 12);
return (res?.list, res?.hasNext ?? false);
}
@override
Future<void> deleteAt(int index) async {
await CommonService.cancelLike(dataList![index].id, loadType.apiType);
loadData();
}
}
/// 我的收藏,漫画/动漫/小说在书架接口,其余在收藏接口
class CollectLogic extends ColHisBuySubLogic {
CollectLogic(super.loadType);
@override
Future<(List?, bool)> fetch(int page) async {
final ListBaseModel? res = loadType.isAcg
? await ACGService.fetchAcgBooklib<CartoonMediaInfo>(loadType.apiType,
page: page, size: 12)
: await MineService.fetchCollectList<VideoModel>(loadType.apiType,
page: page, size: 12);
return (res?.list, res?.hasNext ?? false);
}
@override
Future<void> deleteAt(int index) async {
final model = dataList![index];
final bool isDeleted;
if (model is VideoModel) {
isDeleted =
await MineService.postCollect(model.id, loadType.apiType, false);
} else if (model is CartoonMediaInfo) {
isDeleted = await ACGService.deleteBookshelf(model.id ?? '');
} else {
debugLog('删除类型未处理');
return;
}
if (!isDeleted) return;
dataList!.remove(model);
update();
}
}
@@ -0,0 +1,267 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/mine/collec_history_buy/col_his_buy_page.dart';
import 'package:hgdj/hj_page/mine/collec_history_buy/col_his_buy_sub_logic.dart';
import 'package:hgdj/hj_utils/const.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';
enum LoadDataType {
/// 长视频
video(
title: '影视',
style: PageStyle.grid,
apiType: 'SP',
crossAxisCount: 2,
spaceV: 12,
spaceH: 8,
padding: EdgeInsets.only(left: 20, right: 20, top: 14),
ratio: 174 / 160,
historyType: MediaStyle.Video,
),
/// 短视频
short(
title: '抖音',
style: PageStyle.grid,
apiType: 'SHORT',
crossAxisCount: 3,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.fromLTRB(16, 12, 16, 12),
ratio: 115 / 192,
historyType: MediaStyle.ShortVideo,
),
/// 短剧 media_bookshelf/list, media/my_buy, mine/like;历史走本地 DramaResumeStore
drama(
title: '短剧',
style: PageStyle.grid,
apiType: 'drama',
crossAxisCount: 2,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.only(left: 16, right: 16, top: 14),
ratio: 168 / 266,
historyType: MediaStyle.Drama,
),
/// 帖子
post(
title: '帖子',
style: PageStyle.list,
apiType: 'COVER',
crossAxisCount: 2,
spaceV: 10,
spaceH: 14,
padding: EdgeInsets.only(left: 0, right: 0, top: 14),
ratio: 191 / 174,
historyType: MediaStyle.Community,
),
/// 漫画 media_bookshelf/list, media/my_buy
comics(
title: '漫画',
style: PageStyle.grid,
apiType: 'image',
crossAxisCount: 3,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.only(left: 16, right: 16, top: 14),
ratio: 115 / 192,
historyType: MediaStyle.Comics,
),
/// 动漫 media_bookshelf/list, media/my_buy
cartoon(
title: '动漫',
style: PageStyle.grid,
apiType: 'video',
crossAxisCount: 3,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.only(left: 16, right: 16, top: 14),
ratio: 115 / 192,
historyType: MediaStyle.Cartoon,
),
/// 图集
pictures(
title: '图集',
style: PageStyle.grid,
apiType: 'PIC',
crossAxisCount: 3,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.fromLTRB(16, 12, 16, 12),
ratio: 115 / 192,
historyType: MediaStyle.Pic,
),
/// 小说 media_bookshelf/list, media/my_buy
novel(
title: '小说',
style: PageStyle.grid,
apiType: 'text',
crossAxisCount: 3,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.only(left: 16, right: 16, top: 14),
ratio: 115 / 192,
historyType: MediaStyle.Novel,
),
/// 种子/黄油帖子(tab 暂时关掉,配置留着)
game(
title: '种子',
style: PageStyle.grid,
apiType: 'game',
crossAxisCount: 2,
spaceV: 12,
spaceH: 8,
padding: EdgeInsets.only(left: 20, right: 20, top: 14),
ratio: 174 / 160,
historyType: MediaStyle.Game,
),
/// 合集(tab 暂时关掉,配置留着)
collections(
title: '合集',
style: PageStyle.list,
apiType: 'collection',
crossAxisCount: 2,
spaceV: 12,
spaceH: 6,
padding: EdgeInsets.only(left: 16, right: 16, top: 14),
ratio: 115 / 175,
historyType: MediaStyle.Cartoon,
);
/// tab 标题
final String title;
/// 页面风格 列表、网格
final PageStyle style;
/// 接口的类型参数(收藏 type / 已购 mediaType / 喜欢 likeType 是同一个值)
final String apiType;
/// 针对网格 纵向多少个
final int crossAxisCount;
/// 网格 垂直间隔
final double spaceV;
/// 网格 横向间隔
final double spaceH;
/// 页面间距
final EdgeInsets padding;
/// 网格宽高比
final double ratio;
/// 历史记录类型
final MediaStyle historyType;
const LoadDataType({
required this.title,
required this.style,
required this.apiType,
required this.crossAxisCount,
required this.spaceV,
required this.spaceH,
required this.padding,
required this.ratio,
required this.historyType,
});
/// 漫画/动漫/小说走 ACG 那套接口,返回 CartoonMediaInfo;其余走 VideoModel
bool get isAcg => this == comics || this == cartoon || this == novel;
}
enum PageStyle {
list,
grid;
}
class ColHisBuySubPage extends StatelessWidget {
final LoadDataType loadType;
final PageType pageType;
final bool isEdit;
const ColHisBuySubPage(
this.loadType, {
super.key,
required this.pageType,
this.isEdit = false,
});
//一个页面里 8 个 tab 的 logic 同时活着,按用途+分类隔离;
//用 .name 而非 toString(),它是编译期常量,release 混淆不会改
String get _tag => '${pageType.name}_${loadType.name}';
@override
Widget build(BuildContext context) {
return GetBuilder<ColHisBuySubLogic>(
tag: _tag,
init: instanceLogic(pageType, loadType),
builder: (logic) {
return pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onRefresh: (_) => logic.loadData(),
onLoading: (_) => logic.loadData(isRefresh: false),
child: () {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.isEmptyData) return CErrorWidget();
return _list(logic);
}(),
);
},
);
}
Widget _list(ColHisBuySubLogic logic) {
final count = logic.dataList!.length;
if (loadType.style == PageStyle.list) {
return ListView.separated(
separatorBuilder: (_, __) => 14.sizeBoxH,
itemCount: count,
padding: loadType.padding,
itemBuilder: (_, index) => _item(logic, index, StackFit.loose),
);
}
return GridView.builder(
padding: loadType.padding,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: loadType.crossAxisCount,
mainAxisSpacing: loadType.spaceV,
crossAxisSpacing: loadType.spaceH,
childAspectRatio: loadType.ratio,
),
itemCount: count,
itemBuilder: (_, index) => _item(logic, index, StackFit.expand),
);
}
//列表项 + 编辑态右上角删除按钮
Widget _item(ColHisBuySubLogic logic, int index, StackFit fit) {
return Stack(
fit: fit,
children: [
logic.itemAt(index),
if (isEdit && logic.canDelete)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => logic.deleteAt(index),
child: Image.asset('mine_col_delete.png'.mineImgPath, width: 20),
),
),
],
);
}
}
+222
View File
@@ -0,0 +1,222 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
import 'package:hgdj/hj_utils/light_model.dart';
import 'package:hgdj/hj_utils/store_keys.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/common_alert.dart';
import 'package:hgdj/track_event_manager/device_service.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../config/config.dart';
import '../../hj_model/user/user_info_model.dart';
import '../../routers/jump_router.dart';
import '../main_page/provider/msg_provider.dart';
import '../web_page/h5_page.dart';
import 'collec_history_buy/col_his_buy_page.dart';
import 'identity/mine_identity_page.dart';
import 'message/message_center_page.dart';
import 'mine_drive/mine_drive_page.dart';
import 'mine_following/mine_following_view.dart';
import 'mine_post/mine_publish_main_page.dart';
import 'mine_setting/mine_code_page.dart';
import 'mine_setting/mine_password_logic.dart';
import 'mine_setting/mine_password_page.dart';
import 'mine_setting/mine_setting_page.dart';
import 'mine_share/mine_share_page.dart';
import 'more_question/mine_feedback_page.dart';
import 'welfare/welfare_home_page.dart';
//图标
class IconBean {
String image;
String name;
IconBean(this.image, this.name);
}
class MineMainLogic extends GetxController with GetTickerProviderStateMixin {
RefreshController? refreshCtr;
UserInfoModel? get userInfo => globalStore.meInfo;
List<String> get menuVerNameArr => [
"意见反馈",
"我的购买",
"我的喜欢",
"分享邀请",
"应用推荐",
"账号凭证",
"锁屏密码",
"我的消息",
];
List<IconBean> iconList = [
IconBean('ic_launcher.webp'.commonImgPath, Config.appName),
IconBean('icon_1.webp'.appImgPath, "爱音乐"),
IconBean('icon_2.webp'.appImgPath, "爱阅读"),
IconBean('icon_3.webp'.appImgPath, "支付宝"),
IconBean('icon_4.webp'.appImgPath, "哔哩哔哩"),
IconBean('icon_5.webp'.appImgPath, "饼干大作战"),
IconBean('icon_6.webp'.appImgPath, "美食借鉴"),
IconBean('icon_7.webp'.appImgPath, "我的世界"),
IconBean('icon_8.webp'.appImgPath, "神选之战"),
IconBean('icon_9.webp'.appImgPath, "随心无线"),
IconBean('icon_10.webp'.appImgPath, "特轩小说"),
IconBean('icon_11.webp'.appImgPath, "天天麻将"),
IconBean('icon_12.webp'.appImgPath, "途游"),
IconBean('icon_13.webp'.appImgPath, "微信"),
IconBean('icon_14.webp'.appImgPath, "问道"),
IconBean('icon_15.webp'.appImgPath, "英语图书角"),
];
@override
void onReady() async {
super.onReady();
bool savedQR = await lightKV.getBool(StoreKeys.HAVE_SAVE_QR_CODE) ?? false;
if (!savedQR) {
lightKV.setBool(StoreKeys.HAVE_SAVE_QR_CODE, true);
await _showSaveQrDialog();
}
onRefresh();
}
Future<void> getUserInfo() async {
await globalStore.updateUserInfo();
}
Future<void> getWallet() async {
await globalStore.refreshWallet();
}
Future<void> onRefresh() async {
await Future.wait([
getUserInfo(),
MineMsgProvider().getMessageTip(),
PreSaleProvider().refreshAll(), //刷新预售
MineMsgProvider().refreshPayPopup(), //刷新支付分层配置(状态变则清会员卡缓存 + 重启倒计时)
getWallet(),
]).whenComplete(() {
refreshCtr?.refreshCompleted();
update();
});
}
/// 首次进入二维码页面提示保存二维码
Future<dynamic> _showSaveQrDialog() async {
final value = await Get.dialog(MineAccountIdentityPage());
if (value != null && value is UserInfoModel) {
update();
}
return value;
}
void menuTapEvent(int index) async {
if (index == 0) {
//我的帖子
Get.to(MinePublishPostPage(), preventDuplicates: false);
} else if (index == 1) {
//我的喜欢
ColHisBuyPage.to(PageType.like);
} else if (index == 2) {
//我的关注
Get.to(() => MineFollowingPage());
} else if (index == 3) {
//消息中心
await Get.to(() => MessageCenterPage(),
opaque: true, preventDuplicates: false);
MineMsgProvider().getMessageTip();
}
}
void menuVerTapEvent(int index) async {
if (index == 0) {
//创作中心
Get.to(MinePublishPostPage(), preventDuplicates: false);
} else if (index == 1) {
//意见反馈
Get.to(() => const MineFeedbackPage());
} else if (index == 2) {
//我的购买
ColHisBuyPage.to(PageType.buy);
} else if (index == 3) {
//我的喜欢
ColHisBuyPage.to(PageType.like);
} else if (index == 4) {
//分享邀请
Get.to(() => MineSharePage());
} else if (index == 5) {
//领取兑换
Get.to(() => MineExchangeCodePage(type: 1));
} else if (index == 6) {
//加群开车
Get.to(() => MineDrivePage());
} else if (index == 7) {
//应用推荐
Get.to(() => const WelfareHomePage(index: 1), preventDuplicates: false);
} else if (index == 8) {
//账号凭证
await Get.dialog(MineAccountIdentityPage());
} else if (index == 9) {
//锁屏密码
if (globalStore.password.isNotEmpty) {
final close = await CommonAlert.show(
title: '关闭锁定码',
content: '您确定关闭锁定密码?\n\n开启需重新输入密码',
cancelText: '稍后再说',
confirmText: '确定关闭',
);
if (close) {
Get.toNamed(MinePasswordPage.routeName,
arguments: MinePasswordType.close);
}
} else {
Get.toNamed(
MinePasswordPage.routeName,
arguments: MinePasswordType.setting,
);
}
} else if (index == 10) {
menuTapEvent(3);
} else if (index == 11) {
pushToCustomService();
}
}
toSetting() {
Get.to(() => const MineSettingPage());
}
toMineShare() {
Get.to(() => MineSharePage());
}
toExchangeCenter() {
Get.to(() => const WelfareHomePage(index: 1));
}
toShop() async {
LoadingHelper.showLoading();
var result = await VidService.getShopAddress();
LoadingHelper.dismissLoading();
if (result is String && result.isNotEmpty != true) {
showToast("获取商店地址失败~");
return;
}
Get.to(H5Page(url: result, showTitle: false, showClose: true),
popGesture: true);
}
changeIcon(int index) async {
if (!await CommonAlert.show(content: '您确定要更换图标吗?')) return;
const platform = MethodChannel(DeviceInfoService.requestChannel);
platform.invokeMethod('changeIcon', {"iconIndex": index});
}
}
+353
View File
@@ -0,0 +1,353 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/address.dart';
import 'package:hgdj/config/config.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/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../hj_utils/widget_util.dart';
import '../../tools_base/banner/ads_grid_view_widget.dart';
import 'home_mine_logic.dart';
import 'widgets/mine_info_widget.dart';
import 'widgets/vip_entry_card.dart';
//我的页面
class HomeMinePage extends StatelessWidget {
const HomeMinePage({super.key});
/// 常用功能里唯一需要展示未读红点的入口
static const _messageMenuTitle = '我的消息';
@override
Widget build(BuildContext context) {
return GetBuilder<MineMainLogic>(
init: MineMainLogic(),
builder: (logic) {
return Consumer<GlobalStore>(builder: (_, store, ___) {
return Scaffold(
body: SizedBox.expand(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
enablePullUp: false,
onRefresh: (ctr) => logic.onRefresh(),
child: SingleChildScrollView(
child: Stack(
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: _buildBg(),
),
SafeArea(child: _buildContent(logic, store)),
],
),
),
),
),
);
});
},
);
}
//顶部背景:优先后台下发,兜底默认图
Widget _buildBg() {
final bg = Config.mineBg;
if (bg != null && bg.isNotEmpty) {
return NetworkImageLoader(imageUrl: bg, borderRadius: 0);
}
return Image.asset(
'mine_bg.webp'.mineImgPath,
);
}
Widget _buildContent(MineMainLogic logic, GlobalStore store) {
return Stack(
children: [
_buildTop(logic),
Column(
children: [
48.sizeBoxH,
MineInfoWidget(logic: logic),
21.sizeBoxH,
_buildCountView(), //次数
21.sizeBoxH,
VipEntryCard(), //会员入口
12.sizeBoxH,
MineTaskMenuWidget(), //金币充值等
12.sizeBoxH,
MineFunchtionGridView(), //收藏/历史/下载/关注
12.sizeBoxH,
_buildShop(logic), //商店入口
AdsGridViewWidget(
8,
accordingAdsType: true,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
Container(
margin: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9),
color: Colors.white.withValues(alpha: .05),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
alignment: Alignment.centerLeft,
margin: EdgeInsets.symmetric(horizontal: 16, vertical: 5),
child: Text("常用功能",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 16,
)),
),
GridView.builder(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.zero,
shrinkWrap: true,
itemCount: logic.menuVerNameArr.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 0,
mainAxisSpacing: 0,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
String titleDesc = logic.menuVerNameArr[index];
String imagePath =
'mine_menu_${index + 1}.png'.mineImgPath;
// 仅「我的消息」带未读红点,按名称判断避免菜单顺序变动后红点错位
final isMessageEntry = titleDesc == _messageMenuTitle;
return InkWell(
enableFeedback: false,
onTap: () => logic.menuVerTapEvent(index),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
!isMessageEntry
? Image.asset(imagePath, width: 28)
: Stack(
children: [
Image.asset(
"mine_notification.png".mineImgPath,
width: 28,
),
Consumer<MineMsgProvider>(
builder: (context, model, child) =>
Positioned(
right: 2,
top: 2,
child: model.buildTipsView(),
),
)
],
),
5.sizeBoxH,
Text(
titleDesc,
style:
TextStyle(color: Colors.white, fontSize: 12),
),
],
),
);
},
),
// iOS 换图标需弹系统弹窗且要预置图标资源,暂不支持,仅 Android 显示该入口
if (!Platform.isIOS) ...[
Padding(
padding: EdgeInsets.only(left: 12, top: 10, right: 12),
child: Row(
children: [
Text(
'设置桌面图标',
style: TextStyle(
color: Color(0xffEFEFEF),
fontSize: 14,
fontWeight: FontWeight.w500),
),
Spacer(),
InkWell(
enableFeedback: false,
onTap: () => logic.changeIcon(0),
child: Text(
'恢复默认',
style: TextStyle(
color: Color(0xff989898),
fontSize: 12,
fontWeight: FontWeight.w400),
),
),
],
),
),
11.sizeBoxH,
SizedBox(
height: 76,
child: ListView.separated(
padding: EdgeInsets.symmetric(horizontal: 16),
scrollDirection: Axis.horizontal,
separatorBuilder: (_, __) => 7.sizeBoxW,
itemCount: logic.iconList.length,
itemBuilder: (BuildContext context, int index) {
final data = logic.iconList[index];
return InkWell(
enableFeedback: false,
onTap: () => logic.changeIcon(index),
child: Column(
children: [
Image.asset(data.image, width: 54),
4.sizeBoxH,
Text(
data.name,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12,
),
)
],
),
);
},
),
),
10.sizeBoxH,
],
],
),
),
12.sizeBoxH,
Text(
'我的邀请码: ${store.meInfo?.promotionCode ?? ''}',
style: textStyle(16, Color(0xffBDBDBD), FontWeight.w400),
),
6.sizeBoxH,
_buildGroundUrl(),
30.sizeBoxH,
],
),
],
);
}
//永久官方地址:地址未下发时不渲染,避免点击/复制空串
Widget _buildGroundUrl() {
final url = Address.groundUrl;
if (url == null || url.isEmpty) return const SizedBox.shrink();
return GestureDetector(
onTap: () => launchUrlToWeb(url),
onLongPress: () {
Clipboard.setData(ClipboardData(text: url));
showToast("复制成功");
},
child: Text(
'永久官方地址: $url',
style: textStyle(12, Color(0xff525252), FontWeight.w400),
),
);
}
//消息和设置
Widget _buildTop(MineMainLogic logic) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
InkWell(
enableFeedback: false,
onTap: () => logic.menuTapEvent(3),
child: Image.asset("mine_notification.png".mineImgPath, width: 24),
),
Spacer(),
InkWell(
enableFeedback: false,
onTap: () => logic.toSetting(),
child: Image.asset("setting.png".mineImgPath, width: 24),
),
],
),
);
}
//次数
Widget _buildCountView() {
return Row(
children: [
50.sizeBoxW,
Expanded(
child: Consumer2<GlobalStore, PreSaleProvider>(
builder: (context, store, provider, child) {
int count = (store.wallet?.downloadCount ?? 0) +
(provider.remain?.todayDownloadCount ?? 0);
return _buildCountItem(count.toString(), '剩余缓存次数');
},
),
),
Container(
color: Colors.white.withValues(alpha: .1),
width: 0.5,
height: 12,
),
Expanded(
child: Consumer2<GlobalStore, PreSaleProvider>(
builder: (context, store, provider, child) {
//预售权益和免费次数的和
final total = (store.wallet?.aiUndressFreeTimes ?? 0) +
(provider.remain?.todayAiUndressCount ?? 0);
return _buildCountItem(total.toString(), '剩余AI脱衣次数');
},
),
),
50.sizeBoxW,
],
);
}
Widget _buildCountItem(String count, String title) {
return Column(
children: [
Text(
count,
style: textStyle(16, Colors.white, FontWeight.w500),
),
4.sizeBoxH,
Text(
title,
style: textStyle(
12, Colors.white.withValues(alpha: .7), FontWeight.w400),
),
],
);
}
Widget _buildShop(MineMainLogic logic) {
if (Config.isStoreOpen) {
return InkWell(
enableFeedback: false,
onTap: () => logic.toShop(),
child: Padding(
padding: const EdgeInsets.only(bottom: 10.0, left: 10, right: 10),
child: AspectRatio(
aspectRatio: 355 / 82,
child: Image.asset('mine_shop_icon.webp'.mineImgPath),
),
),
);
}
return SizedBox.shrink();
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import '../../../hj_model/user/user_info_model.dart';
class MineIdentityLogic extends GetxController {
MineIdentityLogic get to => Get.find<MineIdentityLogic>();
UserInfoModel? meInfo = globalStore.meInfo;
GlobalKey boundaryKey = GlobalKey();
String qrCodeStr = '';
int? inviterNum;
@override
void onReady() {
super.onReady();
loadData();
}
void loadData() async {
globalStore.updateUserInfo();
qrCodeStr = await MineService.certificateQR() ?? '';
update();
}
}
@@ -0,0 +1,227 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/cupertino.dart';
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/config/address.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/image_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../tools_base/global_store/store.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'mine_identity_logic.dart';
//账号凭证页面
class MineAccountIdentityPage extends StatelessWidget {
const MineAccountIdentityPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<MineIdentityLogic>(
init: MineIdentityLogic(),
builder: (controller) {
return Material(
color: Colors.transparent,
child: Container(
alignment: Alignment.center,
width: double.infinity,
height: double.infinity,
child: Center(
child: SingleChildScrollView(
child: SizedBox(
width: 300,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
RepaintBoundary(
key: controller.boundaryKey,
child: Stack(
alignment: Alignment.topCenter,
children: [
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(9),
child: Image.asset('share_bg.webp'.mineImgPath,
fit: BoxFit.fill),
),
),
Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
30.sizeBoxH,
Consumer<GlobalStore>(
builder: (_, provider, __) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(50),
border: Border.all(
color: Color(0x4DF68804), width: 3),
),
child: NetworkImageLoader(
imageUrl:
provider.meInfo?.portrait ?? '',
width: 82,
height: 82,
borderRadius: 50,
),
);
},
),
10.sizeBoxH,
Text(
'账号凭证',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
10.sizeBoxH,
Text(
'ID ${globalStore.meInfo?.uid ?? ""}',
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
30.sizeBoxH,
Text("提示*可通过官方客服找回账号",
style: TextStyle(
fontSize: 12,
color: Color(0xFFF68804))),
10.sizeBoxH,
Center(
child: Container(
padding: EdgeInsets.all(11.w),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
"code_bg.webp".mineImgPath),
fit: BoxFit.fill,
),
),
child: Container(
padding: EdgeInsets.all(6.w),
color: Colors.white,
child: controller.qrCodeStr.isNotEmpty
? QrImageView(
data: controller.qrCodeStr,
version: QrVersions.auto,
padding: EdgeInsets.zero,
size: 100,
)
: SizedBox(
width: 100,
height: 100,
child: Center(
child:
CupertinoActivityIndicator(
color: AppColors.actionRed,
),
),
)),
),
),
18.h.sizeBoxH,
EasyRichText(
"APP更新可能会导致会员账号掉线\n请勿删除此凭证,此凭证是您永久有\n效的登陆途径。",
textAlign: TextAlign.center,
defaultStyle: TextStyle(
color:
Colors.white.withValues(alpha: .55),
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.5),
patternList: [
EasyRichTextPattern(
targetString: '请勿删除此凭证',
style: TextStyle(
color: Color(0xFFF52C56),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
],
),
Container(
height: 30,
padding: EdgeInsets.symmetric(
horizontal: 18, vertical: 3),
child: Text(
'${Config.appName} 官网地址 ${Address.groundUrl ?? ""}',
style: TextStyle(
color:
Colors.white.withValues(alpha: .55),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
20.sizeBoxH,
],
),
],
),
),
10.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () async {
var success = await ImageUtil.saveWidgetToAlbum(
controller.boundaryKey);
if (success) {
showToast("保存成功");
Get.back();
}
},
child: Container(
width: 220,
height: 44,
padding: EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(22),
),
alignment: Alignment.center,
child: Text(
"立即保存",
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
20.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => Get.back(result: false),
child: Image.asset("close_button.png".commonImgPath,
width: 32),
)
],
),
),
),
),
),
);
},
);
}
}
@@ -0,0 +1,29 @@
class ApcApiModel {
String? bank;
bool? validated;
String? cardType;
String? key;
List<dynamic>? messages;
String? stat;
static ApcApiModel? fromMap(Map<String, dynamic>? map) {
if (map == null) return null;
ApcApiModel apcApiModel = ApcApiModel();
apcApiModel.bank = map['bank'];
apcApiModel.validated = map['validated'];
apcApiModel.cardType = map['cardType'];
apcApiModel.key = map['key'];
apcApiModel.messages = map['messages'];
apcApiModel.stat = map['stat'];
return apcApiModel;
}
Map toJson() => {
"bank": bank,
"validated": validated,
"cardType": cardType,
"key": key,
"messages": messages,
"stat": stat,
};
}
@@ -0,0 +1,123 @@
class WithdrawConfig {
WithdrawConfig({
this.channels,
this.id,
this.cashTax,
this.coinTax,
this.gameTax,
});
List<Channel>? channels;
int? id;
int? cashTax;
int? coinTax;
int? gameTax;
Channel? get getBankCardChannel {
for (Channel item in (channels ?? [])) {
if (item.isBankCard) {
return item;
}
}
return null;
}
List<Channel> get realChannel {
final channel_ = <Channel>[];
if (getBankCardChannel != null) {
channel_.add(getBankCardChannel!);
}
if (getUsdtChannel != null) {
channel_.add(getUsdtChannel!);
}
return channel_;
}
Channel? get getUsdtChannel {
for (Channel item in (channels ?? [])) {
if (item.isUsdt) {
return item;
}
}
return null;
}
bool get hasBankCard {
for (Channel item in (channels ?? [])) {
if (item.isBankCard) {
return true;
}
}
return false;
}
bool get hasUsdt {
for (Channel item in (channels ?? [])) {
if (item.isUsdt) {
return true;
}
}
return false;
}
factory WithdrawConfig.fromJson(Map<String, dynamic> json) => WithdrawConfig(
channels: List<Channel>.from(json["channels"].map((x) => Channel.fromJson(x))),
id: json["ID"],
cashTax: json["cashTax"],
coinTax: json["coinTax"],
gameTax: json["gameTax"],
);
Map<String, dynamic> toJson() => {
"channels": channels == null ? [] : List<dynamic>.from(channels!.map((x) => x.toJson())),
"ID": id,
"cashTax": cashTax,
"coinTax": coinTax,
"gameTax": gameTax,
};
}
class Channel {
Channel({
this.channelName,
this.cid,
this.payType,
this.minMoney,
this.maxMoney,
this.qpMinMoney,
this.qpMaxMoney,
});
String? channelName;
String? cid;
String? payType;
int? minMoney;
int? maxMoney;
int? qpMinMoney;
int? qpMaxMoney;
bool get isBankCard => payType?.toLowerCase() == "bankcard";
bool get isUsdt => payType?.toLowerCase() == "usdt";
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
channelName: json["channelName"],
cid: json["cid"],
payType: json["payType"],
minMoney: json["minMoney"],
maxMoney: json["maxMoney"],
qpMinMoney: json["qpMinMoney"],
qpMaxMoney: json["qpMaxMoney"],
);
Map<String, dynamic> toJson() => {
"channelName": channelName,
"cid": cid,
"payType": payType,
"minMoney": minMoney,
"maxMoney": maxMoney,
"qpMinMoney": qpMinMoney,
"qpMaxMoney": qpMaxMoney,
};
}
@@ -0,0 +1,160 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'mine_withdrawal_record_page.dart';
import 'widget/record_list_item.dart';
import 'withdraw_details_model.dart';
abstract class MineWithdrawalRecordLogic extends GetxController {
RefreshController? refreshController;
int page = 1;
WithdrawDetailsModel? withdrawDetailsModel;
final dataSource = [];
bool isLoading = true;
@override
onReady() {
super.onReady();
fetchPageData();
}
@mustCallSuper
fetchPageData({bool isRefresh = true}) async {
if (isRefresh) page = 1;
}
Widget instanceChildItem(int index);
}
//提现明细
class WithDrawalRecordController extends MineWithdrawalRecordLogic {
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final result =
await MineService.getWithdrawDetails(pageNumber: page, pageSize: 10);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
result?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(result?.list ?? []);
page += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return WithdrawalRecordItem(dataSource[index]);
}
}
//金币订单明细
class GoldBillRecordController extends MineWithdrawalRecordLogic {
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final res = await MineService.getBillData(
pageSize: 15,
pageNumber: page,
type: 1,
);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return GoldRecordItem(dataSource[index]);
}
}
//充值明细
class RechargeRecordController extends MineWithdrawalRecordLogic {
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final res =
await MineService.getRechargeBill(pageNumber: page, pageSize: 15);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return Padding(
padding: EdgeInsets.only(bottom: 14),
child: RechargeRecordItem(dataSource[index]),
);
}
}
//收益明细
class IncomeRecordController extends MineWithdrawalRecordLogic {
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final res =
await MineService.getIncomeRecord(pageNumber: page, pageSize: 15);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return Padding(
padding: EdgeInsets.only(bottom: 12),
child: InComeRecordItem(dataSource[index]),
);
}
}
MineWithdrawalRecordLogic instanceController(RecordType type) {
switch (type) {
case RecordType.bill:
return GoldBillRecordController();
case RecordType.withdraw:
return WithDrawalRecordController();
case RecordType.recharge:
return RechargeRecordController();
case RecordType.income:
return IncomeRecordController();
default:
throw '$type 没有找到';
}
}
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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 'mine_withdrawal_record_logic.dart';
enum RecordType {
recharge('充值记录'),
withdraw('提现明细'),
bill('余额明细'),
income('业绩明细');
final String title;
const RecordType(this.title);
}
//明细综合页面
class RecordsPage extends StatefulWidget {
final RecordType type; // 0:收益, 1 提现
const RecordsPage(this.type, {super.key});
@override
State<RecordsPage> createState() => _RecordsPageState();
}
class _RecordsPageState extends State<RecordsPage> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return GetBuilder<MineWithdrawalRecordLogic>(
init: instanceController(widget.type),
builder: (controller) {
return Scaffold(
appBar: AppBar(title: Text(widget.type.title)),
body: pullYsRefresh(
onRefresh: (refreshController) => controller.fetchPageData(),
onLoading: (refreshController) =>
controller.fetchPageData(isRefresh: false),
onInit: (ctr) => controller.refreshController = ctr,
child: () {
if (controller.isLoading) return LoadingCenterWidget();
if (controller.dataSource.isEmpty)
return CErrorWidget(
retryOnTap: () => controller.fetchPageData());
return ListView.builder(
itemCount: controller.dataSource.length,
padding: EdgeInsets.only(top: 12, left: 16.w, right: 16.w),
itemBuilder: (context, index) =>
controller.instanceChildItem(index),
);
}(),
),
);
},
);
}
}
@@ -0,0 +1,364 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/mine/exchange/bill_item_model.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../withdraw_details_model.dart';
class GoldRecordItem extends StatelessWidget {
final BillItemModel model;
const GoldRecordItem(this.model, {super.key});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
12.sizeBoxH,
Text(
model.tranType ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400),
),
4.sizeBoxH,
Text(
model.desc ?? '',
style: TextStyle(
color: Colors.white.withValues(alpha: .5),
fontSize: 12,
fontWeight: FontWeight.w400),
),
4.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
model.createdAt.utcToYMDHMS(),
style: TextStyle(
color: Colors.white.withValues(alpha: .5),
fontSize: 12,
fontWeight: FontWeight.w400),
),
Text(
'${model.realCount}${model.unit}',
style: const TextStyle(
color: Color(0xffF68804),
fontSize: 14,
fontWeight: FontWeight.w400),
),
],
),
12.sizeBoxH,
1.line,
],
),
),
],
);
}
}
//充值记录
class RechargeRecordItem extends StatelessWidget {
final ListBean model;
const RechargeRecordItem(this.model, {super.key});
@override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${model.productName ?? ""}",
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
2.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () {
///复制到剪切板
Clipboard.setData(ClipboardData(text: model.orderId ?? ''));
showToast('复制成功');
},
child: Row(
children: [
Flexible(
child: Text(
'账单编号' + ': ${model.orderId}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w500),
),
),
2.sizeBoxW,
Image.asset(
'icon_copy.png'.mineImgPath,
width: 24,
),
],
),
),
2.sizeBoxH,
Text(
"状态: ${getStatus(model.status ?? 0)}",
style: TextStyle(
color: Color(0xffF68804),
fontSize: 12,
),
),
2.sizeBoxH,
Text(
model.createdAt?.utcToYMD() ?? "",
style: TextStyle(fontSize: 12, color: Color(0xff525252)),
),
12.sizeBoxH,
0.5.line,
],
),
);
}
///支付状态
String getStatus(int status) {
switch (status) {
case 1:
return "进行中";
case 2:
return "购买失败";
case 3:
return "购买成功";
}
return "未知";
}
Color getStatusColor(int status) {
var statusStr = Color(0xffffd382);
switch (status) {
case 2:
statusStr = Color(0xffFF1060);
break;
case 3:
statusStr = Color(0xff28C445);
break;
}
return statusStr;
}
}
class InComeRecordItem extends StatelessWidget {
final IncomeModel model;
const InComeRecordItem(this.model, {super.key});
@override
Widget build(BuildContext context) {
return Container(
child: Column(children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${model.tranType}",
style: const TextStyle(
color: Color(0xFFEFEFEF),
fontWeight: FontWeight.w500,
fontSize: 14.0),
),
Container(
margin: EdgeInsets.symmetric(vertical: 4),
child: Text(
model.desc ?? "",
style: TextStyle(fontSize: 12, color: Colors.white60),
),
),
Text(
DateTimeUtil.utc2iso(model.createdAt ?? ''),
style: const TextStyle(
color: Color(0xFF525252),
fontWeight: FontWeight.w400,
fontSize: 12.0),
),
12.sizeBoxH,
],
),
),
12.sizeBoxW,
Text(
model.tranTypeInt == 111
? "+${model.actualAmount}"
: "+${model.actualAmount}金币",
style: const TextStyle(
color: const Color(0xFFF68804),
fontWeight: FontWeight.w400,
fontSize: 12.0),
textAlign: TextAlign.left)
],
),
0.5.line,
]));
}
}
//提现明细cell
class WithdrawalRecordItem extends StatefulWidget {
final ListBean model;
const WithdrawalRecordItem(this.model, {super.key});
@override
State<WithdrawalRecordItem> createState() => _WithdrawalRecordItemState();
}
class _WithdrawalRecordItemState extends State<WithdrawalRecordItem> {
ListBean get model => widget.model;
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
12.sizeBoxH,
Text(
'${(model.money ?? 0) ~/ 100}',
style: TextStyle(
fontSize: 14,
color: AppColors.actionRed,
fontWeight: FontWeight.w500),
),
6.sizeBoxH,
Row(
children: [
Expanded(
child: Text(
'账单编号' + ': ${model.id}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w500),
),
),
InkWell(
enableFeedback: false,
onTap: () {
///复制到剪切板
Clipboard.setData(ClipboardData(text: model.id ?? ''));
showToast('复制成功');
},
child: Image.asset(
'mine_copy.png'.mineImgPath,
width: 24,
),
)
],
),
8.sizeBoxH,
Text(
'${getPayType(model.payType ?? "")}${getStatus(model.status ?? 0)}',
style: TextStyle(fontSize: 12, color: getStatusColor()),
),
6.sizeBoxH,
Row(
children: [
Text(
DateTimeUtil.utc2iso(model.createdAt ?? ''),
style: TextStyle(fontSize: 12, color: Color(0x8CFFFFFF)),
),
Spacer(),
if (model.status != 5 && model.status != 1) ...[
InkWell(
enableFeedback: false,
onTap: () => setState(() {
model.showReason = !model.showReason;
}),
child: Text(
'查看原因',
style: TextStyle(fontSize: 12, color: AppColors.actionRed),
),
)
],
],
),
if (model.showReason) ...[
12.sizeBoxH,
Text(
'${model.statusDesc}',
style: TextStyle(fontSize: 12, color: Colors.white),
),
],
12.sizeBoxH,
0.5.line,
],
),
);
}
String getPayType(String payType) {
if (payType.endsWith("alipay")) {
return "支付宝";
} else if (payType.endsWith("usdt")) {
return "USDT";
} else {
return "银行卡";
}
}
///支付状态
String getStatus(int status) {
switch (status) {
case 1:
return '提现审核中';
case 2:
return '审核通过,转账中';
case 3:
return '提现已拒绝';
case 4:
return '未知错误';
case 5:
return '提现成功';
case 6:
return '提现失败';
}
return '';
}
///支付状态
Color getStatusColor() {
if (model.status == 5) {
return Color(0xff0360FC);
} else {
return Color(0x8CFFFFFF);
}
}
}
@@ -0,0 +1,369 @@
/// hasNext : true
class WithdrawDetailsModel {
bool? hasNext;
List<ListBean>? list;
List<ResultBean>? result;
int? total;
static WithdrawDetailsModel? fromJson(Map<String, dynamic> map) {
WithdrawDetailsModel withdrawDetailsModel = WithdrawDetailsModel();
withdrawDetailsModel.hasNext = map['hasNext'];
withdrawDetailsModel.list = []..addAll((map['list'] as List? ?? []).map((o) => ListBean.fromMap(o)));
withdrawDetailsModel.result = []..addAll((map['result'] as List? ?? []).map((o) => ResultBean.fromMap(o)));
withdrawDetailsModel.total = map['total'];
return withdrawDetailsModel;
}
Map toJson() => {
"hasNext": hasNext,
"list": list,
"result": result,
"total": total,
};
}
class ResultBean {
int? uid;
int? amount;
int? money;
int? payMoney;
int? withdrawType;
int? status;
String? id;
String? name;
String? oid;
String? payType;
String? actName;
String? act;
String? userIp;
String? deviceType;
String? devID;
String? statusDesc;
String? checkedAt;
String? progressAt;
String? failureAt;
String? successAt;
String? updatedAt;
String? createdAt;
String? receivedAt;
static ResultBean fromMap(Map<String, dynamic> map) {
ResultBean info = ResultBean();
info.id = map['id'];
info.uid = map['uid'];
info.name = map['name'];
info.amount = map['amount'];
info.oid = map['oid'];
info.money = map['money'];
info.payMoney = map['payMoney'];
info.payType = map['payType'];
info.withdrawType = map['withdrawType'];
info.actName = map['actName'];
info.act = map['act'];
info.userIp = map['userIp'];
info.deviceType = map['deviceType'];
info.devID = map['devID'];
info.status = map['status'];
info.statusDesc = map['statusDesc'];
info.checkedAt = map['checkedAt'];
info.progressAt = map['progressAt'];
info.failureAt = map['failureAt'];
info.successAt = map['successAt'];
info.updatedAt = map['updatedAt'];
info.createdAt = map['createdAt'];
info.receivedAt = map['receivedAt'];
return info;
}
Map toJson() => {
"id": id,
"uid": uid,
"name": name,
"amount": amount,
"oid": oid,
"money": money,
"payMoney": payMoney,
"payType": payType,
"withdrawType": withdrawType,
"actName": actName,
"act": act,
"userIp": userIp,
"deviceType": deviceType,
"devID": devID,
"status": status,
"statusDesc": statusDesc,
"checkedAt": checkedAt,
"progressAt": progressAt,
"failureAt": failureAt,
"successAt": successAt,
"updatedAt": updatedAt,
"createdAt": createdAt,
"receivedAt": receivedAt,
};
}
class ListBean {
int? money;
int? payMoney;
int? uid;
int? amount;
int? withdrawType;
int? status;
String? id;
String? orderId;
String? name;
String? oid;
String? payType;
String? actName;
String? act;
String? userIp;
String? deviceType;
String? devID;
String? statusDesc;
String? checkedAt;
String? progressAt;
String? failureAt;
String? successAt;
String? updatedAt;
String? createdAt;
String? desc;
String? receivedAt;
String? productName;
double? actualAmount;
bool showReason = false; //本地添加字段,是否展示原因
static ListBean fromMap(Map<String, dynamic> map) {
ListBean info = ListBean();
info.id = map['id'];
info.orderId = map['orderId'];
info.uid = map['uid'];
info.name = map['name'];
info.amount = map['amount'];
info.oid = map['oid'];
info.money = map['money'];
info.payMoney = map['payMoney'];
info.payType = map['payType'];
info.withdrawType = map['withdrawType'];
info.actName = map['actName'];
info.act = map['act'];
info.userIp = map['userIp'];
info.deviceType = map['deviceType'];
info.devID = map['devID'];
info.status = map['status'];
info.statusDesc = map['statusDesc'];
info.checkedAt = map['checkedAt'];
info.progressAt = map['progressAt'];
info.failureAt = map['failureAt'];
info.successAt = map['successAt'];
info.updatedAt = map['updatedAt'];
info.createdAt = map['createdAt'];
info.receivedAt = map['receivedAt'];
info.desc = map['desc'];
info.actualAmount = map['actualAmount']?.toDouble() ?? .0;
info.productName = map['productName'];
return info;
}
Map toJson() => {
"id": id,
"uid": uid,
"name": name,
"amount": amount,
"oid": oid,
"money": money,
"payMoney": payMoney,
"payType": payType,
"withdrawType": withdrawType,
"actName": actName,
"act": act,
"userIp": userIp,
"deviceType": deviceType,
"devID": devID,
"status": status,
"statusDesc": statusDesc,
"checkedAt": checkedAt,
"progressAt": progressAt,
"failureAt": failureAt,
"successAt": successAt,
"updatedAt": updatedAt,
"createdAt": createdAt,
"receivedAt": receivedAt,
"desc": desc,
"actualAmount": actualAmount,
};
}
class IncomeModel {
String? id;
int? uid;
String? purchaseOrder;
String? productID;
num? amount;
num? integral;
String? realIntegral;
num? actualIntegral;
num? actualAmount;
num? tax;
num? taxAmount;
String? channelType;
String? tranType;
num? tranTypeInt;
num? performance;
num? rechargeId;
RechargeUser? rechargeUser;
String? desc;
String? createdAt;
String? sysType;
num? agentLevel;
num? vipLevel;
String? realAmount;
String? money;
String? wlRealAmount;
num? fruitCoin;
num? downloadCount;
num? fruitCoinBalance;
num? aiMateBalance;
String? districtCode;
String? promSeqe;
bool? isDirect;
String? discBindAt;
IncomeModel(
{this.id,
this.uid,
this.purchaseOrder,
this.productID,
this.amount,
this.integral,
this.realIntegral,
this.actualIntegral,
this.actualAmount,
this.tax,
this.taxAmount,
this.channelType,
this.tranType,
this.tranTypeInt,
this.performance,
this.rechargeId,
this.rechargeUser,
this.desc,
this.createdAt,
this.sysType,
this.agentLevel,
this.vipLevel,
this.realAmount,
this.money,
this.wlRealAmount,
this.fruitCoin,
this.downloadCount,
this.fruitCoinBalance,
this.aiMateBalance,
this.districtCode,
this.promSeqe,
this.isDirect,
this.discBindAt});
handelMoney() {
double price = (actualAmount ?? 0) / 10;
return price.toStringAsFixed(2);
}
IncomeModel.fromJson(Map<String, dynamic> json) {
id = json['id'];
uid = json['uid'];
purchaseOrder = json['purchaseOrder'];
productID = json['productID'];
amount = json['amount'];
integral = json['integral'];
realIntegral = json['realIntegral'];
actualIntegral = json['actualIntegral'];
actualAmount = json['actualAmount'];
tax = json['tax'];
taxAmount = json['taxAmount'];
channelType = json['channelType'];
tranType = json['tranType'];
tranTypeInt = json['tranTypeInt'];
performance = json['performance'];
rechargeId = json['rechargeId'];
rechargeUser = json['rechargeUser'] != null ? new RechargeUser.fromJson(json['rechargeUser']) : null;
desc = json['desc'];
createdAt = json['createdAt'];
sysType = json['sysType'];
agentLevel = json['agentLevel'];
vipLevel = json['vipLevel'];
realAmount = json['realAmount'];
money = json['money'];
wlRealAmount = json['wlRealAmount'];
fruitCoin = json['fruitCoin'];
downloadCount = json['downloadCount'];
fruitCoinBalance = json['fruitCoinBalance'];
aiMateBalance = json['aiMateBalance'];
districtCode = json['districtCode'];
promSeqe = json['promSeqe'];
isDirect = json['isDirect'];
discBindAt = json['DiscBindAt'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['id'] = this.id;
data['uid'] = this.uid;
data['purchaseOrder'] = this.purchaseOrder;
data['productID'] = this.productID;
data['amount'] = this.amount;
data['integral'] = this.integral;
data['realIntegral'] = this.realIntegral;
data['actualIntegral'] = this.actualIntegral;
data['actualAmount'] = this.actualAmount;
data['tax'] = this.tax;
data['taxAmount'] = this.taxAmount;
data['channelType'] = this.channelType;
data['tranType'] = this.tranType;
data['tranTypeInt'] = this.tranTypeInt;
data['performance'] = this.performance;
data['rechargeId'] = this.rechargeId;
if (this.rechargeUser != null) {
data['rechargeUser'] = this.rechargeUser!.toJson();
}
data['desc'] = this.desc;
data['createdAt'] = this.createdAt;
data['sysType'] = this.sysType;
data['agentLevel'] = this.agentLevel;
data['vipLevel'] = this.vipLevel;
data['realAmount'] = this.realAmount;
data['money'] = this.money;
data['wlRealAmount'] = this.wlRealAmount;
data['fruitCoin'] = this.fruitCoin;
data['downloadCount'] = this.downloadCount;
data['fruitCoinBalance'] = this.fruitCoinBalance;
data['aiMateBalance'] = this.aiMateBalance;
data['districtCode'] = this.districtCode;
data['promSeqe'] = this.promSeqe;
data['isDirect'] = this.isDirect;
data['DiscBindAt'] = this.discBindAt;
return data;
}
}
class RechargeUser {
int? uid;
String? name;
String? portrait;
RechargeUser({this.uid, this.name, this.portrait});
RechargeUser.fromJson(Map<String, dynamic> json) {
uid = json['uid'];
name = json['name'];
portrait = json['portrait'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['uid'] = this.uid;
data['name'] = this.name;
data['portrait'] = this.portrait;
return data;
}
}
@@ -0,0 +1,239 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/user/wallet_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import '../../../alert/mine/vip_level_dialog.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../track_event_manager/device_service.dart';
import '../mine_profit/bank_card_home_page.dart';
import '../mine_profit/model/alipay_bank_list_model.dart';
import 'in_come_entity.dart';
class WithdrawalLogic extends GetxController with GetTickerProviderStateMixin {
WithdrawalLogic get to => Get.find<WithdrawalLogic>();
TextEditingController? moneyController;
TextEditingController? accountController;
FocusNode focusNode = FocusNode();
bool isShowLoading = false;
WalletModel? userIncomeModel;
WithdrawConfig? configData;
TextEditingController? nameController;
bool isLoading = true;
int withdrawType = 1; // 0支付宝 1银行卡
Channel? selectChannel;
num handlingFee = 0; //手续费
num actualAmount = 0; //实际到账金额
AccountInfoModel? bankModel;
int get minBankCardMoney {
return (configData?.getBankCardChannel?.minMoney ?? 0) ~/ 100;
}
int get minUsdtMoney {
return (configData?.getUsdtChannel?.minMoney ?? 0) ~/ 100;
}
@override
void onInit() {
super.onInit();
moneyController = TextEditingController();
nameController = TextEditingController();
accountController = TextEditingController();
}
@override
void onReady() {
super.onReady();
loadData(showLoading: true);
globalStore.refreshWallet();
}
void changeWithdrawType(String payType) {
// withdrawType = value;
selectChannel = configData?.channels
?.firstWhere((element) => element.payType == payType);
withdrawType = configData?.channels
?.indexWhere((e) => e.payType == selectChannel?.payType) ??
0;
update();
}
///计算提现手续费、实际到账金额
void calcWithdrawAmount(String withdrawAmount) {
if (withdrawAmount.isEmpty) {
handlingFee = 0;
actualAmount = 0;
} else {
num withdrawAmoutNum = num.parse(withdrawAmount);
int coinTax = configData?.coinTax ?? 0;
if (coinTax == 0) {
handlingFee = 0;
actualAmount = withdrawAmoutNum;
} else {
double value = coinTax / 100.0;
final res = (withdrawAmoutNum * double.parse(value.toStringAsFixed(2)))
.toStringAsFixed(2);
handlingFee = double.parse(res).floor();
actualAmount = withdrawAmoutNum - handlingFee;
}
}
update(['money']);
}
@override
void dispose() {
super.dispose();
nameController?.dispose();
moneyController?.dispose();
accountController?.dispose();
focusNode.dispose();
}
Future<void> loadData({
bool isRefresh = true,
bool showLoading = false,
}) async {
//获取支付宝或者银行卡的费率
_withdrawConfigReq();
}
///提现配置请求
void _withdrawConfigReq() async {
final configData = await MineService.withdrawConfig();
this.configData = configData;
isLoading = false;
// 优先银行卡,其次 usdt
selectChannel = configData?.channels
?.firstWhereOrNull((element) => element.payType == 'bankcard') ??
configData?.channels
?.firstWhereOrNull((element) => element.payType == 'usdt');
if (selectChannel == null) {
showToast('暂无可用提现通道');
this.configData = null;
} else {
// 必须同步 withdrawType,页面按 channels[withdrawType] 取值,否则会索引错位甚至越界
withdrawType = configData?.channels
?.indexWhere((e) => e.payType == selectChannel?.payType) ??
0;
}
update();
}
///提交提现
void submitWithdraw() async {
try {
if (!globalStore.isRechargeVIP) {
showVipLevelDialog("您还不是VIP,无法使用提现功能");
return;
}
if ("bankcard" == selectChannel?.payType) {
//检验银行卡信息
_commonWithdrawReq(true);
} else if ("alipay" == selectChannel?.payType ||
"usdt" == selectChannel?.payType) {
_commonWithdrawReq(false);
}
} catch (e) {
showToast("提现错误:$e");
}
}
///公用提现方法
void _commonWithdrawReq(bool isbankType) async {
Channel channel = configData!.channels![withdrawType];
String money = moneyController?.text.trim() ?? "";
String accountDesc = accountController?.text.trim() ?? "";
if (isbankType) {
accountDesc = bankModel?.act ?? "";
}
if (money.isEmpty) {
showToast("提现金额不能为空");
return;
}
if (isbankType && bankModel == null) {
showToast("请选择提现银行卡号");
return;
}
if (!isbankType && accountDesc.isEmpty) {
showToast("请输入钱包地址");
return;
}
double incomeMoneyYuan = (userIncomeModel?.balance ?? 0) / 10;
int withdrawMoneyYuan = int.parse(money);
if (withdrawMoneyYuan > incomeMoneyYuan) {
showToast("提现金额不能大于余额");
return;
}
int minMoneyFen = configData?.channels![withdrawType].minMoney ?? 0;
double minMoneyYuan = minMoneyFen / 100;
if (withdrawMoneyYuan < minMoneyYuan) {
showToast("单笔提现金额不小于$minMoneyYuan元");
return;
}
int maxMoneyFen = configData?.channels![withdrawType].maxMoney ?? 0;
double maxMoneyYuan = maxMoneyFen / 100;
if (withdrawMoneyYuan > maxMoneyYuan) {
showToast("单笔提现金额不大于$maxMoneyYuan元");
return;
}
LoadingHelper.showLoading();
String deviceId = DeviceInfoService.deviceId;
String payType = channel.payType ?? "";
//payType 提现方式,alipaybankcardusdt
//money 提现金额
//name 用户名
//withdrawType 提现类型,0,代理提现; 1,金币提现
//actName 交易账户持有人
//act 交易账户
//devID 设备id
//productType 产品类型 0站群 1棋牌
var result = await MineService.withdraw(
payType,
accountDesc,
withdrawMoneyYuan * 100,
globalStore.meInfo?.name ?? "",
bankModel?.actName,
deviceId,
bankModel?.bankCode,
1,
0,
);
LoadingHelper.dismissLoading();
if (result == true) {
globalStore.refreshWallet(refresh: true);
showToast("提现提交成功~");
clearInputData();
} else {
showToast("提现失败~");
}
}
void gotoBankList() async {
var ret = await Get.to(
BankCardHomePage(selectModel: bankModel),
preventDuplicates: false,
);
if (ret is AccountInfoModel) {
bankModel = ret;
update();
}
}
clearInputData() {
nameController?.clear();
accountController?.clear();
moneyController?.clear();
}
}
@@ -0,0 +1,516 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/mine/widgets/gradient_text.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:provider/provider.dart';
import '../../../assets_tool/app_colors.dart';
import '../../../hj_utils/widget_util.dart';
import '../../../routers/jump_router.dart';
import 'in_come_entity.dart';
import 'mine_withdrawal_record_page.dart';
import 'withdrawal_logic.dart';
//我要提现页面
class WithdrawalPage extends StatelessWidget {
const WithdrawalPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<WithdrawalLogic>(
init: WithdrawalLogic(),
builder: (logic) {
return Scaffold(
appBar: AppBar(
title: Text(
'我要提现',
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
actions: <Widget>[
GestureDetector(
child: Text(
'明细',
style: TextStyle(color: Color(0x73FFFFFF), fontSize: 12),
),
onTap: () => Get.to(RecordsPage(RecordType.withdraw)),
),
16.w.sizeBoxW,
],
),
body: () {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.configData == null) return CErrorWidget();
const measureStyle = TextStyle(fontSize: 14);
var textPainter = TextPainter(
text: TextSpan(
text: '',
style: measureStyle,
),
textDirection: TextDirection.ltr,
textWidthBasis: TextWidthBasis.longestLine,
)..layout();
return Column(
children: [
Expanded(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Container(
margin: EdgeInsets.only(left: 16.w, right: 16.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
margin:
EdgeInsets.only(top: 12.w, bottom: 18.w),
padding: EdgeInsets.symmetric(vertical: 12.w),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Row(
children: [
Container(
height: 22,
width: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topRight: Radius.circular(11),
bottomRight: Radius.circular(11),
),
color: AppColors.actionRed,
),
),
12.sizeBoxW,
Text(
"余额(元)",
style: const TextStyle(
color: AppColors.actionRed,
fontWeight: FontWeight.w400,
fontSize: 14.0,
),
),
],
),
12.sizeBoxH,
Padding(
padding: EdgeInsets.only(left: 20),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Image.asset(
'coin_icon.webp'.mineImgPath,
width: 30),
4.sizeBoxW,
Consumer<GlobalStore>(
builder: (_, store, __) {
logic.userIncomeModel =
store.wallet;
return GradientText(
((store.wallet?.balance ?? 0) *
10 /
100)
.toStringAsFixed(2),
gradient: LinearGradient(
colors: [
Color(0xffFFE8BE),
Color(0xffE6B764)
],
tileMode: TileMode.mirror,
),
style: TextStyle(
fontSize: 31.5,
fontWeight: FontWeight
.w500), //目前fontSize设置为31.5 设置为32,显示不完全,存在系统bug
);
}),
],
),
),
],
)),
Row(
children: [
Text(
'提现币类:',
style: textStyle(
14, Color(0xE5FFFFFF), FontWeight.w500),
),
15.sizeBoxW,
Text(
'人民币',
style: textStyle(
14, Color(0x8CFFFFFF), FontWeight.w400),
),
],
),
16.sizeBoxH,
Row(
children: [
Text(
"提现金额:",
style: textStyle(
14, Color(0xE5FFFFFF), FontWeight.w500),
),
10.sizeBoxW,
Expanded(
child: Container(
alignment: Alignment.centerLeft,
height: 40,
padding:
EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color:
Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(6),
),
child: TextField(
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp("[0-9]")),
LengthLimitingTextInputFormatter(9),
],
controller: logic.moneyController,
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400),
cursorColor:
Colors.white.withValues(alpha: 0.7),
decoration: InputDecoration(
border: InputBorder.none,
contentPadding:
EdgeInsets.only(bottom: 8),
hintStyle: TextStyle(
color: Color(0xFF525252),
fontSize: 12,
fontWeight: FontWeight.w400),
hintText:
'单笔提现金额范围 ${_getPayMoneyRange(logic)}', //"您目前
),
focusNode: logic.focusNode,
onChanged: (value) =>
logic.calcWithdrawAmount(value)),
),
)
],
),
16.sizeBoxH,
Row(
children: [
Text(
"提现方式:",
style: textStyle(
14, Color(0xE5FFFFFF), FontWeight.w500),
),
16.sizeBoxW,
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
height: 30,
child: Row(
children: logic.configData?.realChannel
.asMap()
.map((index, e) => MapEntry(
index,
_buildPayTypeUI(
e, logic, index)))
.values
.toList() ??
[],
),
),
),
),
],
),
16.sizeBoxH,
Row(
children: [
if (_isUsdt(logic)) ...[
Text(
'USDT地址:',
style: textStyle(
14, Color(0xE5FFFFFF), FontWeight.w500),
),
10.sizeBoxW,
Expanded(
child: Container(
decoration: BoxDecoration(
color:
Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(6),
),
child: TextField(
textAlignVertical:
TextAlignVertical.center,
controller: logic.accountController,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
hintText: _getHintText(logic),
hintStyle: TextStyle(
color: Color(0xff999999),
fontSize: 14,
fontWeight: FontWeight.w400),
isCollapsed: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical:
(32 - textPainter.height) / 2,
),
),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
),
)
] else ...[
Text(
'银行卡号:',
style: textStyle(
14, Color(0xE5FFFFFF), FontWeight.w500),
),
10.sizeBoxW,
Expanded(
child: InkWell(
enableFeedback: false,
onTap: () => logic.gotoBankList(),
child: Container(
alignment: Alignment.centerLeft,
height: 37,
padding: EdgeInsets.only(left: 10),
decoration: BoxDecoration(
color:
Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(6),
),
child: Row(
children: [
Expanded(
child: Text(
logic.bankModel == null
? "请选择提现银行账号"
: "${logic.bankModel?.getBankName()}(${logic.bankModel?.act?.substring((logic.bankModel?.act?.length ?? 4) - 4)}) ${logic.bankModel?.actName}",
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
color: logic.bankModel == null
? Color(0xff525252)
: Colors.white,
),
),
),
6.sizeBoxW,
Icon(Icons.keyboard_arrow_right,
color: Colors.white, size: 16),
10.sizeBoxW,
],
),
),
)),
]
],
),
16.sizeBoxH,
GetBuilder<WithdrawalLogic>(
init: logic,
id: 'money',
builder: (controller) => EasyRichText(
'手续费率: ${logic.configData?.coinTax ?? 0}% 实际到账金额: ${logic.actualAmount}',
defaultStyle: textStyle(
14, Color(0xE5FFFFFF), FontWeight.w500),
patternList: [
EasyRichTextPattern(
targetString:
'${logic.configData?.coinTax ?? 0}%',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0x8CFFFFFF),
),
),
EasyRichTextPattern(
targetString:
'实际到账金额: ${logic.handlingFee}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0x8CFFFFFF),
),
),
EasyRichTextPattern(
targetString: '${logic.actualAmount}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0x8CFFFFFF),
),
),
],
),
),
18.sizeBoxH,
Text(
"提现规则:",
style: const TextStyle(
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w500,
fontSize: 16.0),
),
12.sizeBoxH,
Text(
"1、每次提现金额最低${(logic.configData?.channels == null ? 0 : logic.selectChannel?.minMoney ?? 0) ~/ 100}元起,"
"单笔提现最大${(logic.configData?.channels == null ? 0 : logic.selectChannel?.maxMoney ?? 0) ~/ 100}元,且为整数。\n"
"2、每次提现收取${logic.configData?.coinTax}%手续费。\n"
"3、仅支持银行卡提现,收款账户卡号与姓名一致,到账时间未72小时内。\n"
"4、申请提现后请随时关注收款账户进款通知,长时间未到账,请及时联系客服。\n",
style: const TextStyle(
color: Color(0x8CFFFFFF),
fontWeight: FontWeight.w400,
fontSize: 12.0,
height: 2),
),
30.sizeBoxH,
],
),
),
),
),
Column(
children: [
GestureDetector(
onTap: () => logic.submitWithdraw(),
child: Container(
height: 47,
alignment: Alignment.center,
margin: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: Text(
'立即提现',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
)),
),
10.sizeBoxH,
EasyRichText(
'提现中如有问题,请联系 在线客服',
defaultStyle:
textStyle(12, Colors.white, FontWeight.w400),
patternList: [
EasyRichTextPattern(
targetString: '在线客服',
style: TextStyle(color: Color(0xffFFD460)),
recognizer: TapGestureRecognizer()
..onTap = pushToCustomService,
),
],
),
34.sizeBoxH,
],
)
],
);
}());
},
);
}
///支付类型
Widget _buildPayTypeUI(Channel channel, WithdrawalLogic logic, int index) {
bool isSelected = logic.selectChannel?.payType == channel.payType;
return InkWell(
enableFeedback: false,
onTap: () => logic.changeWithdrawType(channel.payType ?? ''),
child: Row(
children: [
Image.asset(
channel.payType == 'usdt'
? 'ic_usdt.png'.mineImgPath
: 'ic_union.png'.mineImgPath,
width: 30),
4.sizeBoxW,
Text(_getPayTypeName(channel.payType ?? ""),
style: TextStyle(color: Colors.white, fontSize: 14)),
10.sizeBoxW,
Image.asset(
isSelected
? 'radio_sel.png'.commonImgPath
: 'mine_withdraw_nor.png'.mineImgPath,
width: 24,
),
24.sizeBoxW,
],
),
);
}
///获取支付方式名称
String _getPayTypeName(String payType) {
if ("alipay" == payType) {
return "支付宝";
} else if ("bankcard" == payType) {
return "银行卡";
} else if ("usdt" == payType) {
return "USDT";
}
return "银行卡";
}
///获取提现金额范围
String _getPayMoneyRange(WithdrawalLogic logic) {
final channel = _currentChannel(logic);
if (channel == null) return "0";
return "${(channel.minMoney ?? 0) / 100}-${(channel.maxMoney ?? 0) / 100}";
}
String _getHintText(WithdrawalLogic logic) {
final channel = _currentChannel(logic);
switch (channel?.payType ?? "") {
case 'alipay':
return '请输入支付宝账号';
case 'bankcard':
return '请输入银行卡号';
case 'usdt':
return '请输入USDT地址';
default:
return "";
}
}
bool _isUsdt(WithdrawalLogic logic) =>
(_currentChannel(logic)?.payType ?? "") == 'usdt';
// 按 withdrawType 取当前通道,带越界保护(配置异常时不崩溃)
Channel? _currentChannel(WithdrawalLogic logic) {
final channels = logic.configData?.channels ?? [];
final index = logic.withdrawType;
if (index < 0 || index >= channels.length) return null;
return channels[index];
}
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../hj_utils/api_service/mine_service.dart';
class MessageCenterLogic extends GetxController with GetTickerProviderStateMixin {
MessageCenterLogic get to => Get.find<MessageCenterLogic>();
late TabController tabController = TabController(length: titleArr.length, vsync: this);
List<String> titleArr = ['评论', '点赞'];
void getUnReadNum() async {
await MineService.getUnreadNum();
update();
}
}
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'message_center_logic.dart';
import 'message_center_sub_page.dart';
class MessageCenterPage extends StatelessWidget {
const MessageCenterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
'消息中心',
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 18,
fontWeight: FontWeight.w600,
),
)),
body: GetBuilder<MessageCenterLogic>(
init: MessageCenterLogic(),
builder: (logic) {
return Column(
children: [
TabBar(
isScrollable: true,
labelStyle: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
),
unselectedLabelStyle: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: .45),
),
indicator: BoxDecoration(),
tabAlignment: TabAlignment.start,
controller: logic.tabController,
labelPadding: EdgeInsets.symmetric(horizontal: 0),
tabs: logic.titleArr
.map((it) => Container(
padding: EdgeInsets.symmetric(horizontal: 12),
margin: EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: it == logic.titleArr.first
? Colors.transparent
: Color(0x1AFFFFFF),
width: 1)),
),
child: Text(it),
))
.toList(),
),
Expanded(
child: TabBarView(
controller: logic.tabController,
children: logic.titleArr
.asMap()
.map((key, value) => MapEntry(
key,
MessageCenterSubPage(MessageType.values[key])
.keepAlive))
.values
.toList()),
),
],
);
},
),
);
}
}
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'message_center_sub_page.dart';
import 'widget/message_items.dart';
abstract class MessageCenterSubLogic extends GetxController {
final dataSource = [];
int page = 1;
bool isLoading = true;
RefreshController? refreshController;
@override
void onReady() {
super.onReady();
fetchPageData();
}
@mustCallSuper
fetchPageData({bool isRefresh = true, bool showLoading = false}) {
if (isRefresh) {
page = 1;
}
if (showLoading) {
isLoading = true;
update();
}
}
Widget instanceChildItem(int index);
}
//评论
class MessageCommentLogic extends MessageCenterSubLogic {
@override
Widget instanceChildItem(int index) {
return MessageCommentItem(dataSource[index]);
}
@override
fetchPageData({bool isRefresh = true, bool showLoading = false}) async {
super.fetchPageData(isRefresh: isRefresh, showLoading: showLoading);
final res = await MineService.getDynamicList(pageNumber: page, msgType: 2);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
update();
}
}
//点赞
class MessageThumbUpLogic extends MessageCenterSubLogic {
@override
fetchPageData({bool isRefresh = true, bool showLoading = false}) async {
super.fetchPageData(isRefresh: isRefresh, showLoading: showLoading);
final res = await MineService.getDynamicList(pageNumber: page, msgType: 1);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return MessageThumbItem(dataSource[index]);
}
}
MessageCenterSubLogic instanceController(MessageType type) {
switch (type) {
case MessageType.comment:
return MessageCommentLogic();
case MessageType.thumb:
return MessageThumbUpLogic();
default:
throw '未知的类型${type}';
}
}
@@ -0,0 +1,73 @@
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 '../../../main.dart';
import 'message_center_sub_controller.dart';
enum MessageType {
comment(1), //评论
thumb(2); //点赞
final int type;
const MessageType(this.type);
}
class MessageCenterSubPage extends StatefulWidget {
final MessageType type;
const MessageCenterSubPage(this.type, {super.key});
@override
State<MessageCenterSubPage> createState() => _MessageCenterSubPageState();
}
class _MessageCenterSubPageState extends State<MessageCenterSubPage>
with RouteAware {
late MessageCenterSubLogic logic;
@override
void initState() {
super.initState();
logic = instanceController(widget.type);
}
void didChangeDependencies() {
super.didChangeDependencies();
routeObserver.subscribe(this, ModalRoute.of(context)!);
}
@override
void didPopNext() {
super.didPopNext();
}
@override
dispose() {
super.dispose();
routeObserver.unsubscribe(this);
}
@override
Widget build(BuildContext context) {
return GetBuilder<MessageCenterSubLogic>(
init: logic,
global: false,
builder: (controller) => pullYsRefresh(
onLoading: (_) => controller.fetchPageData(isRefresh: false),
onRefresh: (_) => controller.fetchPageData(),
onInit: (_) => controller.refreshController = _,
child: () {
if (controller.isLoading) return LoadingCenterWidget();
if (controller.dataSource.isEmpty) return CErrorWidget();
return ListView.builder(
padding: EdgeInsets.only(left: 16, right: 16, top: 12),
itemCount: controller.dataSource.length,
itemBuilder: (BuildContext context, int index) {
return controller.instanceChildItem(index);
},
);
}()),
);
}
}
@@ -0,0 +1,35 @@
class UnReadMsgNumModel {
UnReadMsgNumModel({
this.dtCount,
this.incomeCount,
this.msgCount,
});
UnReadMsgNumModel.fromJson(dynamic json) {
dtCount = json['dtCount'];
incomeCount = json['incomeCount'];
msgCount = json['msgCount'];
}
num? dtCount;
num? incomeCount;
num? msgCount;
UnReadMsgNumModel copyWith({
num? dtCount,
num? incomeCount,
num? msgCount,
}) =>
UnReadMsgNumModel(
dtCount: dtCount ?? this.dtCount,
incomeCount: incomeCount ?? this.incomeCount,
msgCount: msgCount ?? this.msgCount,
);
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
map['dtCount'] = dtCount;
map['incomeCount'] = incomeCount;
map['msgCount'] = msgCount;
return map;
}
num get allNum => (dtCount ?? 0) + (incomeCount ?? 0) + (msgCount ?? 0);
}
@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/message/message_dynamic_list.dart';
import 'package:hgdj/hj_page/user_center_page/user_center_page.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../community/post_detail_page/post_detail_page.dart';
/// 点赞
class MessageThumbItem extends StatelessWidget {
final MessageDynamicList model;
const MessageThumbItem(this.model, {super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => Get.to(() => PostDetailPage(
argument: PostDetailPageArgument(id: model.objId, models: []))),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
margin: EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
"#$lTypeText",
maxLines: 1,
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
SizedBox(width: 12),
Text(
model.createdAt.utcToYMD(),
style: TextStyle(
color: Color(0xE5FFFFFF),
fontSize: 14,
),
),
],
),
7.sizeBoxH,
Text(
'${model.objName}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0x8CFFFFFF),
fontSize: 14,
),
),
6.sizeBoxH,
Row(
children: [
Text(
"${model.likeCount}人为你点赞",
style: TextStyle(
color: Color(0x59FFFFFF),
fontSize: 14,
),
),
],
),
],
),
),
);
}
String get lTypeText {
// video:对帖子点赞 comment:对评论点赞
switch (model.msgType) {
case 'like_msg':
return '帖子点赞';
case 'like_comment_msg':
return '评论点赞';
default:
return '';
}
}
}
class MessageCommentItem extends StatelessWidget {
final MessageDynamicList model;
const MessageCommentItem(this.model, {super.key});
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: () => Get.to(() => PostDetailPage(
argument: PostDetailPageArgument(id: model.objId, models: []))),
child: Container(
padding: EdgeInsets.symmetric(vertical: 18, horizontal: 12),
margin: EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(() => UserCenterPage(uid: model.sendUid ?? 0)),
child: NetworkImageLoader(
imageUrl: model.sendAvatar ?? '',
width: 40,
height: 40,
borderRadius: 20,
),
),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 40,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
model.sendName ?? '',
style: TextStyle(
color: Color(0xFFFFFFFF),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
Spacer(),
Text(
DateTimeUtil.utc2iso(model.createdAt),
style: TextStyle(
color: Color(0xFF989898),
fontSize: 12,
),
),
],
),
),
11.sizeBoxH,
Text(
model.content ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Color(0xFF989898),
fontSize: 12,
),
),
],
),
),
8.sizeBoxW,
Container(
alignment: Alignment.bottomCenter,
height: 82,
child: NetworkImageLoader(
imageUrl: model.objCover ?? '',
height: 60,
width: 60,
borderRadius: 6,
),
)
],
),
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/mine/official_list_item_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
class MineDriveLogic extends GetxController {
/// null 表示未加载(loading 态),非 null 表示已加载(可能为空)
List<OfficialListItemModel>? allList;
List<OfficialListItemModel> groupList = [];
List<OfficialListItemModel> officialList = [];
@override
void onReady() {
super.onReady();
loadData();
}
Future<void> loadData() async {
final list = await MineService.getOfficialLists() ?? [];
allList = list;
// position == 2 归商务合作,其余归官方社群
officialList = list.where((e) => e.position == 2).toList();
groupList = list.where((e) => e.position != 2).toList();
update();
}
}
@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/hj_utils/text_util.dart';
import '../../../hj_model/mine/official_list_item_model.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/loading/loading_center_widget.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'mine_drive_logic.dart';
class MineDrivePage extends StatelessWidget {
const MineDrivePage({super.key});
static const _titleStyle =
TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600);
static const _subtitleStyle =
TextStyle(color: Color(0xff525252), fontSize: 12);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('加群开车')),
body: GetBuilder<MineDriveLogic>(
init: MineDriveLogic(),
builder: (controller) {
if (controller.allList == null) return const LoadingCenterWidget();
if (controller.allList!.isEmpty) {
return CErrorWidget(retryOnTap: controller.loadData);
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: CustomScrollView(
slivers: [
if (controller.groupList.isNotEmpty)
_buildSection(
title: '官方社群',
subtitle: '一起看片一起分享心得',
list: controller.groupList),
if (controller.officialList.isNotEmpty)
_buildSection(
title: '商务合作',
subtitle: '代理合作/商务合作',
list: controller.officialList),
],
),
);
},
),
);
}
/// 一个分组区块:标题 + 副标题 + 列表卡片
Widget _buildSection({
required String title,
required String subtitle,
required List<OfficialListItemModel> list,
}) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
18.sizeBoxH,
Text(title, style: _titleStyle),
6.sizeBoxH,
Text(subtitle, style: _subtitleStyle),
12.sizeBoxH,
Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(9),
),
child: Column(children: list.map(_buildItem).toList()),
),
],
),
);
}
/// 单条社群/合作项
Widget _buildItem(OfficialListItemModel item) {
return InkWell(
enableFeedback: false,
onTap: () => launchUrlToWeb(item.officialUrl ?? ''),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5),
height: 80,
alignment: Alignment.center,
child: Row(
children: [
NetworkImageLoader(
imageUrl: item.officialImg ?? '', width: 40, height: 40),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
item.officialName ?? '',
style: const TextStyle(
color: Colors.white, fontSize: 14, height: 1.1),
),
if (TextUtil.isNotEmpty(item.officialDesc)) ...[
6.sizeBoxH,
Text(
item.officialDesc ?? '',
style: const TextStyle(
color: Color(0xff525252), fontSize: 12, height: 1.1),
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12),
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'立即加入',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
],
),
),
);
}
}
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/swipe_action_item.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_model/mine/follow_user_list_model.dart';
import 'mine_follow_sub_page.dart';
import 'widget/follow_list_items.dart';
abstract class MineFollowSubLogic extends GetxController
with GetTickerProviderStateMixin {
final int? uid;
MineFollowSubLogic(this.uid);
RxInt loadCount = 0.obs;
final dataSource = [];
RefreshController? refreshController;
int pageNumber = 1;
bool isLoading = true;
bool isGridStyle = false;
@override
void onReady() {
super.onReady();
fetchPageData();
}
@mustCallSuper
fetchPageData({bool isRefresh = true}) {
if (isRefresh) {
pageNumber = 1;
}
}
Widget instanceChildItem(int index);
}
class ActressController extends MineFollowSubLogic {
ActressController(super.uid);
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final retModel = await MineService.fetchCollectList<FollowUserModel>(
'actress',
page: pageNumber,
size: 10,
uid: uid,
);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
retModel?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(retModel?.list ?? []);
pageNumber += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return FollowActressItem(dataSource[index]);
}
}
class BloggerController extends MineFollowSubLogic {
BloggerController(super.uid);
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final retModel = await CommonService.getFollowUsers(
pageNumber: pageNumber,
pageSize: 10,
uid: uid,
);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
retModel?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(retModel?.list ?? []);
pageNumber += 1;
update();
}
@override
Widget instanceChildItem(int index) {
final model = dataSource[index] as FollowUserModel;
// 用 ObjectKey 而不是 ValueKey(uid)uid 是可空的,两条 uid 都为 null 就是重复 key
// Flutter 会直接抛 Duplicate keys 白屏。绑对象身份天然唯一,且能让展开状态不串项
return SwipeActionItem(
key: ObjectKey(model),
actionText: '取消关注',
onAction: () => _unfollow(model),
child: FollowBloggerItem(model),
);
}
/// 正在取关的项:按钮收起动画那 200ms 里还能再点一次,防重复请求
final _unfollowing = <FollowUserModel>{};
/// 左滑取关:成功才从列表摘掉(接口内部会 emit CollectStatusModel 同步其他页面)
Future<void> _unfollow(FollowUserModel model) async {
if (!_unfollowing.add(model)) return;
try {
final ok = await MineService.getFollow(model.uid, false);
if (!ok) return showToast('取消关注失败,请重试');
dataSource.remove(model); // 按对象移除,不用 index——异步期间列表可能已刷新
update();
} finally {
_unfollowing.remove(model);
}
}
}
class TopicController extends MineFollowSubLogic {
TopicController(super.uid);
@override
bool get isGridStyle => true;
@override
fetchPageData({bool isRefresh = true}) async {
super.fetchPageData(isRefresh: isRefresh);
final retModel = await MineService.fetchCollectList<TagsBean>("tag",
page: pageNumber, size: 10, uid: uid);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
retModel?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(retModel?.list ?? []);
pageNumber += 1;
update();
}
@override
Widget instanceChildItem(int index) {
return FollowTopicItem(dataSource[index]);
}
}
MineFollowSubLogic instanceController(FollowType type, {int? uid}) {
switch (type) {
case FollowType.user:
return BloggerController(uid);
case FollowType.topic:
return TopicController(uid);
case FollowType.actress:
return ActressController(uid);
default:
throw 'type 没有定义';
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../tools_base/loading/loading_center_widget.dart';
import '../../../tools_base/refresh/pull_refresh.dart';
import '../../../tools_base/widget/swipe_action_item.dart';
import 'mine_follow_sub_logic.dart';
enum FollowType {
user,
topic,
actress;
}
class MineFollowSubPage extends StatelessWidget {
final FollowType pageType; // 0 博主, 1 粉丝, 2 话题
final int? uid;
MineFollowSubPage({super.key, required this.pageType, this.uid});
@override
Widget build(BuildContext context) {
return Scaffold(
// 用默认 globalglobal:false 下 widget 销毁不会 Get.deleteonClose 永远不触发,
// 往 logic 里加 Timer/订阅就会静默泄漏。本页单实例(入口只在「我的」,链路不成环)故不加 tag;
// 将来若做成多 pageType 并存的 TabBarView,得按 pageType 补 tag,否则三个 tab 抢同一个实例
body: GetBuilder<MineFollowSubLogic>(
init: instanceController(pageType, uid: uid),
builder: (logic) {
return pullYsRefresh(
onRefresh: (refreshController) => logic.fetchPageData(),
onLoading: (refreshController) => logic.fetchPageData(isRefresh: false),
onInit: (ctr) => logic.refreshController = ctr,
child: () {
if (logic.isLoading) return const LoadingCenterWidget();
if (logic.dataSource.isEmpty)
return CErrorWidget(
retryOnTap: () {
logic.fetchPageData();
},
);
if (logic.isGridStyle)
return GridView.builder(
padding: EdgeInsets.only(left: 16, right: 16, top: 12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 5,
childAspectRatio: 165 / 143,
),
itemCount: logic.dataSource.length,
itemBuilder: (BuildContext context, int index) {
return logic.instanceChildItem(index);
},
);
// 一滚动就收起左滑展开的那项,免得滑走了还留着个张开的
return NotificationListener<ScrollStartNotification>(
onNotification: (_) {
SwipeActionItem.closeOpened();
return false;
},
child: ListView.builder(
padding: EdgeInsets.only(top: 12),
itemCount: logic.dataSource.length,
itemBuilder: (BuildContext context, int index) {
return logic.instanceChildItem(index);
},
),
);
}());
}),
);
}
}
@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'mine_follow_sub_page.dart';
class MineFollowingPage extends StatelessWidget {
const MineFollowingPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的关注')),
body: MineFollowSubPage(pageType: FollowType.user),
);
}
}
@@ -0,0 +1,294 @@
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_model/mine/follow_user_list_model.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.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 '../../../community/community_tag_page/community_tag_page.dart';
import 'package:hgdj/extension/extensions.dart';
/// 关注列表 - 女优项
class FollowActressItem extends StatefulWidget {
final FollowUserModel model;
const FollowActressItem(this.model, {super.key});
@override
State<FollowActressItem> createState() => _FollowActressItemState();
}
class _FollowActressItemState extends State<FollowActressItem> {
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: () {
// type:1 原跳女优主页 ActressMainPage(已下线),pushToPersonCenter 内 type==1 已 return null
// → 当前点击不跳转,待产品确认替代页(objcId 与用户中心 uid 非同一体系,不能直接跳 UserCenterPage
pushToPersonCenter(widget.model.objcId, type: 1);
},
child: Container(
margin: EdgeInsets.symmetric(horizontal: 18.w),
padding: EdgeInsets.symmetric(horizontal: 13.w, vertical: 16.h),
decoration: BoxDecoration(
color: Color(0xff242424),
borderRadius: BorderRadius.all(Radius.circular(6)),
),
child: Row(
children: [
NetworkImageLoader(
imageUrl: widget.model.portrait ?? "",
width: 43,
height: 43,
borderRadius: 6,
),
SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
widget.model.name ?? "",
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
Text(
"粉丝: ${widget.model.fans?.countStr}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: Color(0xff666666),
fontWeight: FontWeight.w400,
),
),
],
),
),
GestureDetector(
onTap: () async {
final isFollow = widget.model.hasFollow ?? false;
final res = await MineService.postCollect(
widget.model.objcId, 'actresss', !isFollow);
if (res) {
widget.model.hasFollow = !isFollow;
setState(() {});
}
},
child: Container(
padding: EdgeInsets.symmetric(horizontal: 11.w, vertical: 5.h),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .3),
borderRadius: BorderRadius.circular(40)),
child: Row(
children: [
if (widget.model.hasFollow == true) ...[
Image.asset('collect_red.png'.commonImgPath, width: 16.w),
7.w.sizeBoxW,
],
Text(
widget.model.hasFollow == true ? '已关注' : '关注',
style: TextStyle(color: Colors.white, fontSize: 14),
)
],
),
),
),
],
),
),
);
}
}
/// 关注列表 - 博主项
class FollowBloggerItem extends StatefulWidget {
final FollowUserModel model;
const FollowBloggerItem(this.model, {super.key});
@override
State<FollowBloggerItem> createState() => _FollowBloggerItemState();
}
class _FollowBloggerItemState extends State<FollowBloggerItem> {
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: () {
pushToPersonCenter(widget.model.uid ?? 0);
},
child: Container(
padding: EdgeInsets.only(bottom: 18, left: 16, right: 16),
child: Row(
children: [
NetworkImageLoader(
imageUrl: widget.model.portrait ?? "",
width: 60,
height: 60,
borderRadius: 30,
),
SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
widget.model.name ?? "",
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 16,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w500,
),
),
6.sizeBoxH,
Row(
children: [
Flexible(
child: Text(
'${widget.model.totalWorks ?? 0} 作品',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 12,
color: Color(0x8CFFFFFF),
fontWeight: FontWeight.w400),
),
),
SizedBox(width: 12),
Flexible(
child: Text(
'${widget.model.fans?.countStr} 粉丝',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 12,
color: Color(0x8CFFFFFF),
fontWeight: FontWeight.w400),
),
),
],
)
],
),
),
SizedBox(width: 12),
Image.asset(
'arrow_right_grey.webp'.commonImgPath,
color: Color(0xffDCDCDC),
width: 24,
)
],
),
),
);
}
}
/// 关注列表 - 话题项
class FollowTopicItem extends StatefulWidget {
final TagsBean model;
const FollowTopicItem(this.model, {super.key});
@override
State<FollowTopicItem> createState() => _FollowTopicItemState();
}
class _FollowTopicItemState extends State<FollowTopicItem> {
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: () {
Get.to(() => CommunityTagDetailPage(model: widget.model));
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
NetworkImageLoader(
imageUrl: widget.model.coverImg ?? "",
width: double.infinity,
height: double.infinity,
borderRadius: 8,
),
Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .6),
borderRadius: BorderRadius.circular(8)),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
"#${widget.model.name}",
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
Text(
'${widget.model.videoCount.countStr}个帖子',
style: TextStyle(color: Color(0xffcccccc), fontSize: 10),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
],
),
),
SizedBox(height: 12),
GestureDetector(
onTap: () async {
final isCollected = widget.model.hasCollected ?? false;
final res = await MineService.postCollect(
widget.model.id, "tag", !isCollected);
if (res) {
widget.model.hasCollected = !isCollected;
setState(() {});
}
},
child: Container(
width: 108,
height: 27,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color(0xFFF68804),
borderRadius: BorderRadius.circular(6)),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.model.hasCollected == true ? '已关注' : '关注',
style: TextStyle(color: Colors.white, fontSize: 14),
)
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,26 @@
class PointItem {
double x;
double y;
int? index;
bool isSelected;
bool isError;
bool isFirstSelected;
double angle;
PointItem({
required this.x,
required this.y,
this.index,
this.isSelected = false,
this.isError = false,
this.isFirstSelected = false,
this.angle = double.infinity,
});
@override
bool operator ==(Object other) =>
identical(this, other) || other is PointItem && runtimeType == other.runtimeType && x == other.x && y == other.y;
@override
int get hashCode => x.hashCode ^ y.hashCode;
}
@@ -0,0 +1,602 @@
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../../tools_base/debug_log.dart';
import '../model/point_item.dart';
import 'line_painter.dart';
/// 当点被选中时的回调函数
typedef OnHitPoint = void Function(List<int> result);
/// 手势滑动结束时的回调函数
/// [result] 已经选择的所有点的结果集
typedef OnComplete = void Function(List<int> result);
class GesturePasswordWidget extends StatefulWidget with DiagnosticableTreeMixin {
/// GesturePasswordWidget 的 width 和 height.
final double size;
///用来判断点是否被选中的区域大小,值越大识别越精准.
final double identifySize;
///正常情况下展示的widget
final Widget? normalItem;
///选中情况下展示的widget
final Widget? selectedItem;
/// 错误情况下展示的widget,只有设置了[minLength]或[answer]时才会生效,
/// 1)当[minLength]不为null时,如果选择的点的数量小于minLength,则展示[errorItem]
/// 比如设置了 minLength = 4,但是选择的点的结果集为 [0,1,3],共选择了3个点,小于4;
/// 2)当[answer]不为null时,如果选择的点的结果集和[answer]不相等,则展示[errorItem]
/// 比如 answer = [0,1,2,4,7],但是选择的点的结果集为[0,1,2,5,8],和answer不相等;
/// 另外,[errorItem]的展示时长由[completeWaitMilliseconds]控制。
final Widget? errorItem;
/// 当这个点被选中时要展示的widget,其展示时长由[hitShowMilliseconds]控制,达到展示时长
/// 后继续展示[selectedItem]。
final Widget? hitItem;
///正常情况下显示的箭头控件。
///跟随手势旋转时,x轴正方向为0度,所以如果你使用了箭头,确保箭头指向x轴正方向。
final Widget? arrowItem;
///错误情况下显示的箭头控件,如果设置了[errorArrowItem],则必须设置[arrowItem],
///否则[errorArrowItem]不会展示。
///跟随手势旋转时,x轴正方向为0度,所以如果你使用了箭头,确保箭头指向x轴正方向。
final Widget? errorArrowItem;
///[arrowItem]和[errorArrowItem]在x轴上的偏移,原点在[normalItem]的中心。
///当 -1 < [arrowXAlign] < 1 时,[arrowItem]和[errorArrowItem]在[normalItem]范围内进行绘制;
///当[arrowXAlign] > 1 或者[arrowXAlign] < -1时,在[normalItem]范围外进行绘制;
final double arrowXAlign;
///[arrowItem]和[errorArrowItem]在y轴上的偏移,原点在[normalItem]的中心。
///当 -1 < [arrowYAlign] < 1 时,[arrowItem]和[errorArrowItem]在[normalItem]范围内进行绘制;
///当[arrowYAlign] > 1 或者[arrowYAlign] < -1时,在[normalItem]范围外进行绘制;
final double arrowYAlign;
///单行个数,总个数等于 singleLineCount * singleLineCount.
final int singleLineCount;
///GesturePasswordWidget的背景色,默认为 [Theme.of].[scaffoldBackgroundColor]
final Color? color;
///当点被选中时的回调函数
final OnHitPoint? onHitPoint;
///手势滑动结束时的回调函数
final OnComplete? onComplete;
///线的颜色
final Color lineColor;
///错误场景下线的颜色,见[errorItem]
final Color errorLineColor;
///线的宽度
final double lineWidth;
/// 是否采用宽松策略,默认为true。
/// 考虑这种情况:第一个点选中了 index = 0 的点,第二个点选中了 index = 6的点,
/// 此时index = 0,index = 3,index = 6这三个点在一条直线上,
/// 如果loose为true,输出为[0,3,6],
/// 如果loose为false,输出为[0,6].
final bool loose;
///正确的结果,demo: [0, 1, 2, 4, 7]
final List<int>? answer;
///最后选择的所有点及绘制的直线在屏幕上展示的时间,时间结束后,清除所有点,恢复到初始状态,
///时间结束之前 GesturePasswordWidget 不再接受任何手势事件。
final int completeWaitMilliseconds;
/// 只是用来展示用,不能触摸
final bool ignoring;
///见[hitItem]
final int hitShowMilliseconds;
///如果设置了此值,则长度不够时显示[errorItem]和[errorLineColor].
final int? minLength;
///是否开启触觉反馈(命中点轻震、绘制错误较重震),默认开启
final bool enableHaptic;
GesturePasswordWidget({
super.key,
this.size = 300.0,
this.identifySize = 50.0,
this.normalItem,
this.selectedItem,
this.errorItem,
this.hitItem,
this.arrowItem,
this.errorArrowItem,
this.arrowXAlign = 0.6,
this.arrowYAlign = 0.0,
this.singleLineCount = 3,
this.color,
this.ignoring = false,
this.onHitPoint,
this.onComplete,
this.lineColor = Colors.green,
this.errorLineColor = Colors.redAccent,
this.lineWidth = 2.0,
this.answer,
this.loose = true,
this.completeWaitMilliseconds = 300,
this.hitShowMilliseconds = 40,
this.minLength,
this.enableHaptic = true,
}) : assert(singleLineCount > 1, 'singLineCount must not be smaller than 1'),
assert(identifySize > 0),
assert(size > identifySize),
assert(!(errorArrowItem != null && arrowItem == null), 'when arrowItem == null, errorArrowItem will not be shown.');
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('size', size));
properties.add(DoubleProperty('identifySize', identifySize));
properties.add(DiagnosticsProperty<Widget>('normalItem', normalItem));
properties.add(DiagnosticsProperty<Widget>('selectedItem', selectedItem));
properties.add(DiagnosticsProperty<Widget>('errorItem', errorItem));
properties.add(DiagnosticsProperty<Widget>('hitItem', hitItem));
properties.add(DiagnosticsProperty<Widget>('arrowItem', arrowItem));
properties.add(
DiagnosticsProperty<Widget>('errorArrowItem', errorArrowItem),
);
properties.add(DoubleProperty('arrowXAlign', arrowXAlign));
properties.add(DoubleProperty('arrowYAlign', arrowYAlign));
properties.add(IntProperty('singleLineCount', singleLineCount));
properties.add(ColorProperty('color', color));
properties.add(DiagnosticsProperty<OnHitPoint>('onHitPoint', onHitPoint));
properties.add(DiagnosticsProperty<OnComplete>('onComplete', onComplete));
properties.add(ColorProperty('lineColor', lineColor));
properties.add(ColorProperty('errorLineColor', errorLineColor));
properties.add(IterableProperty('answer', answer));
properties.add(DoubleProperty('lineWidth', lineWidth));
properties.add(FlagProperty(
'loose',
value: loose,
ifFalse: 'loose: false',
ifTrue: 'loose: true',
defaultValue: true,
));
properties.add(
IntProperty('completeWaitMilliseconds', completeWaitMilliseconds),
);
properties.add(IntProperty('hitShowMilliseconds', hitShowMilliseconds));
properties.add(IntProperty('minLength', minLength));
}
@override
State<GesturePasswordWidget> createState() => _GesturePasswordWidgetState();
}
class _GesturePasswordWidgetState extends State<GesturePasswordWidget> {
late Point origin;
late int totalCount;
Point<double>? lastPoint;
Widget? normalItem;
Widget? defaultNormalItem;
Widget? selectedItem;
Widget? defaultSelectedItem;
Widget? errorItem;
Widget? defaultErrorItem;
Color? lineColor;
String tipText = '绘制解锁图形';
bool _completing = false; // 完成动画期间锁定手势,避免动画未结束就开始下一笔
final points = <PointItem>[];
final linePoints = <Point<double>>[];
final result = <int>[];
final double defaultSize = 10.0;
@override
void initState() {
super.initState();
defaultNormalItem = Container(
width: defaultSize,
height: defaultSize,
decoration: BoxDecoration(
color: Colors.greenAccent,
borderRadius: BorderRadius.circular(50.0),
),
);
defaultSelectedItem = Container(
width: defaultSize,
height: defaultSize,
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(50.0),
),
);
defaultErrorItem = Container(
width: defaultSize,
height: defaultSize,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(50.0),
),
);
if (widget.answer?.isNotEmpty ?? false) {
tipText = '验证解锁图形';
}
lineColor = widget.lineColor;
normalItem = widget.normalItem ?? defaultNormalItem;
selectedItem = widget.selectedItem ?? defaultSelectedItem;
errorItem = widget.errorItem ?? defaultErrorItem;
totalCount = widget.singleLineCount * widget.singleLineCount;
origin = Point<double>(widget.size * 0.5, widget.size * 0.5);
calculatePointPosition();
}
@override
void didUpdateWidget(GesturePasswordWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.lineColor != oldWidget.lineColor) {
lineColor = widget.lineColor;
}
if (widget.normalItem != oldWidget.normalItem) {
normalItem = widget.normalItem ?? defaultNormalItem;
}
if (widget.selectedItem != oldWidget.selectedItem) {
selectedItem = widget.selectedItem ?? defaultSelectedItem;
}
if (widget.errorItem != oldWidget.errorItem) {
errorItem = widget.errorItem ?? defaultErrorItem;
}
if (widget.singleLineCount != oldWidget.singleLineCount ||
widget.size != oldWidget.size ||
widget.identifySize != oldWidget.identifySize) {
totalCount = widget.singleLineCount * widget.singleLineCount;
origin = Point<double>(widget.size * 0.5, widget.size * 0.5);
points.clear();
calculatePointPosition();
}
}
@override
Widget build(BuildContext context) {
//预览小点的边长:每行 singleLineCount 个 10px 的点 + 之间 8px 间距。必须按 singleLineCount 算,
//写死值(原来是 56)比内容宽时 Wrap 默认贴左上,整块预览会偏左偏上,且换行数只对 3x3 成立
final previewSize = 10.0 * widget.singleLineCount + 8 * (widget.singleLineCount - 1);
return Column(
children: [
SizedBox(
width: previewSize,
height: previewSize,
child: Wrap(
runSpacing: 8,
spacing: 8,
children: points
.map((e) => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
//选中态靠加粗边框填实心,边框画在 10x10 内部,不影响尺寸和对齐
border: Border.all(width: e.isSelected ? 5 : 1.0, color: const Color(0xff6792ff)),
),
))
.toList(),
),
),
Container(
margin: EdgeInsets.symmetric(vertical: 20),
child: Text(
tipText,
style: TextStyle(
color: Colors.white,
),
),
),
IgnorePointer(
ignoring: widget.ignoring || _completing,
child: Container(
color: widget.color ?? Theme.of(context).scaffoldBackgroundColor,
width: widget.size,
height: widget.size,
child: Stack(
children: createPointsWidget()
..add(
GestureDetector(
onPanDown: handlePanDown,
onPanUpdate: handlePanUpdate,
onPanEnd: handlePanEnd,
onPanCancel: () {
handlePanEnd(null);
},
child: CustomPaint(
painter: LinePainter(
points: linePoints,
lineColor: lineColor,
lineWidth: widget.lineWidth,
),
willChange: true,
size: Size(widget.size, widget.size),
),
),
),
),
),
)
],
);
}
//计算每个点的位置
void calculatePointPosition() {
double initX = widget.identifySize * 0.5;
double initY = widget.identifySize * 0.5;
double gap = (widget.size - widget.identifySize) / (widget.singleLineCount - 1);
for (int i = 0; i < totalCount; i++) {
double centerX = initX + i % widget.singleLineCount * gap;
double centerY = initY + i ~/ widget.singleLineCount * gap;
var point = PointItem(x: centerX, y: centerY, index: i);
//展示模式(ignoring)下预置 answer 命中的点为选中
if (widget.ignoring && (widget.answer?.contains(i) ?? false)) {
point.isSelected = true;
}
points.add(point);
}
}
//创建每个点的widget
List<Widget> createPointsWidget() {
return points.map<Widget>((p) {
double reference = 1 - (widget.identifySize / widget.size);
double x = (p.x - origin.x) / (widget.size * 0.5) / reference;
double y = (p.y - origin.y) / (widget.size * 0.5) / reference;
Widget? child = normalItem;
if (p.isError) {
child = errorItem;
} else if (p.isFirstSelected) {
child = widget.hitItem;
} else if (p.isSelected) {
child = selectedItem;
}
Widget? arrowItem = widget.arrowItem;
if (p.isError && widget.errorArrowItem != null) {
arrowItem = widget.errorArrowItem;
}
return Align(
alignment: Alignment(x, y),
child: Container(
color: Colors.transparent,
width: widget.identifySize,
height: widget.identifySize,
alignment: Alignment.center,
child: widget.arrowItem == null || p.angle == double.infinity
? child
: Transform.rotate(
angle: p.angle,
child: Stack(
alignment: AlignmentDirectional.center,
children: [
child!,
Align(
alignment: Alignment(
widget.arrowXAlign,
widget.arrowYAlign,
),
child: arrowItem,
),
],
),
),
),
);
}).toList();
}
void handlePanDown(DragDownDetails details) {
Point<double> curPoint = Point(details.localPosition.dx, details.localPosition.dy);
final point = calculateHitPoint(curPoint);
if (point != null) {
if (!linePoints.contains(Point(point.x, point.y))) {
addPointToResult(point.index);
setState(() {
point.isSelected = true;
linePoints.add(Point(point.x, point.y));
});
}
}
}
void handlePanUpdate(DragUpdateDetails details) {
Point<double> curPoint = Point(details.localPosition.dx, details.localPosition.dy);
final hitPoint = calculateHitPoint(curPoint);
if (hitPoint != null) {
if (!linePoints.contains(Point(hitPoint.x, hitPoint.y))) {
final drawPoint = Point(hitPoint.x, hitPoint.y);
//宽松策略下,若三点共线则自动将中间的点设置为选中状态。
if (widget.loose && linePoints.isNotEmpty) {
handleLooseCase(points[result.last], hitPoint);
}
//处理箭头的角度展示
if (widget.arrowItem != null) {
for (int i = 0; i < result.length - 1; i++) {
final p1 = Point(points[result[i]].x, points[result[i]].y);
final p2 = Point(points[result[i + 1]].x, points[result[i + 1]].y);
points[result[i]].angle = calculateAngle(p1, p2);
}
}
if (result.isNotEmpty) {
final p1 = Point(points[result.last].x, points[result.last].y);
points[result.last].angle = calculateAngle(p1, Point(hitPoint.x, hitPoint.y));
}
addPointToResult(hitPoint.index);
setState(() {
linePoints.remove(lastPoint);
hitPoint.isSelected = true;
linePoints.add(drawPoint);
});
}
} else {
if (linePoints.isNotEmpty) {
if (widget.arrowItem != null) {
final p1 = Point(points[result.last].x, points[result.last].y);
points[result.last].angle = calculateAngle(p1, curPoint);
}
setState(() {
linePoints.remove(lastPoint);
linePoints.add(curPoint);
});
lastPoint = curPoint;
}
}
}
void handlePanEnd(DragEndDetails? details) async {
if (result.isEmpty) {
return;
}
if (!mounted) {
return;
}
linePoints.removeLast();
if ((widget.answer != null && widget.answer!.join() != result.join()) ||
(widget.minLength != null && widget.minLength! > result.length)) {
lineColor = widget.errorLineColor;
if (widget.minLength != null && widget.minLength! > result.length) {
tipText = '请至少链接4个点';
} else {
tipText = '图形绘制不正确';
}
for (int i = 0; i < result.length; i++) {
points[result[i]].isError = true;
}
if (widget.enableHaptic) HapticFeedback.mediumImpact(); //绘制错误:较重震动
}
//清除最后一个点的角度
points[result.last].angle = double.infinity;
if (!mounted) {
return;
}
setState(() {
_completing = true;
});
await Future.delayed(Duration(
milliseconds: widget.completeWaitMilliseconds,
));
_completing = false;
lineColor = widget.lineColor;
widget.onComplete?.call(result);
debugLog('handlePanEnd answer=${widget.answer}, result = $result');
if (!mounted) {
return;
}
setState(() {
for (final p in points) {
p.isSelected = false;
p.isError = false;
p.angle = double.infinity;
}
linePoints.clear();
result.clear();
});
}
//计算命中的点
PointItem? calculateHitPoint(Point<double> curPoint) {
for (int i = 0; i < points.length; i++) {
final p = Point(points[i].x, points[i].y);
if (p.distanceTo(curPoint) < widget.identifySize * 0.5) {
if (points[i].isSelected) {
return null;
}
return points[i];
}
}
return null;
}
void addPointToResult(int? index) {
if (widget.enableHaptic) HapticFeedback.selectionClick(); //每命中一个点轻震,提升手感
result.add(index ?? 0);
widget.onHitPoint?.call(result);
if (widget.hitItem != null) {
setState(() {
points[index!].isFirstSelected = true;
});
Future.delayed(Duration(milliseconds: widget.hitShowMilliseconds), () {
if (!mounted) return;
setState(() {
points[index!].isFirstSelected = false;
});
});
}
}
//根据海伦公式计算三角形面积,面积为0时视为三点共线。如果这个点还在共线的中间,
//则将其设置为选中状态,并将其添加到result中。
void handleLooseCase(PointItem pre, PointItem next) {
List<int?> midItems = [];
for (final item in points) {
if (item != pre && item != next && item.isSelected == false) {
final itemDrawPoint = Point<double>(item.x, item.y);
final preDrawPoint = Point<double>(pre.x, pre.y);
final nextDrawPoint = Point<double>(next.x, next.y);
double a = itemDrawPoint.distanceTo(preDrawPoint);
double b = itemDrawPoint.distanceTo(nextDrawPoint);
double c = preDrawPoint.distanceTo(nextDrawPoint);
double p = (a + b + c) * 0.5;
double area = p * (p - a) * (p - b) * (p - c);
double halfDistance = c * 0.5;
Point<double> mid = Point(
(pre.x + next.x) * 0.5,
(pre.y + next.y) * 0.5,
);
if (area - 0.5 <= 0 && itemDrawPoint.distanceTo(mid) < halfDistance) {
item.isSelected = true;
midItems.add(item.index);
}
}
}
if (next.index! > pre.index!) {
midItems.sort((a, b) => a! - b!);
} else {
midItems.sort((a, b) => b! - a!);
}
for (final index in midItems) {
addPointToResult(index);
}
}
//计算两点之间连线和 x 轴的夹角,返回弧度
double calculateAngle(Point p1, Point p2) {
return atan2((p2.y - p1.y), (p2.x - p1.x)); //弧度
}
}
@@ -0,0 +1,38 @@
import 'dart:math';
import 'package:flutter/widgets.dart';
class LinePainter extends CustomPainter {
final List<Point>? points;
final Color? lineColor;
final double? lineWidth;
LinePainter({
this.points,
this.lineColor,
this.lineWidth,
});
@override
void paint(Canvas canvas, Size size) {
if (points != null && points!.length > 1) {
Paint paint = Paint();
paint.strokeWidth = lineWidth!;
paint.color = lineColor!;
paint.isAntiAlias = true;
paint.style = PaintingStyle.fill;
paint.strokeCap = StrokeCap.round;
for (int i = 0; i < points!.length - 1; i++) {
canvas.drawLine(
Offset(points![i].x as double, points![i].y as double),
Offset(points![i + 1].x as double, points![i + 1].y as double),
paint,
);
}
}
}
@override
bool shouldRepaint(LinePainter oldDelegate) => true;
}
@@ -0,0 +1,66 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'mine_publish_sub_logic.dart';
class MinePublishLogic extends GetxController {
MinePublishLogic();
List<VideoModel> checkedList = [];
List<VideoModel> dataSource = [];
void addChecked(VideoModel videoModel) {
checkedList.add(videoModel);
update();
}
void removeChecked(VideoModel videoModel) {
checkedList.remove(videoModel);
update();
}
void clearChecked() {
checkedList.clear();
update();
}
bool get isAllChecked {
if (dataSource.isEmpty || checkedList.isEmpty) {
return false;
}
return checkedList.length == dataSource.length;
}
bool get isEmptyChecked {
return checkedList.isEmpty;
}
void selectAllOrNot() {
if (isAllChecked) {
checkedList.clear();
} else {
checkedList.clear();
checkedList.addAll(dataSource);
}
update();
}
void delete() async {
if (checkedList.isEmpty) {
showToast('请选择要删除的项');
return;
}
String resultMsg = await MineService.deletePublishes(
ids: checkedList.map((e) => e.id ?? '').toList());
if (resultMsg.isEmpty) {
checkedList.clear();
update();
Get.find<MinePublishSubLogic>(tag: '2').loadData(isRefresh: true);
showToast('删除成功');
} else {
showToast(resultMsg);
}
}
}
@@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/user/wallet_model.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/indicator/custom_tab_indicator.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:provider/provider.dart';
import '../../../hj_utils/widget_util.dart';
import '../../home/widget/publist_entry_alert.dart';
import '../make_money/mine_withdrawal_record_page.dart';
import '../make_money/withdrawal_page.dart';
import 'mine_publish_main_logic.dart';
import 'mine_publish_sub_logic.dart';
import 'mine_publish_sub_page.dart';
///我发布的帖子
class MinePublishPostPage extends StatefulWidget {
const MinePublishPostPage({super.key});
@override
State<StatefulWidget> createState() {
return _MinePublishPostPageState();
}
}
class _MinePublishPostPageState extends State<MinePublishPostPage>
with TickerProviderStateMixin {
final tabs = ['已发布', '待审核', '未通过'];
WalletModel? get wallet => globalStore.wallet;
late final tabCtr = TabController(length: tabs.length, vsync: this);
bool inEdit = false;
@override
void initState() {
super.initState();
tabCtr.addListener(_onTabChanged);
}
@override
void dispose() {
tabCtr.removeListener(_onTabChanged);
tabCtr.dispose();
super.dispose();
}
void _onTabChanged() {
setState(() {});
}
void _setSelectedTabDatas() {
final logic = Get.find<MinePublishLogic>();
if (tabCtr.index == 2) {
logic.dataSource = List<VideoModel>.from(
Get.find<MinePublishSubLogic>(tag: '2').dataList ?? []);
} else {
logic.dataSource = [];
}
logic.update();
}
@override
Widget build(BuildContext context) {
return GetBuilder(
init: MinePublishLogic(),
builder: (controller) {
return Scaffold(
appBar: AppBar(
title: Text("创作中心",
style: textStyle(18, Color(0xE5FFFFFF), FontWeight.w600)),
actions: [
Visibility(
visible: tabCtr.index == 2,
child: TextButton(
onPressed: () {
if (inEdit) {
controller.clearChecked();
} else {
_setSelectedTabDatas();
}
setState(() => inEdit = !inEdit);
},
child: Text(
inEdit ? '完成' : '编辑',
style: TextStyle(
color: Color(0xFF989898),
fontSize: 12,
),
),
),
)
],
),
body: Stack(
children: [
Column(
children: [
Consumer<GlobalStore>(
builder: (context, store, child) {
return Container(
margin: EdgeInsets.symmetric(
horizontal: 16, vertical: 18),
padding: EdgeInsets.fromLTRB(0, 14, 0, 22),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
color: Colors.white.withValues(alpha: .05),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
_buildItem(
'收益余额',
'${wallet?.income}',
'立即提现',
() => Get.to(WithdrawalPage()),
),
_buildItem(
'累计收益',
'${wallet?.vidIncome}',
'业绩明细',
() =>
Get.to(RecordsPage(RecordType.income)),
),
],
),
],
),
);
},
),
_buildTabbar(),
Expanded(child: _buildContent())
],
),
if (!inEdit)
Positioned(
right: 20,
bottom: 50,
child: PublishButton(
entry: PublishEntry.community,
),
),
if (inEdit)
Positioned(
left: 0,
right: 0,
height: 62,
bottom: MediaQuery.of(context).viewInsets.bottom,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: Color(0xFF303030),
borderRadius: BorderRadius.circular(3),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
GestureDetector(
onTap: () {
controller.selectAllOrNot();
},
child: Container(
width: 112,
height: 24,
alignment: Alignment.center,
child: Text(
controller.isAllChecked ? '取消全选' : '全选',
style: TextStyle(
color: Color(0x99FFFFFF), fontSize: 16),
),
),
),
Container(
width: 1,
height: 20,
color: Color(0x1AFFFFFF),
),
GestureDetector(
onTap: controller.isEmptyChecked
? null
: () {
controller.delete();
},
child: Container(
width: 112,
height: 38,
decoration: BoxDecoration(
color: Color(0xFFF52C56).withValues(
alpha: controller.isEmptyChecked ? 0.5 : 1),
borderRadius: BorderRadius.circular(3),
),
alignment: Alignment.center,
child: Text(
'删除(${controller.checkedList.length})',
style: TextStyle(
color: Colors.white.withValues(
alpha: controller.isEmptyChecked
? 0.5
: 1),
fontSize: 16),
),
),
),
],
),
),
),
],
));
},
);
}
_buildTabbar() {
return TabBar(
padding: const EdgeInsets.only(left: 12),
tabAlignment: TabAlignment.fill,
controller: tabCtr,
onTap: (index) {
if (index != 2 && inEdit) {
// 如果切换到非"未通过"标签页,强制退出编辑模式
setState(() {
inEdit = false;
Get.find<MinePublishLogic>().clearChecked();
});
}
},
tabs: List.generate(
tabs.length,
(index) => Padding(
padding: const EdgeInsets.only(top: 12, bottom: 4),
child: FittedBox(
child: Text(tabs[index]),
),
)),
isScrollable: false,
labelColor: Colors.white.withValues(alpha: .9),
labelStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
unselectedLabelColor: Colors.white.withValues(alpha: 0.35),
unselectedLabelStyle:
const TextStyle(fontSize: 14, fontWeight: FontWeight.w400),
indicator: CustomIndicator(
width: 13,
height: 3,
borderRadius: BorderRadius.circular(2),
isGradient: true,
),
);
}
_buildItem(
String title, String count, String actionTitle, Function() action) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
title,
style: TextStyle(
color: Colors.white.withValues(alpha: .7),
fontWeight: FontWeight.w500,
fontSize: 14),
),
6.sizeBoxH,
Text(
count,
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.w500, fontSize: 24),
),
6.sizeBoxH,
GestureDetector(
onTap: () => action.call(),
child: Container(
width: 72,
height: 25,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(13.5)),
color: Color(0xffF68804),
),
child: Text(
actionTitle,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 12.0),
),
),
),
],
);
}
// TabBarView 内容(外层调用处 line 134 已用 Expanded 包裹,这里不再套 Expanded,否则双 Expanded 冲突)
Widget _buildContent() {
return TabBarView(
controller: tabCtr,
physics: inEdit && tabCtr.index == 2
? const NeverScrollableScrollPhysics()
: null,
children: [
MinePublishSubPage(status: 1, inEdit: false).keepAlive,
MinePublishSubPage(status: 0, inEdit: false).keepAlive,
MinePublishSubPage(status: 2, inEdit: inEdit).keepAlive,
],
);
}
}
@@ -0,0 +1,23 @@
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
class MinePublishSubLogic extends ListBaseLogic<VideoModel> {
final int status;
MinePublishSubLogic({required this.status});
@override
void onReady() {
super.onReady();
loadData();
}
void loadData({bool isRefresh = true}) =>
fetchData(isRefresh: isRefresh, fetch: _fetch);
Future<(List<VideoModel>?, bool)> _fetch(int page) async {
final res = await MineService.fetchPublishes(page: page, status: status);
return (res.list, res.hasNext ?? false);
}
}
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:get/get.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 '../../community/widget/community_post_widget.dart';
import 'mine_publish_main_logic.dart';
import 'mine_publish_sub_logic.dart';
typedef OnItemCheckCallback = void Function(
VideoModel videoModel, bool isChecked);
class MinePublishSubPage extends StatelessWidget {
final int status;
final bool inEdit;
final OnItemCheckCallback? onItemCheck;
const MinePublishSubPage(
{super.key, this.status = 0, this.inEdit = false, this.onItemCheck});
@override
Widget build(BuildContext context) {
final logic = Get.find<MinePublishLogic>();
return GetBuilder<MinePublishSubLogic>(
init: MinePublishSubLogic(status: status),
tag: status.toString(),
builder: (controller) {
return pullYsRefresh(
onInit: (refr) => controller.refreshCtr = refr,
onRefresh: (refr) => controller.loadData(),
onLoading: (refr) => controller.loadData(isRefresh: false),
child: () {
if (controller.isLoading) return LoadingCenterWidget();
if (controller.isEmptyData) return CErrorWidget();
final list = controller.dataList!;
return ListView.builder(
itemCount: list.length,
itemBuilder: (BuildContext context, int index) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: CommunityPostWidget(
videoModel: list[index],
isMyPublish: true,
showCheck: inEdit,
isChecked: logic.checkedList.contains(list[index]),
onItemCheck: (videoModel, isChecked) {
if (isChecked) {
logic.addChecked(videoModel);
} else {
logic.removeChecked(videoModel);
}
},
),
);
},
);
}(),
);
},
);
}
}
@@ -0,0 +1,312 @@
import 'dart:convert' as convert;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/common_alert.dart';
import '../../../../hj_utils/light_model.dart';
import '../../../../hj_utils/store_keys.dart';
import '../../../../tools_base/refresh/pull_refresh.dart';
import '../../../hj_utils/widget_util.dart';
import '../../../tools_base/loading/loading_alert_widget.dart';
import '../../../tools_base/loading/loading_center_widget.dart';
import 'model/alipay_bank_list_model.dart';
import 'widget/add_bank_dialog.dart';
///银行卡管理
class BankCardHomePage extends StatefulWidget {
final AccountInfoModel? selectModel;
BankCardHomePage({super.key, this.selectModel});
@override
State<StatefulWidget> createState() {
return _BankCardHomePageState();
}
}
class _BankCardHomePageState extends State<BankCardHomePage> {
bool isEdit = false;
bool loading = true;
List<AccountInfoModel> aliList = [];
AccountInfoModel? selectItem;
List<AccountInfoModel> deleteList = [];
RefreshController? refreshCtr;
@override
void initState() {
super.initState();
_getAliListData();
}
void _onDeleteAccount() async {
if (deleteList.isEmpty) return;
var isDelete = await CommonAlert.show(content: "是否确认删除账号");
if (!isDelete) return;
LoadingAlertWidget.show();
bool result = false;
for (var model in deleteList) {
result = await MineService.bankCardDelete(id: model.id);
if (result) {
var result = await lightKV.getString(StoreKeys.LAST_A_ACCOUNT);
if (result != null) {
AccountInfoModel listBean =
AccountInfoModel.fromMap(convert.jsonDecode(result));
if (model.id == listBean.id) {
lightKV.setString(StoreKeys.LAST_A_ACCOUNT, "");
}
}
aliList.remove(model);
setState(() {});
}
}
if (result) {
showToast("删除成功");
isEdit = false;
if (mounted) setState(() {});
}
LoadingAlertWidget.cancel();
}
void _getAliListData() async {
final model = await MineService.getBankCards();
aliList.clear();
aliList.addAll(model?.list ?? []);
refreshCtr?.refreshCompleted();
if (aliList.isNotEmpty == true) {
if (widget.selectModel != null) {
selectItem = widget.selectModel;
} else {
// selectItem = aliList.firstOrNull;
}
}
loading = false;
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: Text('选择银行卡',
style: textStyle(18, Color(0xE5FFFFFF), FontWeight.w600)),
leading: BackButton(
onPressed: () => Get.back(result: selectItem),
),
actions: [
InkWell(
enableFeedback: false,
onTap: () {
isEdit = !isEdit;
if (isEdit) {
deleteList.clear();
}
setState(() {});
},
child: Container(
alignment: Alignment.center,
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Text(
isEdit ? "完成" : "编辑",
style: TextStyle(
color: isEdit ? AppColors.actionRed : Color(0x8CFFFFFF),
fontSize: 12,
),
),
),
),
18.sizeBoxW,
],
),
body: Stack(
fit: StackFit.expand,
children: [
Column(
children: [
Expanded(child: () {
if (loading) return LoadingCenterWidget();
if (aliList.isEmpty == true) return CErrorWidget();
return Container(
margin: EdgeInsets.fromLTRB(16, 12, 16, 0),
child: pullYsRefresh(
onInit: (ctr) => refreshCtr = ctr,
enablePullUp: false,
onRefresh: (ctr) => _getAliListData(),
child: ListView.builder(
padding: EdgeInsets.only(top: 10, bottom: 80),
shrinkWrap: true,
itemCount: aliList.length ?? 0,
itemBuilder: (BuildContext context, int index) {
return _buildCardView(index, aliList[index]);
},
),
),
);
}()),
_buildCardAddView(),
],
),
if (isEdit)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: GestureDetector(
onTap: () => _onDeleteAccount(),
child: Container(
margin: EdgeInsets.symmetric(horizontal: 35, vertical: 20),
height: 44,
decoration: BoxDecoration(
color: Color(0xffF52C56),
borderRadius: const BorderRadius.all(Radius.circular(3)),
),
child: Center(
child: Text(
"删除",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
),
),
),
),
],
),
);
}
Widget _buildCardView(int index, AccountInfoModel? item) {
bool isSelected = false;
if (isEdit) {
if (deleteList.contains(item) == true) {
isSelected = true;
}
} else {
isSelected = selectItem?.id == item?.id;
}
return InkWell(
enableFeedback: false,
onTap: () {
if (isEdit) {
if (deleteList.contains(item) == true) {
deleteList.remove(item);
} else {
deleteList.add(item!);
}
} else {
Get.back(result: item);
}
setState(() {});
},
child: Container(
margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(9)),
color: const Color(0x0DFFFFFF)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
item?.getBankName() ?? "",
style: const TextStyle(
color: const Color(0xE5ffffff),
fontWeight: FontWeight.w500,
fontSize: 16.0),
),
9.sizeBoxW,
Text(
item?.actName ?? "",
style: const TextStyle(
color: const Color(0xE5ffffff),
fontWeight: FontWeight.w400,
fontSize: 12.0),
)
],
),
6.sizeBoxH,
Text("卡号:${item?.act}",
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w400,
fontSize: 12.0))
],
),
),
Stack(
alignment: Alignment.center,
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.transparent,
shape: BoxShape.circle,
),
),
Icon(
isSelected
? CupertinoIcons.checkmark_circle_fill
: CupertinoIcons.circle,
color: isSelected ? Color(0xffE1351F) : Color(0xff999999),
),
],
),
],
),
),
);
}
//添加银行卡
_buildCardAddView() {
return InkWell(
enableFeedback: false,
onTap: () async {
final res = await Get.dialog(AddBankDialog(),
barrierColor: Colors.black.withValues(alpha: .7));
if (res != null) {
_getAliListData();
}
},
child: Container(
margin: EdgeInsets.symmetric(horizontal: 35, vertical: 20),
height: 44,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: const BorderRadius.all(Radius.circular(3)),
),
child: Center(
child: Text(
"添加银行卡",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
),
),
);
}
}
@@ -0,0 +1,53 @@
import 'bankcard_info.dart';
class ApBankListModel {
List<AccountInfoModel>? list;
static ApBankListModel fromJson(Map<String, dynamic>? map) {
ApBankListModel apBankListModel = ApBankListModel();
if (map == null) return apBankListModel;
if (map['list'] is List) {
apBankListModel.list = (map['list'] as List).map((o) => AccountInfoModel.fromMap(o)).toList();
}
return apBankListModel;
}
}
class AccountInfoModel {
String? id;
String? actName;
String? act;
String? bankCode;
String? cardType;
static AccountInfoModel fromMap(Map<String, dynamic>? map) {
AccountInfoModel listBean = AccountInfoModel();
if (map == null) return AccountInfoModel();
listBean.id = map['id'];
listBean.actName = map['actName'];
listBean.act = map['act'];
listBean.bankCode = map['bankCode'];
listBean.cardType = map['cardType'];
return listBean;
}
///获取银行名称
String? getBankName() {
if (bankCode?.isEmpty == true) {
return "";
}
BankCardModel? model = BankcardInfo().getBankInfoMap(bankCode!);
if (model == null) {
return BankcardInfo().getBankName(bankCode!);
}
return model.bankName ?? "";
}
}
class BankCardModel {
String? bankLogoId;
String? bankName;
String? bankCardId;
BankCardModel(this.bankLogoId, this.bankName, this.bankCardId);
}
@@ -0,0 +1,201 @@
import 'alipay_bank_list_model.dart';
class BankcardInfo {
static final BankcardInfo _instance = BankcardInfo._internal();
factory BankcardInfo() => _instance;
BankcardInfo._internal();
var bankName = ['招商银行', '中国银行', '农业银行', '交通银行', '建设银行', '民生银行', '中信银行', '光大银行', '华夏银行', '工商银行', '兴业银行', '国家开发银行'];
var bankCardId = ['CMB', 'BOC', 'ABC', 'COMM', 'CCB', 'CMBC', 'CITIC', 'CEB', 'HXBANK', 'ICBC', 'CIB', 'CDB'];
BankCardModel? getBankInfoMap(String bankId) {
Map<String, dynamic> map = Map();
for (int i = 0; i < bankCardId.length; i++) {
BankCardModel model = new BankCardModel(null, bankName[i], bankCardId[i]);
map[bankCardId[i]] = model;
}
bool contains = map.containsKey(bankId);
if (contains) {
return map[bankId];
}
return null;
}
///支付宝支持银行
String? getBankName(String bankId) {
Map<String, String> map = Map();
map["SRCB"] = "深圳农村商业银行";
map["BGB"] = "广西北部湾银行";
map["SHRCB"] = "上海农村商业银行";
map["BJBANK"] = "北京银行";
map["WHCCB"] = "威海市商业银行";
map["BOZK"] = "周口银行";
map["KORLABANK"] = "库尔勒市商业银行";
map["SPABANK"] = "平安银行";
map["SDEB"] = "顺德农商银行";
map["HURCB"] = "湖北省农村信用社";
map["WRCB"] = "无锡农村商业银行";
map["BOCY"] = "朝阳银行";
map["CZBANK"] = "浙商银行";
map["HDBANK"] = "邯郸银行";
map["BOC"] = "中国银行";
map["BOD"] = "东莞银行";
map["CCB"] = "中国建设银行";
map["ZYCBANK"] = "遵义市商业银行";
map["SXCB"] = "绍兴银行";
map["GZRCU"] = "贵州省农村信用社";
map["ZJKCCB"] = "张家口市商业银行";
map["BOJZ"] = "锦州银行";
map["BOP"] = "平顶山银行";
map["HKB"] = "汉口银行";
map["SPDB"] = "上海浦东发展银行";
map["NXRCU"] = "宁夏黄河农村商业银行";
map["NYNB"] = "广东南粤银行";
map["GRCB"] = "广州农商银行";
map["BOSZ"] = "苏州银行";
map["HZCB"] = "杭州银行";
map["HSBK"] = "衡水银行";
map["HBC"] = "湖北银行";
map["JXBANK"] = "嘉兴银行";
map["HRXJB"] = "华融湘江银行";
map["BODD"] = "丹东银行";
map["AYCB"] = "安阳银行";
map["EGBANK"] = "恒丰银行";
map["CDB"] = "国家开发银行";
map["TCRCB"] = "江苏太仓农村商业银行";
map["NJCB"] = "南京银行";
map["ZZBANK"] = "郑州银行";
map["DYCB"] = "德阳商业银行";
map["YBCCB"] = "宜宾市商业银行";
map["SCRCU"] = "四川省农村信用";
map["KLB"] = "昆仑银行";
map["LSBANK"] = "莱商银行";
map["YDRCB"] = "尧都农商行";
map["CCQTGB"] = "重庆三峡银行";
map["FDB"] = "富滇银行";
map["JSRCU"] = "江苏省农村信用联合社";
map["JNBANK"] = "济宁银行";
map["CMB"] = "招商银行";
map["JINCHB"] = "晋城银行JCBANK";
map["FXCB"] = "阜新银行";
map["WHRCB"] = "武汉农村商业银行";
map["HBYCBANK"] = "湖北银行宜昌分行";
map["TZCB"] = "台州银行";
map["TACCB"] = "泰安市商业银行";
map["XCYH"] = "许昌银行";
map["CEB"] = "中国光大银行";
map["NXBANK"] = "宁夏银行";
map["HSBANK"] = "徽商银行";
map["JJBANK"] = "九江银行";
map["NHQS"] = "农信银清算中心";
map["MTBANK"] = "浙江民泰商业银行";
map["LANGFB"] = "廊坊银行";
map["ASCB"] = "鞍山银行";
map["KSRB"] = "昆山农村商业银行";
map["YXCCB"] = "玉溪市商业银行";
map["DLB"] = "大连银行";
map["DRCBCL"] = "东莞农村商业银行";
map["GCB"] = "广州银行";
map["NBBANK"] = "宁波银行";
map["BOYK"] = "营口银行";
map["SXRCCU"] = "陕西信合";
map["GLBANK"] = "桂林银行";
map["BOQH"] = "青海银行";
map["CDRCB"] = "成都农商银行";
map["QDCCB"] = "青岛银行";
map["HKBEA"] = "东亚银行";
map["HBHSBANK"] = "湖北银行黄石分行";
map["WZCB"] = "温州银行";
map["TRCB"] = "天津农商银行";
map["QLBANK"] = "齐鲁银行";
map["GDRCC"] = "广东省农村信用社联合社";
map["ZJTLCB"] = "浙江泰隆商业银行";
map["GZB"] = "赣州银行";
map["GYCB"] = "贵阳市商业银行";
map["CQBANK"] = "重庆银行";
map["DAQINGB"] = "龙江银行";
map["CGNB"] = "南充市商业银行";
map["SCCB"] = "三门峡银行";
map["CSRCB"] = "常熟农村商业银行";
map["SHBANK"] = "上海银行";
map["JLBANK"] = "吉林银行";
map["CZRCB"] = "常州农村信用联社";
map["BANKWF"] = "潍坊银行";
map["ZRCBANK"] = "张家港农村商业银行";
map["FJHXBC"] = "福建海峡银行";
map["ZJNX"] = "浙江省农村信用社联合社";
map["LZYH"] = "兰州银行";
map["JSB"] = "晋商银行";
map["BOHAIB"] = "渤海银行";
map["CZCB"] = "浙江稠州商业银行";
map["YQCCB"] = "阳泉银行";
map["SJBANK"] = "盛京银行";
map["XABANK"] = "西安银行";
map["BSB"] = "包商银行";
map["JSBANK"] = "江苏银行";
map["FSCB"] = "抚顺银行";
map["HNRCU"] = "河南省农村信用";
map["COMM"] = "交通银行";
map["CITIC"] = "中信银行";
map["XTB"] = "邢台银行";
map["HXBANK"] = "华夏银行";
map["HNRCC"] = "湖南省农村信用社";
map["DYCCB"] = "东营市商业银行";
map["ORBANK"] = "鄂尔多斯银行";
map["BJRCB"] = "北京农村商业银行";
map["XYBANK"] = "信阳银行";
map["ZGCCB"] = "自贡市商业银行";
map["CDCB"] = "成都银行";
map["HANABANK"] = "韩亚银行";
map["CMBC"] = "中国民生银行";
map["LYBANK"] = "洛阳银行";
map["GDB"] = "广东发展银行";
map["ZBCB"] = "齐商银行";
map["CBKF"] = "开封市商业银行";
map["H3CB"] = "内蒙古银行";
map["CIB"] = "兴业银行";
map["CRCBANK"] = "重庆农村商业银行";
map["SZSBK"] = "石嘴山银行";
map["DZBANK"] = "德州银行";
map["SRBANK"] = "上饶银行";
map["LSCCB"] = "乐山市商业银行";
map["JXRCU"] = "江西省农村信用";
map["ICBC"] = "中国工商银行";
map["JZBANK"] = "晋中市商业银行";
map["HZCCB"] = "湖州市商业银行";
map["NHB"] = "南海农村信用联社";
map["XXBANK"] = "新乡银行";
map["JRCB"] = "江苏江阴农村商业银行";
map["YNRCC"] = "云南省农村信用社";
map["ABC"] = "中国农业银行";
map["GXRCU"] = "广西省农村信用";
map["PSBC"] = "中国邮政储蓄银行";
map["BZMD"] = "驻马店银行";
map["ARCU"] = "安徽省农村信用社";
map["GSRCU"] = "甘肃省农村信用";
map["LYCB"] = "辽阳市商业银行";
map["JLRCU"] = "吉林农信";
map["URMQCCB"] = "乌鲁木齐市商业银行";
map["XLBANK"] = "中山小榄村镇银行";
map["CSCB"] = "长沙银行";
map["JHBANK"] = "金华银行";
map["BHB"] = "河北银行";
map["NBYZ"] = "鄞州银行";
map["LSBC"] = "临商银行";
map["BOCD"] = "承德银行";
map["SDRCU"] = "山东农信";
map["NCB"] = "南昌银行";
map["TCCB"] = "天津银行";
map["WJRCB"] = "吴江农商银行";
map["CBBQS"] = "城市商业银行资金清算中心";
map["HBRCU"] = "河北省农村信用社";
return map[bankId];
}
}
@@ -0,0 +1,177 @@
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/config/address.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/net/http_manager.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
import '../../make_money/alipay_ccdcapi_model.dart';
class AddBankDialog extends StatefulWidget {
const AddBankDialog({super.key});
@override
State<AddBankDialog> createState() => _AddBankDialogState();
}
class _AddBankDialogState extends State<AddBankDialog> {
late final bankNameCtr = TextEditingController();
late final bankNoCtr = TextEditingController();
@override
Widget build(BuildContext context) {
return CommonDialog(
canTapClose: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'添加银行卡',
style: TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.w500),
),
12.sizeBoxH,
Divider(height: .5, color: Colors.white.withValues(alpha: .1)),
12.sizeBoxH,
Container(
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 16),
child: TextField(
maxLength: 20,
controller: bankNameCtr,
style: TextStyle(color: Colors.white),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'[a-zA-Z\u4E00-\u9F5a]'))
],
decoration: InputDecoration(
border: InputBorder.none,
counterText: '',
contentPadding: EdgeInsets.zero,
isDense: true,
hintText: '请输入姓名',
hintStyle: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
),
),
),
),
12.sizeBoxH,
Container(
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.centerLeft,
padding: EdgeInsets.symmetric(horizontal: 16),
child: TextField(
maxLength: 20,
controller: bankNoCtr,
style:
TextStyle(color: Colors.white, fontWeight: FontWeight.w400),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9]'))
],
keyboardType: TextInputType.number,
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请输入银行卡号',
counterText: '',
contentPadding: EdgeInsets.zero,
isDense: true,
hintStyle: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
),
),
),
),
24.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () {
if (bankNoCtr.text.isEmpty) {
showToast('请输入银行卡号');
return;
}
if (bankNameCtr.text.isEmpty) {
showToast('请输入持卡人姓名');
return;
}
_bankNumberVerifyReq();
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: Text(
"立即提交",
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
)
],
),
);
}
///校验银行卡信息
void _bankNumberVerifyReq() async {
String bankNum = bankNoCtr.text.trim();
if (bankNum.isEmpty || bankNum.length < 13) {
showToast("银行卡号错误");
return;
}
LoadingHelper.showLoading();
httpManager.fetchResponseByGET(Address.aliCcdApi + bankNum).then((ret) {
LoadingHelper.dismissLoading();
if (!ret.isSuccess) {
showToast("银行卡验证失败.");
return;
}
if (ret.isSuccess) {
final model = ApcApiModel.fromMap(Map<String, dynamic>.from(ret.data));
if (model?.validated ?? false) {
// model.bank, model.cardType
debugLog("model.bank-->", "${model?.bank}");
debugLog("model.cardType-->", "${model?.cardType}");
// _commonWithdrawReq(true, bank: model?.bank);
_addBankCard(model!);
} else {
showToast("银行卡验证失败");
}
} else {
showToast("银行卡验证失败");
}
});
}
_addBankCard(ApcApiModel model) async {
final res = await MineService.addBankCard(
model.key, bankNameCtr.text, model.bank, model.cardType);
if (res) {
Get.back(result: true);
}
}
}
@@ -0,0 +1,73 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import '../../../tools_base/event_bus/event_bus_util.dart';
import '../../../tools_base/event_bus/events.dart';
import 'bing_phone_page.dart';
class BindPhoneLogic extends GetxController {
late final phoneCtr = TextEditingController();
late final codeCtr = TextEditingController();
late final countDownNof = ValueNotifier(-1);
Timer? _timer;
final PhonePageType pageType;
BindPhoneLogic(this.pageType);
@override
onClose() {
super.onClose();
_timer?.cancel();
}
onSendCode() async {
if (_timer != null) return;
if (phoneCtr.text.isEmpty || phoneCtr.text.length < 11) {
showToast('请输入正确的手机号~');
return;
}
final res = await MineService.postCaptchaCode(phoneCtr.text, pageType.type);
if (res) {
_timer?.cancel();
_timer = Timer.periodic(Duration(seconds: 1), (timer) {
countDownNof.value += 1;
if (countDownNof.value == 60) {
_timer?.cancel();
_timer = null;
countDownNof.value = -1;
}
});
}
}
onBindPhone() async {
if (phoneCtr.text.isEmpty) {
showToast('请输入正确的手机号~');
return;
}
if (codeCtr.text.isEmpty) {
showToast('请输入验证码~');
return;
}
if (pageType == PhonePageType.bind) {
final res = await MineService.bindPhone(phoneCtr.text, codeCtr.text);
if (res) {
showToast('绑定成功');
globalStore.updateUserInfo();
Get.back();
}
} else {
final result =
await globalStore.loginByMobile(phoneCtr.text, codeCtr.text);
if (result != null) {
showToast('登录成功');
eventBus.emit(ReLoginEvent());
}
}
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'bind_phone_logic.dart';
enum PhonePageType {
bind('绑定手机', 1, '立即绑定'),
find('找回账号', 2, '立即找回');
final int type;
final String title;
final String buttonTitle;
const PhonePageType(this.title, this.type, this.buttonTitle);
}
class BindPhonePage extends StatelessWidget {
final PhonePageType pageType;
const BindPhonePage({super.key, this.pageType = PhonePageType.bind});
@override
Widget build(BuildContext context) {
return GetBuilder<BindPhoneLogic>(
init: BindPhoneLogic(pageType),
builder: (controller) => Scaffold(
appBar: AppBar(
title: Text(controller.pageType.title),
),
body: Padding(
padding: EdgeInsets.only(left: 16, right: 16, top: 47),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: double.infinity,
height: 42,
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
'手机号',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16),
),
12.sizeBoxW,
Expanded(
child: TextField(
controller: controller.phoneCtr,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
),
maxLength: 11,
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请输入手机号码',
isCollapsed: true,
contentPadding: EdgeInsets.zero,
counterText: '',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14)),
),
)
],
),
),
10.sizeBoxH,
Divider(
height: .5,
color: Colors.white.withValues(alpha: .05),
),
30.sizeBoxH,
Container(
width: double.infinity,
height: 42,
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
'验证码',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16),
),
12.sizeBoxW,
Expanded(
child: TextField(
controller: controller.codeCtr,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
),
maxLength: 6,
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请输入手机验证码',
isCollapsed: true,
contentPadding: EdgeInsets.zero,
counterText: '',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14)),
),
),
12.sizeBoxW,
GestureDetector(
onTap: () => controller.onSendCode(),
child: ValueListenableBuilder(
valueListenable: controller.countDownNof,
builder:
(BuildContext context, int value, Widget? child) {
final iscountdown = value > -1;
return Container(
padding: EdgeInsets.symmetric(
horizontal: 4, vertical: 4),
child: Text(
iscountdown ? '${60 - value}' : '获取验证码',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12),
),
);
},
),
)
],
),
),
10.sizeBoxH,
Divider(
height: .5,
color: Colors.white.withValues(alpha: .05),
),
30.sizeBoxH,
GestureDetector(
onTap: () => controller.onBindPhone(),
child: Container(
width: double.infinity,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
child: Text(
controller.pageType.buttonTitle,
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500),
),
),
)
],
),
),
),
);
}
}
@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
/// 绑定邀请码逻辑
class InviteBindLogic extends GetxController {
// 邀请码输入框
final codeCtr = TextEditingController();
/// 立即绑定
void bind() async {
if (codeCtr.text.isEmpty) {
showToast('请输入邀请码~');
return;
}
final res = await MineService.exchangeInviteCode(codeCtr.text);
if (res) {
showToast('绑定成功');
globalStore.updateUserInfo();
Get.back();
} else {
showToast('绑定失败');
}
}
@override
void onClose() {
codeCtr.dispose();
super.onClose();
}
}
@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'invite_bind_logic.dart';
/// 绑定邀请码页
class InviteBindPage extends StatelessWidget {
const InviteBindPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<InviteBindLogic>(
init: InviteBindLogic(),
builder: (controller) => Scaffold(
appBar: AppBar(
title: Text('绑定邀请码'),
),
body: Padding(
padding: EdgeInsets.only(left: 28, right: 28, top: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Text(
'输入邀请码',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 30,
fontWeight: FontWeight.w500),
),
12.sizeBoxH,
Text(
'邀请码只能绑定一次 且不能修改',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.35),
fontSize: 16,
fontWeight: FontWeight.w400,
),
),
30.sizeBoxH,
// 邀请码输入
SizedBox(
width: double.infinity,
height: 42,
child: Row(
children: [
Text(
'邀请码',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16),
),
24.sizeBoxW,
Expanded(
child: TextField(
controller: controller.codeCtr,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
),
maxLength: 11,
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请输入邀请码(字母大写)',
isCollapsed: true,
contentPadding: EdgeInsets.zero,
counterText: '',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.35),
fontSize: 14)),
),
)
],
),
),
10.sizeBoxH,
Divider(
height: .35,
color: Colors.white.withValues(alpha: .05),
),
18.sizeBoxH,
Text(
'邀请1人 获得1天会员',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.35), fontSize: 14),
),
30.sizeBoxH,
// 立即绑定按钮
GestureDetector(
onTap: controller.bind,
child: Container(
width: double.infinity,
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
child: Text(
'立即绑定',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500),
),
),
)
],
),
),
),
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/mine/exchange_record_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class MineExchangeCodeLogic extends GetxController {
final int type; //0-填写邀请码 1-填写兑换码
TextEditingController controller = TextEditingController();
List<ExchangeRecordModel> groupList = [];
bool isLoadingHistoryData = true;
RefreshController? refreshController; //由 pullYsRefresh 的 onInit 注入,组件负责释放
int currentPage = 1;
MineExchangeCodeLogic({this.type = 0});
@override
onReady() {
super.onReady();
if (type == 1) loadData();
}
loadData({int page = 1}) async {
try {
final res = await MineService.getExchangeRecord(page, 20);
if (res != null && res.data != null) {
if (page == 1) groupList.clear();
currentPage = page;
groupList.addAll(res.data ?? []);
}
refreshController?.refreshCompleted();
(res?.total ?? 0) > (res?.data?.length ?? 0)
? refreshController?.loadComplete()
: refreshController?.loadNoData();
} catch (e) {
refreshController?.refreshCompleted();
refreshController?.loadComplete();
debugLog(e);
}
update();
isLoadingHistoryData = false;
}
loadMoreData() => loadData(page: currentPage + 1);
void onExChangeCode() async {
if (controller.text.isEmpty) {
showToast('请输入${type == 0 ? '邀请码' : '兑换码'}');
return;
}
if (type == 1) {
try {
LoadingAlertWidget.show();
bool ret = await MineService.postExchangeCode(controller.text);
LoadingAlertWidget.cancel();
if (ret == true) {
controller.text = "";
globalStore.updateUserInfo();
showToast("兑换成功");
loadData();
}
} catch (e) {
LoadingAlertWidget.cancel();
debugLog(e);
}
} else if (type == 0) {
try {
LoadingAlertWidget.show();
bool ret = await MineService.getProxyBind(controller.text);
LoadingAlertWidget.cancel();
if (ret == true) {
globalStore.meInfo?.inviterCode = controller.text;
globalStore.updateUserInfo();
showToast("绑定成功");
Get.back(result: true);
}
} catch (e) {
LoadingAlertWidget.cancel();
debugLog(e);
}
}
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.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/mine/exchange_record_model.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 'mine_code_logic.dart';
//邀请码和兑换码
class MineExchangeCodePage extends StatefulWidget {
final int type; //0-填写邀请码 1-填写兑换码
const MineExchangeCodePage({super.key, this.type = 0});
@override
State<MineExchangeCodePage> createState() => _MineExchangeCodePageState();
}
class _MineExchangeCodePageState extends State<MineExchangeCodePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.type == 0 ? '邀请码' : '领取兑换')),
body: GetBuilder<MineExchangeCodeLogic>(
init: MineExchangeCodeLogic(type: widget.type),
builder: (_) => Padding(
padding: EdgeInsets.symmetric(vertical: 28, horizontal: 16),
child: Column(
children: [
Expanded(
child: pullYsRefresh(
onInit: (controller) => _.refreshController = controller,
onRefresh: (controller) => _.loadData(),
onLoading: (controller) => _.loadMoreData(),
child: CustomScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
slivers: <Widget>[
SliverToBoxAdapter(
child: _buildContent(_),
),
if (widget.type == 1) ...[
_buildHistoryTable(_),
]
],
),
),
),
InkWell(
enableFeedback: false,
onTap: () => _.onExChangeCode(),
child: Container(
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(3)),
color: AppColors.actionRed,
),
child: Center(
child: Text(
"立即兑换",
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
),
),
],
)),
),
);
}
Widget _buildHistoryTable(MineExchangeCodeLogic logic) {
if (logic.isLoadingHistoryData) {
return SliverToBoxAdapter(child: LoadingCenterWidget());
} else if (logic.groupList.isEmpty) {
return SliverToBoxAdapter(
child: CErrorWidget(errorMsg: "暂无兑换记录"),
);
} else {
return SliverList.separated(
itemBuilder: (context, index) {
return _buildListItem(logic.groupList[index]);
},
itemCount: logic.groupList.length,
separatorBuilder: (context, index) {
return Divider(
height: 1,
color: Colors.black87.withValues(alpha: 0.1),
);
},
);
}
}
_buildContent(MineExchangeCodeLogic _) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"输入兑换码",
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
fontSize: 30,
),
),
12.sizeBoxH,
Text(
"每个兑换码只能输入一次",
style: TextStyle(
color: Color(0xff525252),
fontWeight: FontWeight.w500,
fontSize: 16,
),
),
56.sizeBoxH,
Container(
height: 42,
child: Row(
children: [
Text(
"兑换码",
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
16.sizeBoxW,
Expanded(
child: TextField(
keyboardType: TextInputType.text,
autofocus: true,
autocorrect: true,
cursorColor: Colors.white,
textAlign: TextAlign.left,
controller: _.controller,
style: TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: '请输入${widget.type == 0 ? '邀请码' : '兑换码'}(字母大写)',
hintStyle: TextStyle(color: Color(0xff434c55)),
border: InputBorder.none,
),
),
)
],
)),
12.sizeBoxH,
1.line,
18.sizeBoxH,
Text(
"官方社群领取更多福利",
style: TextStyle(
color: Color(0xff525252),
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
36.sizeBoxH,
Text(
"兑换记录",
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w600,
fontSize: 18,
),
),
12.sizeBoxH,
Row(
children: [
Expanded(child: _buildSectionItem('兑换码')),
5.sizeBoxW,
Expanded(child: _buildSectionItem('兑换类型')),
5.sizeBoxW,
Expanded(child: _buildSectionItem('兑换时间')),
],
),
],
);
}
_buildSectionItem(String title) {
return Container(
alignment: Alignment.center,
height: 42,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
),
child: Text(
title,
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w400,
fontSize: 14,
),
),
);
}
Widget _buildListItem(ExchangeRecordModel item) {
return Container(
height: 44,
child: Row(
children: [
Flexible(
child: Container(
height: 44,
alignment: Alignment.center,
child: Text(
item.code ?? '',
style: TextStyle(
color: Colors.white.withValues(alpha: .55), fontSize: 14),
),
),
),
Flexible(
child: Container(
height: 44,
alignment: Alignment.center,
child: Text(
item.desc ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: .55), fontSize: 14),
),
),
),
Flexible(
child: Container(
height: 44,
alignment: Alignment.center,
child: Text(
'${item.createdAt.utcToYMD(gap: '.')}',
style: TextStyle(
color: Colors.white.withValues(alpha: .55), fontSize: 14),
),
),
),
],
),
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_utils/permission_util.dart';
import '../../../routers/jump_router.dart';
import 'bing_phone_page.dart';
import 'mine_scan_login_page.dart';
class MineFindAccountPage extends StatelessWidget {
late final dataSource = [
{
'title': '手机号找回',
'ontap': () =>
Get.to(() => const BindPhonePage(pageType: PhonePageType.find)),
},
{
'title': '凭证找回',
'ontap': () async {
if (await PermissionUtil.checkCameraPermission()) {
Get.to(() => const MineScanLoginPage());
}
}
},
{
'title': '联系客服',
'ontap': () => pushToCustomService(),
}
];
MineFindAccountPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(elevation: 0, title: Text('找回账号')),
body: Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(8),
),
child: ListView.separated(
physics: NeverScrollableScrollPhysics(),
itemCount: dataSource.length,
shrinkWrap: true,
separatorBuilder: (_, __) => Divider(
height: 0.5,
color: Colors.white.withValues(alpha: .1),
),
itemBuilder: (BuildContext context, int index) {
final data = dataSource[index];
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: data['ontap'] as Function()?,
child: Column(
children: [
16.sizeBoxH,
Row(
children: [
Text(
(data['title'] ?? '').toString(),
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.9)),
),
Spacer(),
Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: Color(0xFFDCDCDC),
)
],
),
16.sizeBoxH,
],
),
);
},
),
)
],
),
),
);
}
}
@@ -0,0 +1,39 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_page/splash/splash_page.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/toast.dart';
/// 手势密码用途:设置 / 关闭 / 进 app 解锁校验
enum MinePasswordType { setting, close, check }
class MinePasswordLogic extends GetxController {
//本页是中间件 redirect 的目标(RouteSettings 只能带 arguments),没法改构造传参;
//用 is 判断而不是隐式强转:路由栈里读到别的页的 arguments 时只降级不抛 TypeError
final MinePasswordType type = _typeFromArgs();
static MinePasswordType _typeFromArgs() {
final args = Get.arguments;
return args is MinePasswordType ? args : MinePasswordType.setting;
}
bool get isCheck => type == MinePasswordType.check;
String get title => isCheck ? '请输入解锁密码' : '请绘制锁屏图形';
/// 画完手势:设置/关闭成功即退出本页,解锁通过则回启动页重走流程
Future<void> onComplete(List<int> result) async {
if (result.length < 4) {
showToast('请至少链接4个点');
return;
}
switch (type) {
case MinePasswordType.setting:
if (await globalStore.setLockPassword(result) == true) Get.back();
case MinePasswordType.close:
if (await globalStore.closeLockPassword(result) == true) Get.back();
case MinePasswordType.check:
if (await globalStore.checkLockPassword(result) == true)
Get.toNamed(SplashPage.routeName);
}
}
}
@@ -0,0 +1,61 @@
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 '../mine_gesture_pwd/widget/gesture_password_widget.dart';
import 'mine_password_logic.dart';
/// 手势密码页:设置 / 关闭 / 解锁校验共用,用途由路由 arguments 传 [MinePasswordType]
class MinePasswordPage extends StatelessWidget {
static const routeName = '/MinePasswordPage';
const MinePasswordPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<MinePasswordLogic>(
init: MinePasswordLogic(),
//必须 falsetype 来自路由 arguments,全局注册会让「关闭」复用上一次「设置/解锁」残留的 logic
global: false,
builder: (logic) => Scaffold(
//解锁校验时还进不了 app,不给返回入口
appBar: logic.isCheck ? null : AppBar(title: const Text('手势密码')),
//必须给满宽约束:Column 横向是按最宽的子节点收缩的(这里 250),而 Scaffold 的 body
//是贴 (0,0) 摆放的,不撑满就整块贴左边;撑满后 Column 默认的居中对齐才真正生效
body: SizedBox(
width: double.infinity,
child: Column(
children: [
//没有 AppBar 顶着,用锁图标占位
if (logic.isCheck) ...[
65.sizeBoxH,
Image.asset('mine_lock_password.png'.mineImgPath, width: 29),
],
55.sizeBoxH,
Text(
logic.title,
style: TextStyle(
color: Colors.black.withValues(alpha: 0.9), fontSize: 18),
),
75.sizeBoxH,
GesturePasswordWidget(
lineColor: const Color(0xFFF52C56),
errorLineColor: const Color(0xffDD001B),
singleLineCount: 3,
identifySize: 60.0,
size: 250,
minLength: 4,
errorItem: Image.asset('error.webp'.mineImgPath,
color: const Color(0xFFF52C56)),
selectedItem: Image.asset('select.png'.mineImgPath,
color: const Color(0xFFF52C56)),
normalItem: Image.asset('normal.png'.mineImgPath),
onComplete: logic.onComplete,
),
],
),
),
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_pickers/image_pickers.dart';
import 'package:hgdj/hj_utils/image_util.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/net/net_manager.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
import '../identity/mine_identity_page.dart';
class MineScanLginLogic extends GetxController {
QRViewController? scanController;
@override
void onClose() {
scanController?.dispose();
super.onClose();
}
onQRViewCreated(QRViewController controller) {
scanController = controller;
scanController?.scannedDataStream.listen((scanData) {
print('扫码结果:${scanData.code}');
loginByQrValue(scanData.code ?? '');
});
}
/// 开启本地相册
openNativePhoto() async {
// image_picker 走系统相册 intent,选图不需要存储权限,直接调起
final images = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
selectCount: 1,
showCamera: false,
cropConfig: CropConfig(enableCrop: false),
);
if (images.isNotEmpty) {
final qrValue = await ImageUtil.decodeQr(images[0].path ?? '');
if (qrValue == null || qrValue.isEmpty) {
showToast('二维码错误~~');
return;
}
loginByQrValue(qrValue);
}
}
/// 是否正在处理一次扫码登录。扫码流会按摄像头帧连续 emit,pauseCamera 异步拦不住已缓冲的帧,
/// 不加锁会并发触发 N 次 loginByQr → token 被反复清空/轮换,服务端单会话把旧 token 全废,
/// 满屏 5009/5005「用户信息已经过期」,最终 token 落空、换号失败。
bool _isHandling = false;
/// 开始二维码登录
loginByQrValue(String qrValue) async {
if (_isHandling) return; // 一次扫码只处理一次,挡掉连续帧/重复触发
if (qrValue.isEmpty) {
showToast("二维码为空");
return;
}
_isHandling = true;
scanController?.pauseCamera();
LoadingHelper.showLoading();
var userInfo = await globalStore.loginByQr(qrValue);
LoadingHelper.dismissLoading();
// 必须拿到 token 才算成功:只有 uid 没 token 会留下空 token,下一个请求立刻被判过期
if (userInfo?.uid == null || (userInfo?.token ?? '').isEmpty) {
showToast("切换账号失败");
scanController?.resumeCamera();
_isHandling = false; // 失败放开,允许重试
return;
}
// 刷新ua
netManager.refreshUserAgent();
showToast('登录成功');
eventBus.emit(ReLoginEvent());
}
/// 跳转凭证
jumpToCertificate() => Get.to(MineAccountIdentityPage());
}
@@ -0,0 +1,98 @@
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:qr_code_scanner/qr_code_scanner.dart';
import 'mine_scan_login_logic.dart';
class MineScanLoginPage extends StatefulWidget {
const MineScanLoginPage({super.key});
@override
State<MineScanLoginPage> createState() => _MineScanLoginPageState();
}
class _MineScanLoginPageState extends State<MineScanLoginPage> {
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
@override
Widget build(BuildContext context) {
return GetBuilder<MineScanLginLogic>(
init: MineScanLginLogic(),
builder: (controller) => Scaffold(
extendBodyBehindAppBar: true,
appBar: AppBar(
elevation: 0,
iconTheme: IconThemeData(color: Colors.white),
backgroundColor: Colors.transparent,
title: Text(
'扫码登录',
style: TextStyle(
color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500),
),
),
body: Stack(
children: [
QRView(
key: qrKey,
onQRViewCreated: controller.onQRViewCreated,
overlay: QrScannerOverlayShape(
overlayColor: Colors.black,
borderColor: Colors.white,
borderRadius: 0,
borderLength: 20,
borderWidth: 5,
cutOutSize: 235),
),
Positioned(
bottom: 100,
left: 0,
right: 0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: GestureDetector(
onTap: controller.openNativePhoto,
child: Column(
children: [
Image.asset('scan_photo.png'.mineImgPath, width: 48),
12.sizeBoxH,
Text(
'相册',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500),
)
],
),
),
),
Expanded(
child: GestureDetector(
onTap: controller.jumpToCertificate,
child: Column(
children: [
Image.asset('mine_id.png'.mineImgPath, width: 48),
12.sizeBoxH,
Text(
'我的凭证',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500),
)
],
),
),
),
],
),
)
],
),
),
);
}
}
@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/hj_utils/version_util.dart';
import 'package:hgdj/hj_utils/video_cache_manager.dart';
import 'package:hgdj/tools_base/cache/cache_util.dart';
import 'package:hgdj/tools_base/cache/image_cache_manager.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/image/image_data_handle/image_cache_disk.dart';
import 'package:hgdj/tools_base/video_download/video_download_manager.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import 'package:hgdj/tools_base/widget/common_alert.dart';
import '../../../alert/splash/update_dialog.dart';
import '../identity/mine_identity_page.dart';
import '../more_question/mine_qa_page.dart';
import 'bing_phone_page.dart';
import 'invite_bind_page.dart';
import 'mine_find_account_page.dart';
import 'mine_setting_profile_page.dart';
import 'setting_avatar_page.dart';
class MineSettingPage extends StatefulWidget {
const MineSettingPage({super.key});
@override
State<MineSettingPage> createState() => _MineSettingPageState();
}
class _MineSettingPageState extends State<MineSettingPage> {
String cacheSize = ''; // 缓存大小
@override
void initState() {
super.initState();
getCacheSize();
}
Future<void> getCacheSize() async {
try {
cacheSize = await loadCache();
setState(() {});
} catch (e) {
debugPrint(e.toString());
}
}
@override
Widget build(BuildContext context) {
final meInfo = context.watch<GlobalStore>().meInfo;
final mobile = meInfo?.mobile ?? '';
final inviterCode = meInfo?.inviterCode ?? '';
return Scaffold(
appBar: AppBar(title: Text('设置中心')),
body: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(8),
),
margin: EdgeInsets.only(
left: 10,
top: 12,
right: 10,
),
child: ListView(
children: [
_buildItem(
title: '头像',
avatarUrl: meInfo?.portrait ?? '',
onTap: () async {
if (!globalStore.isVIP) {
if (await CommonAlert.show(
content: '您还不是VIP无法修改头像',
subContent: '开通会员 即可解锁继续',
showCancel: false,
confirmText: '开通会员',
)) {
pushToWalletPage();
}
return;
}
Get.to(() => const SettingAvatarPage());
}),
_buildLine(),
_buildItem(
title: '昵称',
subTitle: meInfo?.name,
onTap: () async {
if (!globalStore.isVIP) {
if (await CommonAlert.show(
content: '您还不是VIP无法修改昵称!',
subContent: '开通会员 即可解锁继续',
showCancel: false,
confirmText: '开通会员',
)) {
pushToWalletPage();
}
return;
}
Get.to(() =>
const SettingProfilePage(SettingProfileType.nickName));
}),
_buildLine(),
_buildItem(
title: '${Config.appName}ID',
subTitle: meInfo?.uid.toString(),
showCopy: true,
onTap: () {}),
_buildLine(),
_buildItem(
title: '手机号码',
subTitle: mobile.isEmpty ? '立即绑定' : mobile,
onTap: () {
Get.to(() => const BindPhonePage());
}),
_buildLine(),
_buildItem(
title: '账号找回',
onTap: () {
Get.to(() => MineFindAccountPage());
}),
_buildLine(),
_buildItem(
title: '邀请码',
subTitle: inviterCode.isEmpty ? '未设置' : inviterCode,
onTap: () {
if (inviterCode.isEmpty) {
Get.to(() => const InviteBindPage());
} else {
showToast('您已绑定过邀请码');
}
}),
_buildLine(),
_buildItem(
title: '账号凭证',
onTap: () {
Get.to(() => MineAccountIdentityPage());
}),
_buildLine(),
_buildItem(
title: '常见问题',
onTap: () {
Get.to(() => MineQAPage());
}),
_buildLine(),
_buildItem(
title: '清除缓存',
subTitle: cacheSize,
onTap: () async {
await VideoDownloadManager.instance.emptyCache();
await ImageCacheDisk.emptyCache();
await VideoCacheManager().emptyCache();
await ImageCacheManager().emptyCache();
cacheSize = "0.0KB";
if (mounted) setState(() {});
showToast('清理缓存成功');
}),
_buildLine(),
_buildItem(
title: '检查更新',
subTitle: 'V${Config.innerVersion}',
onTap: () {
//比对启动页拉到的版本配置,确实有新版才弹更新框
final target = checkUpdate();
target == null
? showToast("当前已是最新版!")
: UpdateDialog.show(target);
}),
_buildLine(),
],
),
),
);
}
Widget _buildLine() {
return Padding(padding: EdgeInsets.only(left: 16), child: .5.line);
}
Widget _buildItem({
String title = '',
String? subTitle,
bool showCopy = false,
String? avatarUrl,
VoidCallback? onTap,
}) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onTap,
child: Container(
height: 56,
padding: EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
Text(
title,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9), fontSize: 14),
),
Spacer(),
if (avatarUrl != null)
NetworkImageLoader(
imageUrl: avatarUrl,
width: 24,
height: 24,
borderRadius: 12,
),
if (subTitle != null)
Text(
subTitle,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45), fontSize: 12),
),
if (showCopy) ...[
4.sizeBoxW,
GestureDetector(
onTap: () {
Clipboard.setData(
ClipboardData(text: '${globalStore.meInfo?.uid}'));
showToast('复制成功');
},
child: Image.asset(
'mine_copy.png'.mineImgPath,
width: 24,
),
),
],
if (!showCopy) ...[
10.sizeBoxW,
Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: Color(0xFF70708C),
),
]
],
),
),
);
}
}
@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'mine_setting_profile_page.dart';
import 'setting_profile_widget.dart';
abstract class MineSettingProfilePage extends GetxController {
String get title => '';
late final nickNameTfCtr = TextEditingController();
late final sloganTfCtr = TextEditingController();
onSaveProfile();
instanceChildItem(int index);
@override
void onClose() {
nickNameTfCtr.dispose();
sloganTfCtr.dispose();
super.onClose();
}
}
class SettingNickNameController extends MineSettingProfilePage {
@override
String get title => '修改昵称';
@override
onSaveProfile() async {
if (nickNameTfCtr.text.isEmpty) {
showToast('昵称不能为空~');
return;
}
LoadingHelper.showLoading();
final res = await MineService.updateUserInfo({'name': nickNameTfCtr.text});
LoadingHelper.dismissLoading();
if (res) {
globalStore.updateUserInfo();
Get.back();
}
}
@override
instanceChildItem(int index) {
return SettingNickName();
}
}
class SettingSloganController extends MineSettingProfilePage {
@override
String get title => '个性签名';
@override
onSaveProfile() async {
if (sloganTfCtr.text.isEmpty) {
showToast('请输入个性签名~');
return;
}
LoadingHelper.showLoading();
final res = await MineService.updateUserInfo({'summary': sloganTfCtr.text});
LoadingHelper.dismissLoading();
if (res) {
globalStore.updateUserInfo();
Get.back();
}
}
@override
instanceChildItem(int index) {
return SettingSlogan();
}
}
MineSettingProfilePage instanceSettingProfileController(
SettingProfileType type) {
switch (type) {
case SettingProfileType.nickName:
return SettingNickNameController();
case SettingProfileType.slogan:
return SettingSloganController();
default:
throw '';
}
}
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'mine_setting_profile_logic.dart';
enum SettingProfileType {
nickName,
slogan,
}
class SettingProfilePage extends StatelessWidget {
final SettingProfileType type;
const SettingProfilePage(this.type, {super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<MineSettingProfilePage>(
init: instanceSettingProfileController(type),
builder: (controller) => Scaffold(
appBar: AppBar(
title: Text(controller.title),
actions: [
GestureDetector(
onTap: () => controller.onSaveProfile(),
child: Text(
'保存',
style: TextStyle(color: Color(0xff999999), fontSize: 14),
),
),
16.sizeBoxW
],
),
body: controller.instanceChildItem(0),
),
);
}
}
@@ -0,0 +1,49 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/toast.dart';
class SettingAvatarLogic extends ListBaseLogic<String> {
int selectIndex = -1;
late final int rawIndex;
@override
void onReady() {
super.onReady();
loadData();
}
void loadData() => fetchData(isRefresh: true, fetch: _fetch);
//头像列表无分页,单次拉取,hasNext 固定 false
Future<(List<String>?, bool)> _fetch(int page) async {
final res = await MineService.getPortrait();
selectIndex = res.indexOf(globalStore.meInfo?.portrait ?? '');
rawIndex = selectIndex;
return (res, false);
}
selectAavatar(int index) {
selectIndex = index;
update();
}
confirmChangeAvatar() async {
if (globalStore.meInfo?.urrPortraitStatus == 1) {
showToast('头像审核中~');
return;
}
if (selectIndex == rawIndex || selectIndex == -1) {
showToast('你都没选择,保存什么~');
return;
}
final res = await MineService.updateUserInfo(
{'portrait': dataList![selectIndex], 'isDefaultSource': true});
if (res) {
globalStore.updateUserInfo();
showToast('更新成功');
Get.back(result: true);
}
}
}
@@ -0,0 +1,101 @@
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_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import 'setting_avatar_logic.dart';
class SettingAvatarPage extends StatelessWidget {
const SettingAvatarPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<SettingAvatarLogic>(
init: SettingAvatarLogic(),
builder: (controller) => Scaffold(
appBar: AppBar(
title: Text('选择头像'),
actions: [
InkWell(
enableFeedback: false,
onTap: controller.confirmChangeAvatar,
child: Text(
'保存',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55), fontSize: 12),
),
),
16.sizeBoxW,
],
),
body: () {
if (controller.isLoading) return LoadingCenterWidget();
if (controller.isEmptyData) return CErrorWidget();
final list = controller.dataList!;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
30.verticalSpace,
NetworkImageLoader(
imageUrl: context.watch<GlobalStore>().meInfo?.portrait ?? '',
width: 90,
height: 90,
borderRadius: 45,
),
10.verticalSpace,
Text(
'当前头像',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55), fontSize: 12),
),
20.verticalSpace,
Expanded(
child: GridView.builder(
padding: EdgeInsets.only(left: 38, right: 38, top: 0),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 20,
crossAxisSpacing: 20,
childAspectRatio: 1,
),
itemCount: list.length,
itemBuilder: (BuildContext context, int index) {
final select = controller.selectIndex == index;
return GestureDetector(
onTap: () => controller.selectAavatar(index),
child: Stack(
children: [
NetworkImageLoader(
imageUrl: list[index],
width: double.infinity,
height: double.infinity,
borderRadius: 100,
),
if (select)
Align(
alignment: Alignment.bottomRight,
child: Image.asset(
'red_checked.png'.mineImgPath,
width: 22,
height: 22,
),
)
],
),
);
},
),
),
12.sizeBoxH,
],
);
}(),
),
);
}
}
@@ -0,0 +1,136 @@
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/global_store/store.dart';
import 'mine_setting_profile_logic.dart';
class SettingNickName extends StatefulWidget {
const SettingNickName({super.key});
@override
State<SettingNickName> createState() => _SettingNickNameState();
}
class _SettingNickNameState extends State<SettingNickName> {
late final controller = Get.find<MineSettingProfilePage>();
@override
void initState() {
super.initState();
controller.nickNameTfCtr.text = globalStore.meInfo?.name ?? '';
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
60.sizeBoxH,
Row(
children: [
Expanded(
child: TextField(
controller: controller.nickNameTfCtr,
maxLength: 20,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9), fontSize: 18),
decoration: InputDecoration(
border: InputBorder.none,
hintText: '输入新的昵称',
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.35),
fontSize: 18),
counterText: '',
isCollapsed: true,
contentPadding: EdgeInsets.zero),
),
),
InkWell(
enableFeedback: false,
onTap: () => controller.nickNameTfCtr.clear(),
child: Container(
padding: EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(90)),
child: Image.asset(
'close_button.png'.commonImgPath,
width: 24,
)),
)
],
),
12.sizeBoxH,
0.5.line,
12.sizeBoxH,
Text(
'注意*诱导性昵称会被投诉封号',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.35), fontSize: 12),
)
],
),
);
}
}
class SettingSlogan extends StatefulWidget {
const SettingSlogan({super.key});
@override
State<SettingSlogan> createState() => _SettingSloganState();
}
class _SettingSloganState extends State<SettingSlogan> {
late final controller = Get.find<MineSettingProfilePage>();
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(left: 16, top: 12, right: 16),
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .04),
borderRadius: BorderRadius.circular(8)),
constraints: BoxConstraints(
minHeight: 111,
),
child: Stack(
children: [
TextField(
maxLines: 10,
style: const TextStyle(color: Color(0xff333333), fontSize: 12),
maxLength: 150,
controller: controller.sloganTfCtr,
decoration: InputDecoration(
border: InputBorder.none,
hintText: '有趣的介绍能让你的逼格提高N个档次!...',
hintStyle: TextStyle(color: Color(0xff999999), fontSize: 12),
counterText: '',
contentPadding: EdgeInsets.zero,
isDense: true),
),
Positioned(
bottom: 0,
right: 0,
// 字数计数:只监听不接管所有权(ChangeNotifierProvider(create:) 会把 ctr 一起 dispose
// 而它归 MineSettingProfilePage.onClose 释放 → 二次释放)
child: ValueListenableBuilder(
valueListenable: controller.sloganTfCtr,
builder: (_, value, __) => Text(
'${value.text.length}/150',
style: TextStyle(
color: Color(0xff666666),
fontSize: 12,
),
),
),
)
],
),
);
}
}
@@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/config/address.dart';
import 'package:hgdj/config/config.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../hj_utils/image_util.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import 'mine_share_record_page.dart';
//分享邀请
class MineSharePage extends StatelessWidget {
// 该页只通过 Get.to 作为路由根打开,实例稳定不会被父级 rebuild,
// boundaryKey 字段只创建一次,RepaintBoundary 截图正常
final GlobalKey boundaryKey = GlobalKey();
MineSharePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("邀请分享"),
actions: [
InkWell(
enableFeedback: false,
onTap: () {
Get.to(() => MineShareRecordPage());
},
child: Text(
"记录",
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontSize: 12,
),
),
),
16.sizeBoxW,
],
),
body: Container(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
// 用 FittedBox 完整渲染卡片,不放进 SingleChildScrollView
// RepaintBoundary 在可滚动容器内时,toImage 受 Viewport 裁剪影响会截图残缺/失败
child: Center(
child: FittedBox(
fit: BoxFit.contain,
child: RepaintBoundary(
key: boundaryKey,
child: Container(
margin: EdgeInsets.only(top: 30),
padding: EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: 300,
height: 550,
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(9),
child: Image.asset('share_bg.webp'.mineImgPath),
),
// 整张卡片统一响应 meInfo 变化:头像/邀请码/二维码同源刷新
Consumer<GlobalStore>(
builder: (_, provider, __) {
final me = provider.meInfo;
return Column(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
30.sizeBoxH,
Container(
decoration: BoxDecoration(
borderRadius:
BorderRadius.circular(45),
border: Border.all(
color: Color(0x4DF68804),
width: 3),
),
child: NetworkImageLoader(
imageUrl: me?.portrait ?? '',
width: 82,
height: 82,
borderRadius: 45),
),
10.sizeBoxH,
Text(
'我的邀请码',
style: TextStyle(
color: Colors.white
.withValues(alpha: .9),
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
10.sizeBoxH,
Text(
me?.promotionCode ?? "",
style: TextStyle(
color: Colors.white
.withValues(alpha: .55),
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
30.sizeBoxH,
Text("每邀请3人,送3天VIP",
style: TextStyle(
fontSize: 14,
color: Color(0xFFF68804))),
10.sizeBoxH,
Row(
children: [
Spacer(),
Center(
child: Container(
padding: EdgeInsets.all(11.w),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage(
"code_bg.webp"
.mineImgPath),
fit: BoxFit.fill,
),
),
child: Container(
padding: EdgeInsets.all(6.w),
color: Colors.white,
child: QrImageView(
data: me?.promoteURL ?? "",
version: QrVersions.auto,
size: 100,
backgroundColor: Colors.white,
),
),
),
),
Spacer(),
],
),
4.sizeBoxH,
Text(
'提示*苹果手机请用相机扫码/安卓手机\n推荐UC浏览器扫码',
style: TextStyle(
color: Colors.white
.withValues(alpha: .55),
fontSize: 12,
fontWeight: FontWeight.w400,
),
textAlign: TextAlign.center,
),
6.sizeBoxH,
Container(
height: 30,
padding: EdgeInsets.symmetric(
horizontal: 18, vertical: 3),
child: Text(
'${Config.appName} 官网地址 ${Address.groundUrl ?? ""}',
style: TextStyle(
color: Colors.white
.withValues(alpha: .55),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
],
);
},
)
],
),
),
),
),
),
),
),
Container(
margin: EdgeInsets.symmetric(horizontal: 20),
padding: EdgeInsets.symmetric(vertical: 12),
child: Column(
children: [
GestureDetector(
onTap: () async {
final ok =
await ImageUtil.saveWidgetToAlbum(boundaryKey);
showToast(ok ? "保存成功" : "保存失败,请重试");
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
child: Text(
"保存图片",
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
16.sizeBoxH,
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(
text: globalStore.meInfo?.promoteURL ?? ""));
showToast('复制成功');
},
child: Container(
width: 112,
height: 44,
alignment: Alignment.center,
child: Text(
"复制链接",
style: TextStyle(
fontSize: 12,
color: Color(0x8CFFFFFF),
decoration: TextDecoration.underline, // 添加下划线
decorationColor: Color(0xFF11887C), // 下划线颜色
decorationThickness: 1.0, // 下划线粗细
decorationStyle: TextDecorationStyle.solid, // 下划线样式
),
),
),
),
],
),
),
],
)),
);
}
}
@@ -0,0 +1,31 @@
import 'package:hgdj/hj_model/user/user_income_info_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/base_list_controller.dart';
import '../../../hj_model/mine/promotion_record.dart';
class MineShareRecordLogic extends ListBaseLogic<Promotion> {
UserIncomeModel? model; // 邀请收益头部信息(独立接口,与列表分页无关)
@override
void onReady() {
super.onReady();
loadInfo();
loadData();
}
// 头部邀请收益信息
void loadInfo() async {
model = await MineService.fetchIncomeInfo();
update();
}
// 邀请记录列表:下拉刷新 / 上拉加载更多(分页、防重入、异常兜底交给基类)
void loadData({bool isRefresh = true}) =>
fetchData(isRefresh: isRefresh, fetch: _fetch);
Future<(List<Promotion>?, bool)> _fetch(int page) async {
final resp = await MineService.getBindRecord(10, page);
return (resp?.list, resp?.hasNext == true); // hasNext==true 才有更多(保持原语义)
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_model/mine/promotion_record.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_utils/widget_util.dart';
import 'mine_share_record_logic.dart';
class MineShareRecordPage extends StatelessWidget {
const MineShareRecordPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<MineShareRecordLogic>(
init: MineShareRecordLogic(),
builder: (logic) {
return Scaffold(
appBar: AppBar(title: Text("邀请记录")),
body: Column(
children: [
12.sizeBoxH,
Text('已邀请:${logic.model?.totalInviteUserCount ?? 0}',
style: textStyle(12, Color(0xff525252), FontWeight.w400)),
Expanded(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
onLoading: (_) => logic.loadData(isRefresh: false),
onRefresh: (_) => logic.loadData(),
child: _buildBody(logic),
),
),
],
),
);
},
);
}
// loading / 空数据 / 列表 三态
Widget _buildBody(MineShareRecordLogic logic) {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.isEmptyData) return CErrorWidget();
final list = logic.dataList ?? [];
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shrinkWrap: true,
itemCount: list.length,
separatorBuilder: (_, __) =>
Divider(height: 0.5, color: Colors.white.withValues(alpha: .1)),
itemBuilder: (_, index) => _buildItemView(list[index]),
);
}
// 单条邀请记录:头像 + 昵称 + 注册时间
Widget _buildItemView(Promotion item) {
return Container(
height: 30,
width: double.infinity,
margin: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
NetworkImageLoader(
imageUrl: item.portrait ?? '',
width: 30,
height: 30,
borderRadius: 30,
),
11.sizeBoxW,
Text(
item.name ?? '',
style: TextStyle(
color: Color(0xffffffff),
fontWeight: FontWeight.w500,
fontSize: 14.0),
),
Spacer(),
Text(
'注册时间: ${item.createAt.utcToYMD(gap: '/')}',
style: TextStyle(
color: Color(0xff989898),
fontWeight: FontWeight.w400,
fontSize: 12.0),
)
],
),
);
}
}
@@ -0,0 +1,382 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/api_service/drama_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../hj_model/video_model.dart';
import '../../../tools_base/toast.dart';
import '../../../tools_base/video_download/video_save_util.dart';
import '../../../tools_base/video_download/video_download_manager.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../home/home_cell_style/video_simple_cell.dart';
import 'video_cache_logic.dart';
class VideoCacheCell extends StatefulWidget {
final VideoCacheLogic logic; // 注册局部刷新回调用
final VideoModel model;
final MediaStyle style; // 影视 / 抖音 / 动漫 / 短剧
final bool isEditing; // 缓存编辑状态
final GestureTapCallback? onCacheTap;
/// 点卡片进播放页。必须交给内层 [VideoSimpleCell]——它自带手势且在更深一层,
/// 只在外面套 GestureDetector 的话手势竞技场里赢的是它,跳转会走它默认的那套(按时长分长/短视频)
final GestureTapCallback? onTap;
const VideoCacheCell({
super.key,
required this.logic,
required this.model,
required this.style,
this.isEditing = false,
this.onCacheTap,
this.onTap,
});
@override
State<VideoCacheCell> createState() => _VideoCacheCellState();
}
class _VideoCacheCellState extends State<VideoCacheCell> {
/// 覆盖层状态切换动画时长:编辑态/缓存按钮/开始结束/勾选态统一用它
static const _switchDuration = Duration(milliseconds: 200);
VideoModel get model => widget.model;
bool get isDownloading => model.isDownloading;
bool get isFinished => model.progress >= 1;
String get statusText {
if (isFinished) return "已完成";
if (isDownloading) return "下载中...";
return "已暂停";
}
@override
void initState() {
super.initState();
// 向 logic 注册局部刷新:下载回调更新 model 后只 setState 本 cell,不再整页 update()
widget.logic.registerCellRefresher(model, _refresh);
}
@override
void didUpdateWidget(covariant VideoCacheCell oldWidget) {
super.didUpdateWidget(oldWidget);
// 切换到不同视频时换绑刷新回调。
// 用 realVideoUrl 而非 id 比较:本地缓存记录的 id 可能都是 "-1",比不出差异会漏换绑
if (oldWidget.model.realVideoUrl != model.realVideoUrl) {
widget.logic.unregisterCellRefresher(oldWidget.model, _refresh);
widget.logic.registerCellRefresher(model, _refresh);
}
}
@override
void dispose() {
widget.logic.unregisterCellRefresher(model, _refresh);
super.dispose();
}
/// logic 的下载回调命中本 cell 的 url 时触发:model 已被 logic 更新,这里只需重建
void _refresh() {
if (mounted) setState(() {});
}
void _toggleSelected() {
model.isSelected = !model.isSelected;
setState(() {});
}
/// 短剧按集卖,而本地记录里的播放地址是当初解锁时拿到的,权益到期后它照样能用
/// (m3u8 接口只认登录 token,不认单集权益)。**离线播放刻意不拦**——下载就是为了离线看,
/// 点卡片直接放本地文件(见 VideoCachePage._openVideo);这里只管两个会往外扩散的动作:
/// 导出相册、续下载
Future<bool> _ensureUnlocked() async {
if (widget.style != MediaStyle.Drama) return true;
final episode = await DramaService.fetchEpisode(model.subid);
if (episode?.canPlay == true) return true;
showToast("该剧集权益已过期,请重新解锁");
return false;
}
void _saveToAlbum() async {
if (!await _ensureUnlocked()) return;
VideoSaveUtil.instance.convertVideoMp4(
model.realVideoUrl,
loadInfo: DownloadInfo(status: "1", localPath: model.localPath),
isShowLoading: true,
);
}
void _onCacheTap() async {
if (!await _ensureUnlocked()) return;
widget.onCacheTap?.call();
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Container(
decoration: BoxDecoration(
color: const Color(0xff151515),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildBody()),
_buildStatus(),
],
),
),
_buildOverlay(),
],
);
}
/// cell 主体:动漫用 ACG 版本,其它(含短剧)用通用视频 cell
Widget _buildBody() {
if (widget.style == MediaStyle.Cartoon) {
return VideoACGCacheCell(model: model);
}
final isDrama = widget.style == MediaStyle.Drama;
return VideoSimpleCell(
videoModel: model,
onTap: widget.onTap,
textLines: 1,
imgBorderRadius: BorderRadius.circular(8),
//短剧和「热门短剧」橱窗一个样式:标题是剧名,封面右下角标本条是第几集;
//按集卖,整部剧标一个金币/VIP 角标对不上,跟橱窗一样关掉
coverRightText: isDrama ? '${model.episodeNo ?? 1}' : null,
showLevelIcon: !isDrama,
);
}
/// 状态文案 + 保存按钮 + 进度条
Widget _buildStatus() {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 3),
child: Row(
children: [
// Expanded + ellipsis:吸收富余宽度,抖音 3 列窄格里不再右溢出
Expanded(
child: Text(
statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 11, color: Color(0xffd43f61), height: 1.5),
),
),
// 仅下载完成且非动漫显示"保存到相册",避免下载中误触 + 右侧溢出
if (widget.style != MediaStyle.Cartoon && isFinished) ...[
6.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: _saveToAlbum,
child: const Text(
"保存到相册",
maxLines: 1,
style: TextStyle(
fontSize: 11,
color: Color(0xFFF68804),
height: 1.5,
decoration: TextDecoration.underline,
),
),
),
],
],
),
),
SizedBox(
height: 14,
child: Opacity(
opacity: isFinished ? 0 : 1,
child: Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: model.progress,
backgroundColor: const Color(0xff727272),
color: const Color(0xffe75160),
),
),
),
5.sizeBoxW,
// 固定宽度,避免百分比数字位数变化导致进度条右边跳动
SizedBox(
width: 46,
child: Text(
"${(model.progress * 100).toStringAsFixed(1)}%",
textAlign: TextAlign.right,
style: const TextStyle(
fontSize: 11, color: Color(0xffaab2b7), height: 1.2),
),
),
],
),
),
),
],
),
);
}
/// 覆盖层三态切换(编辑勾选 / 缓存按钮 / 下载完成隐藏):淡入淡出,不再硬跳。
/// 三个分支 runtimeType 各不相同,AnimatedSwitcher 自动识别为切换;
/// 而分支内部的 isDownloading / selected 变化 type 与 key 都不变,交给各自内层动画,蒙层不跟着闪。
Widget _buildOverlay() {
return AnimatedSwitcher(
duration: _switchDuration,
// 显式 expand:改造前蒙层是 StackFit.expand 的直接 child(紧约束铺满 cell),
// 套 AnimatedSwitcher 后默认布局会变成松约束按内容撑开,这里保持原来的铺满行为
layoutBuilder: (currentChild, previousChildren) => Stack(
fit: StackFit.expand,
children: [
// 正在淡出的旧层要屏蔽点击:它还在树里,否则切换的这 200 毫秒内
// 可能点到已经切走的按钮(刚进编辑模式却点中"开始缓存")
...previousChildren.map((c) => IgnorePointer(child: c)),
if (currentChild != null) currentChild,
],
),
child: () {
if (widget.isEditing) return _buildSelectLayer();
if (isFinished) return const SizedBox.shrink();
return _buildDownloadButton();
}(),
);
}
/// 开始 / 结束缓存按钮
Widget _buildDownloadButton() {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: GestureDetector(
onTap: _onCacheTap,
child: Container(
margin: const EdgeInsets.only(bottom: 72),
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(10),
),
// 开始/结束 图标与文案切换:只动这一行,外层蒙层与按钮底不动。
// 两种文案都是 4 个字 + 同宽图标,切换期间两者叠在 Stack 里不会撑宽按钮
child: AnimatedSwitcher(
duration: _switchDuration,
child: Row(
key: ValueKey(isDownloading),
mainAxisSize: MainAxisSize.min, // 按内容收窄,不再固定 120 宽
children: [
Image.asset(
(isDownloading ? "cache_stop.webp" : "cache_start.webp")
.videoPath,
width: 10,
fit: BoxFit.cover,
),
6.sizeBoxW,
Text(
isDownloading ? "结束缓存" : "开始缓存",
style: const TextStyle(fontSize: 12, color: Colors.white),
),
],
),
),
),
),
),
);
}
/// 编辑态勾选层
Widget _buildSelectLayer() {
final selected = model.isSelected;
return InkWell(
enableFeedback: false,
onTap: _toggleSelected,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: const Color(0x99707070),
),
child: Center(
// 勾选态切换:淡入 + 轻微放大,点击有即时反馈(scale 从 .7 起,不从 0 免得过于夸张)
child: AnimatedSwitcher(
duration: _switchDuration,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: .7, end: 1.0).animate(animation),
child: child,
),
),
child: Image.asset(
(selected ? "cache_selected.webp" : "cache_unselected.webp")
.videoPath,
key: ValueKey(selected),
width: 40,
height: 40,
),
),
),
),
);
}
}
class VideoACGCacheCell extends StatelessWidget {
final VideoModel model;
const VideoACGCacheCell({super.key, required this.model});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: NetworkImageLoader(
imageUrl: model.cover ?? "",
imgBorderRadius: BorderRadius.circular(12),
borderRadius: 4,
),
),
4.sizeBoxH,
Text(
model.title ?? '',
style: const TextStyle(fontSize: 14, color: Colors.white),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.left,
),
4.sizeBoxH,
Text(
'${model.updateDesc} · 共${model.totalEpisode ?? 0}',
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 12,
color:
model.updateStatus == 2 ? Color(0xff757575) : Color(0xffEEC76B),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
}
@@ -0,0 +1,242 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../hj_model/video_model.dart';
import '../../../tools_base/video_download/video_cache_store.dart';
import '../../../tools_base/video_download/video_download_manager.dart';
class VideoCacheLogic extends GetxController with GetTickerProviderStateMixin {
/// 删除退场动画时长:cell 缩放淡出播完才真删数据,page 侧动画时长与此保持一致
static const removeAnimDuration = Duration(milliseconds: 260);
//顺序与 _cacheMap 一致
final tabStyles = const [
MediaStyle.Video,
MediaStyle.ShortVideo,
MediaStyle.Cartoon,
MediaStyle.Drama
];
late final tabTitles = tabStyles.map((e) => e.cacheTabTitle).toList();
//在 onInit 里赋值而不是写成 late final 惰性初始化:那样 onClose 里的 dispose
//会成为「首次访问」,在控制器已销毁时才去 createTicker
late final TabController tabCtr;
/// 各类型缓存列表;短剧按**集**存,一部剧会有多条记录
final _cacheMap = {
MediaStyle.Video: <VideoModel>[],
MediaStyle.ShortVideo: <VideoModel>[],
MediaStyle.Cartoon: <VideoModel>[],
MediaStyle.Drama: <VideoModel>[],
};
List<VideoModel> listOf(MediaStyle style) => _cacheMap[style] ?? const [];
List<VideoModel> get allVideos =>
[for (final list in _cacheMap.values) ...list];
bool isLoading = true;
bool isEditing = false;
/// 删除流程进行中,防止 await 期间重复点「删除」
bool _isDeleting = false;
/// 正在播退场动画的 item:动画期间仍留在列表里,播完才真删数据
final _removingItems = <VideoModel>{};
bool isRemoving(VideoModel model) => _removingItems.contains(model);
/// taskKey -> 对应 cell 的局部刷新回调。
/// 下载进度不再触发整页 update(),改为只 setState 命中 url 的 cell(局部刷新)。
/// 用 Set 兜底同一 url 出现在多个 cell 的极端情况。
final _cellRefreshers = <String, Set<VoidCallback>>{};
/// 下载回调:Android isolate / iOS task 两端都靠它回传,命中哪条只刷哪个 cell
late final _callback = DownloadCallback(
success: (url) async {
final model = _findByUrl(url);
model?.loadProgress = "100.00";
// 实时完成时 localPath 还没回填(只在进页面 searchInfo 填过一次,那会儿还没下完),
// 这里补查一次写回,否则点"保存到相册"会因 localPath 为空报"视频文件不存在"
final info = await VideoDownloadManager.instance.searchInfo(url: url);
if (info != null) model?.localPath = info.localPath;
_refreshCells(url);
},
fail: (url, error) {
_findByUrl(url)?.isLoaderRunning = "0";
showToast("缓存加载失败");
_refreshCells(url);
},
progress: (url, progress) {
_findByUrl(url)?.loadProgress = progress;
_refreshCells(url);
},
);
/// cell 挂载时注册自己的局部刷新回调
void registerCellRefresher(VideoModel model, VoidCallback refresh) {
_cellRefreshers
.putIfAbsent(VideoDownloadManager.taskKey(model.realVideoUrl), () => {})
.add(refresh);
}
/// cell 卸载 / 换绑视频时注销
void unregisterCellRefresher(VideoModel model, VoidCallback refresh) {
final key = VideoDownloadManager.taskKey(model.realVideoUrl);
final set = _cellRefreshers[key];
if (set == null) return;
set.remove(refresh);
if (set.isEmpty) _cellRefreshers.remove(key);
}
/// 精准刷新某 url 对应的 cell(替代原来下载回调里的全页 update())
void _refreshCells(String url) {
final set = _cellRefreshers[VideoDownloadManager.taskKey(url)];
if (set == null) return;
for (final refresh in Set.of(set)) {
refresh();
}
}
/// 按 taskKey 在列表里找到对应 modelurl 带 token/cdn,直接比会漏匹配)
VideoModel? _findByUrl(String url) {
final key = VideoDownloadManager.taskKey(url);
for (final item in allVideos) {
if (VideoDownloadManager.taskKey(item.realVideoUrl) == key) return item;
}
return null;
}
@override
void onInit() {
super.onInit();
tabCtr = TabController(length: tabStyles.length, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
}
void _loadData() async {
try {
for (final entry in _cacheMap.entries) {
entry.value
..clear()
..addAll(
await VideoCacheStore.instance.getMovieCacheVideoList(entry.key));
}
// 本地记录只有 url,进度/暂停态要逐条问下载器;顺带把 _callback 挂上去
for (final item in allVideos) {
//条目多时这个循环很慢,用户中途退页面就别再往下问:
//onClose 已经把回调摘干净了,这里再 searchInfo 会把 _callback 重新挂回单例,留下悬挂回调
if (isClosed) return;
final info = await VideoDownloadManager.instance.searchInfo(
url: item.realVideoUrl,
callback: _callback,
);
if (info != null) {
item.localPath = info.localPath;
item.loadProgress = info.progress;
item.isLoaderRunning = info.isLoaderRunning;
}
}
} catch (e) {
debugLog(e);
}
isLoading = false;
if (!isClosed) update();
}
/// 右上角按钮:非编辑态进编辑,编辑态执行删除
void editEvent() {
if (_isDeleting) return; // 删除动画/落盘期间不响应重复点击
if (isEditing) {
_deleteSelected();
return;
}
//进编辑先清掉上轮残留的勾选,否则再次进编辑会显示脏选中态
//(删除失败时未删项、或退场动画那 260ms 里新勾的项,都会残留 isSelected
for (final item in allVideos) {
item.isSelected = false;
}
isEditing = true;
update();
}
/// 先标记被选中项播退场动画,动画播完再真删,避免 cell 硬闪消失
void _deleteSelected() async {
final deleteList = allVideos.where((e) => e.isSelected).toList();
if (deleteList.isEmpty) {
isEditing = false;
update();
return;
}
_isDeleting = true;
_removingItems.addAll(deleteList);
update();
try {
await Future.delayed(removeAnimDuration);
for (final item in deleteList) {
await VideoDownloadManager.instance.delete(item.realVideoUrl);
}
await VideoCacheStore.instance.removeVideoListNoType(deleteList);
for (final list in _cacheMap.values) {
list.removeWhere(deleteList.contains);
}
} catch (e) {
debugLog(e);
showToast("删除失败");
} finally {
// 必须复位,否则一次异常就把页面卡死:_isDeleting 会让「删除」按钮永久失效,
// _removingItems 残留会让那几个 cell 一直保持透明
_removingItems.clear();
isEditing = false;
_isDeleting = false;
// 删除期间用户可能已退页面(GetBuilder 会 dispose logic),落盘照做但别再刷 UI
if (!isClosed) update();
}
}
/// 开始 / 暂停下载
void toggleDownload(VideoModel model) async {
if (model.isDownloading) {
await VideoDownloadManager.instance.pause(model.realVideoUrl);
model.isLoaderRunning = "0";
} else {
// 开始新任务前,先暂停其它正在下载的任务(同时只允许一个下载)
for (final item in allVideos) {
if (item.isDownloading) {
await VideoDownloadManager.instance.pause(item.realVideoUrl);
item.isLoaderRunning = "0";
}
}
final result = await VideoDownloadManager.instance.download(
url: model.realVideoUrl,
callback: _callback,
);
if (result != null) {
debugLog("download movie file: $result");
} else {
model.isLoaderRunning = "1";
}
}
update();
}
@override
void onClose() {
// 只注销本页自己注册的那个 _callback,绝不能清光单例里的全部回调:
// 那会连短视频页 DownloadButton 等别处组件的回调一起清掉,
// 表现为「短视频页点下载 → 来缓存页看一眼 → 退回去,那个按钮的进度就再也不动了」。
// _callback 是同一个实例注册在多个 url 上(_loadData 逐个 searchInfo 都传了它),故逐个摘除
for (final item in allVideos) {
VideoDownloadManager.instance
.removeCallback(item.realVideoUrl, _callback);
}
_cellRefreshers.clear();
tabCtr.dispose();
super.onClose();
}
}
@@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
import '../../../hj_model/drama_media_info.dart';
import '../../../hj_model/video_model.dart';
import '../../../tools_base/indicator/custom_tab_indicator.dart';
import '../../drama/drama_detail_page.dart';
import '../../video/simple_video_player_page.dart';
import 'video_cache_cell.dart';
import 'video_cache_logic.dart';
class VideoCachePage extends StatelessWidget {
const VideoCachePage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<VideoCacheLogic>(
init: VideoCacheLogic(),
global: false,
builder: (logic) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text("下载缓存"),
actions: [
InkWell(
enableFeedback: false,
onTap: () => logic.editEvent(),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 12),
alignment: Alignment.centerRight,
child: Text(
logic.isEditing ? "删除" : "编辑",
style: const TextStyle(
color: Color(0xff757575),
fontSize: 14,
),
),
),
),
const SizedBox(width: 14),
],
),
body: logic.isLoading
? LoadingCenterWidget()
: Column(
children: [
Container(
color: Theme.of(context).appBarTheme.backgroundColor,
child: TabBar(
//tab 等分整宽,不滚动
indicator: CustomIndicator(isGradient: true),
indicatorWeight: 1,
unselectedLabelColor: Color(0x8CFFFFFF),
unselectedLabelStyle: TextStyle(fontSize: 14),
labelStyle: TextStyle(
fontSize: 14, fontWeight: FontWeight.w500),
labelColor: Color(0xE5FFFFFF),
tabs: logic.tabTitles
.map(
(e) => Padding(
padding: EdgeInsets.fromLTRB(0, 5, 0, 5),
child: Text(e),
),
)
.toList(),
controller: logic.tabCtr,
),
),
Expanded(
child: TabBarView(
controller: logic.tabCtr,
children: [
// keepAlive:切 tab 不重建,保留滚动位置,也避免入场动画反复重播
for (final style in logic.tabStyles)
_buildGrid(logic, style).keepAlive,
],
),
)
],
),
);
},
);
}
Widget _buildGrid(VideoCacheLogic logic, MediaStyle style) {
final dataArr = logic.listOf(style);
if (dataArr.isEmpty) {
return CErrorWidget();
}
return GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
//短剧跟「热门短剧」橱窗同规格:2 列 168/266
crossAxisCount:
style == MediaStyle.ShortVideo || style == MediaStyle.Cartoon
? 3
: 2,
crossAxisSpacing: 6,
mainAxisSpacing: 12,
childAspectRatio: switch (style) {
MediaStyle.ShortVideo => 191 / 390,
MediaStyle.Cartoon => 191 / 420,
MediaStyle.Drama => 168 / 266,
_ => 191 / 210,
},
),
itemCount: dataArr.length,
itemBuilder: (context, int index) {
final videoModel = dataArr[index];
return StaggerInItem(
// key 用 model 对象身份而非 index:删除后剩余项 Element 按 key 复用、
// 各自 State 保留,既不重播入场动画(否则整屏闪一下),也不会串味。
// 用 ObjectKey 而不是 url/taskKey:同一 url 可能对应多条记录(见 _cellRefreshers 的注释),
// 那样会撞成重复 key 直接抛 Duplicate keys;对象身份天然唯一且删除后不变
key: ObjectKey(videoModel),
index: index,
child: _buildCell(logic, videoModel, style),
);
},
);
}
/// 单个缓存 cell:被标记删除时缩放 + 淡出(时长与 logic 一致,播完 logic 才真删数据)
Widget _buildCell(
VideoCacheLogic logic, VideoModel videoModel, MediaStyle style) {
final isRemoving = logic.isRemoving(videoModel);
// 退场中屏蔽点击:opacity 到 0 也照样命中手势,否则那 260 毫秒里
// 点到看不见的 cell 会跳进播放页
return IgnorePointer(
ignoring: isRemoving,
child: AnimatedOpacity(
opacity: isRemoving ? 0 : 1,
duration: VideoCacheLogic.removeAnimDuration,
curve: Curves.easeOut,
child: AnimatedScale(
scale: isRemoving ? .8 : 1,
duration: VideoCacheLogic.removeAnimDuration,
curve: Curves.easeOut,
child: GestureDetector(
//动漫 cell 自身没有手势,靠这一层接管;其余 cell 内层 VideoSimpleCell 也收了同一个回调
onTap: () => _openVideo(videoModel, style),
child: VideoCacheCell(
logic: logic,
model: videoModel,
style: style,
isEditing: logic.isEditing,
onCacheTap: () => logic.toggleDownload(videoModel),
onTap: () => _openVideo(videoModel, style),
),
),
),
),
);
}
/// 点缓存卡片进播放页
void _openVideo(VideoModel videoModel, MediaStyle style) {
//下载就是为了离线看:有本地文件就直接放,不进二级页——二级页是纯网络播放器
//(VideoPlayerBaseLogic.initPlayer 只认 network),进那儿等于没用上下好的文件,断网还打不开
if (style == MediaStyle.Drama && videoModel.localPath?.isNotEmpty == true) {
_playLocal(videoModel,
'${videoModel.title ?? ""}${videoModel.episodeNo ?? 1}');
return;
}
//没下完(或只有下载态没落到文件)的没有本地文件可放,进二级页在线看这一集:
//本地记录只留了剧 id 和分集 id,短剧不在 /vid/info 里,走 pushToVideoPage 那套只会拿剧 id 去查视频详情
if (style == MediaStyle.Drama) {
Get.to(() => DramaDetailPage(
drama: DramaMediaInfo()
..id = videoModel.id
..title = videoModel.title
..verticalCover = videoModel.cover,
initialContentId: videoModel.subid,
));
return;
}
//id 为 -1 的是老版本落的记录,没有详情可拉,只能本地放
if (videoModel.id == "-1") {
_playLocal(videoModel, videoModel.title ?? "");
return;
}
pushToVideoPage(
videoModel: videoModel, isCartoon: videoModel.videoType == 1);
}
/// 用本地文件播([SimpleVideoPlayerLogic] 拿到 localPath 就走 PlayerFactory.file
void _playLocal(VideoModel videoModel, String title) =>
Get.to(SimpleVideoPlayerPage(
localPath: videoModel.localPath,
videoUrl: videoModel.realVideoUrl,
title: title,
));
}
@@ -0,0 +1,199 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../widgets/gradient_text.dart';
class UserChargeView extends StatelessWidget {
final bool isFromCoin; // true: 来源金币充值, vip时间为灰色
/// true:旧版文案/配色(会员中心 A / DISABLED
final bool classic;
const UserChargeView(
{super.key, this.isFromCoin = false, this.classic = false});
@override
Widget build(BuildContext context) {
return Consumer<GlobalStore>(
builder: (_, provider, __) {
if (classic) return _buildClassic(provider);
return _buildModern(provider);
},
);
}
Widget _buildModern(GlobalStore provider) {
final vipIcon = provider.meInfo?.vipImageName ?? '';
return Row(
children: [
NetworkImageLoader(
imageUrl: provider.meInfo?.portrait ?? '',
width: 60,
height: 60,
borderRadius: 30,
),
12.sizeBoxW,
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
provider.meInfo == null
? '未知'
: provider.meInfo?.name?.substring(
0, min(provider.meInfo?.name?.length ?? 0, 9)) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: const TextStyle(
fontSize: 18,
color: Color(0xffF6EEDC),
fontWeight: FontWeight.w600,
),
),
8.sizeBoxW,
if (!provider.isVIP)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Color(0x14FFFFFF), width: 1),
),
child: const Text(
'未开通',
style: TextStyle(
color: Color(0xff9A927C),
fontSize: 10,
height: 1.2,
),
),
)
else if (vipIcon.isNotEmpty)
Image.asset(vipIcon, height: 20),
],
),
4.sizeBoxH,
globalStore.isVIP
? Consumer<PreSaleProvider>(
builder: (context, provider, child) {
if (isFromCoin) {
return Text(
'会员到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
style: TextStyle(
color: Color(0xff9A927C),
fontSize: 12,
),
);
} else {
return GradientText(
'会员到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
gradient: LinearGradient(
colors: [
Color(0xffFFE8BE),
Color(0xffE6B764),
],
),
style: TextStyle(fontSize: 12),
);
}
},
)
: Text(
'开通会员 · 解锁全站尊享特权',
style: TextStyle(
color: Color(0xff9A927C),
fontSize: 12,
),
),
],
)
],
).paddingSymmetric(horizontal: 16);
}
Widget _buildClassic(GlobalStore provider) {
final vipIcon = provider.meInfo?.vipImageName ?? '';
return Row(
children: [
NetworkImageLoader(
imageUrl: provider.meInfo?.portrait ?? '',
width: 60,
height: 60,
borderRadius: 30,
),
12.sizeBoxW,
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
provider.meInfo == null
? '未知'
: provider.meInfo?.name?.substring(
0, min(provider.meInfo?.name?.length ?? 0, 9)) ??
'',
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(
fontSize: 18,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
12.sizeBoxW,
if (provider.isVIP && vipIcon.isNotEmpty)
Image.asset(vipIcon, height: 20),
],
),
4.sizeBoxH,
globalStore.isVIP
? Consumer<PreSaleProvider>(
builder: (context, provider, child) {
if (isFromCoin) {
return Text(
'到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
style: TextStyle(
color: Color(0xff989898),
fontSize: 12,
),
);
} else {
return GradientText(
'到期时间:${globalStore.meInfo?.vipExpireDate?.utcToYMD()}',
gradient: LinearGradient(
colors: [
Color(0xffFFE8BE),
Color(0xffE6B764),
],
),
style: TextStyle(fontSize: 12),
);
}
},
)
: Text(
'您还不是会员 开通会员 畅享特权',
style: TextStyle(
color: Color(0xff989898),
fontSize: 12,
),
),
],
)
],
).paddingSymmetric(horizontal: 16);
}
}
@@ -0,0 +1,56 @@
class AICouponModel {
String? createTime;
String? expiredTime;
String? goodsDesc;
String? goodsName;
String? goodsOrigin;
int? goodsType;
int? goodsValue;
String? id;
int? status;
int? uid;
String? useTime;
AICouponModel(
{this.createTime,
this.expiredTime,
this.goodsDesc,
this.goodsName,
this.goodsOrigin,
this.goodsType,
this.goodsValue,
this.id,
this.status,
this.uid,
this.useTime});
AICouponModel.fromJson(Map<String, dynamic> json) {
createTime = json['createTime'];
expiredTime = json['expiredTime'];
goodsDesc = json['goodsDesc'];
goodsName = json['goodsName'];
goodsOrigin = json['goodsOrigin'];
goodsType = json['goodsType'];
goodsValue = json['goodsValue'];
id = json['id'];
status = json['status'];
uid = json['uid'];
useTime = json['useTime'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['createTime'] = this.createTime;
data['expiredTime'] = this.expiredTime;
data['goodsDesc'] = this.goodsDesc;
data['goodsName'] = this.goodsName;
data['goodsOrigin'] = this.goodsOrigin;
data['goodsType'] = this.goodsType;
data['goodsValue'] = this.goodsValue;
data['id'] = this.id;
data['status'] = this.status;
data['uid'] = this.uid;
data['useTime'] = this.useTime;
return data;
}
}
@@ -0,0 +1,67 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../hj_model/mine/exchange/recharge_list_model.dart';
import '../../../hj_model/mine/exchange/recharge_type_list_model.dart';
import 'online_pay_page.dart';
import 'pay_order_source.dart';
class MineChargeCoinLogic extends GetxController {
/// 下单来源(透传 /mine/topay 的 sourcePage),由入口传入
final PaySourcePage sourcePage;
/// 下单埋点上下文(短剧付费墙要带 mediaId/contentId/checkoutContextId 做归因)
final PayOrderTrackInfo? orderTrack;
MineChargeCoinLogic(
{this.sourcePage = PaySourcePage.unknown, this.orderTrack});
bool isInitLoading = true; // 首屏加载中
RechargeListModel? model; // 金币充值档位列表
RechargeTypeModel? selectedCoin; // 当前选中的金币档位
@override
void onReady() {
super.onReady();
loadData();
}
// 拉取金币充值档位,默认选中第一档
Future<void> loadData() async {
final res = await MineService.getChatRechargeTypes(1);
isInitLoading = false;
model = res;
res?.list ??= [];
if (res?.list?.isNotEmpty == true) {
selectedCoin = res!.list!.first;
}
update();
}
// 选择金币档位
void onSelectCoin(int index) {
selectedCoin = model?.list?[index];
update();
}
// 去支付(购买金币)
void onGotoPay() {
if (selectedCoin == null) {
showToast("请选择产品");
return;
}
if (selectedCoin!.rechargeTypeListUI.isEmpty) {
showToast("未配置支付方式,请联系客服");
return;
}
Get.bottomSheet(
OnlinePayPage(
coinRcModel: selectedCoin,
orderTrack: (orderTrack ?? const PayOrderTrackInfo())
.copyWith(sourcePage: sourcePage),
),
isScrollControlled: true,
);
}
}
@@ -0,0 +1,240 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
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';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:provider/provider.dart';
import '../../../hj_utils/widget_util.dart';
import '../../../routers/jump_router.dart';
import '../make_money/mine_withdrawal_record_page.dart';
import 'charge_user_header.dart';
import 'mine_charge_coin_logic.dart';
import 'pay_order_source.dart';
import 'widgets/coin_item.dart';
//金币充值页面
class MineChargeCoinPage extends StatelessWidget {
/// 下单来源,入口不传则 UNKNOWN(金币没有专属来源枚举,别拿会员的顶上)
final PaySourcePage sourcePage;
const MineChargeCoinPage(
{super.key, this.sourcePage = PaySourcePage.unknown});
@override
Widget build(BuildContext context) {
return GetBuilder<MineChargeCoinLogic>(
init: MineChargeCoinLogic(sourcePage: sourcePage),
global: false, // 多入口各自独立:叠栈时别复用上一个页面的 controller(来源会串成上一次的)
builder: (controller) {
return Scaffold(
body: () {
if (controller.isInitLoading) return const LoadingCenterWidget();
if (controller.model == null)
return CErrorWidget(retryOnTap: () => controller.loadData());
return Column(
children: [
Expanded(
child: CustomScrollView(
slivers: <Widget>[
// 头像 + 余额卡片:随金币列表上滑,自带折叠淡出动画
SliverAppBar(
pinned: true,
backgroundColor: Color(0xff0F0F0F),
surfaceTintColor: Colors.transparent,
expandedHeight: kToolbarHeight + 200,
title: Text(
'金币充值',
style: textStyle(16, Colors.white, FontWeight.w600),
),
actions: [
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(RecordsPage(RecordType.recharge)),
child: Text(
'充值记录',
style: TextStyle(
color: Color(0xff666666), fontSize: 16.sp),
),
),
18.w.sizeBoxW
],
flexibleSpace: FlexibleSpaceBar(
background: SafeArea(
bottom: false,
child: Column(
children: [
kToolbarHeight.sizeBoxH,
UserChargeView(isFromCoin: true),
18.sizeBoxH,
_buildWallet(),
],
),
),
),
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid.builder(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 111 / 138,
),
itemCount: controller.model?.list?.length ?? 0,
itemBuilder: (BuildContext context, int index) {
final model = controller.model!.list![index];
final isSelected =
controller.selectedCoin?.id == model.id;
return CoinItem(
model,
onTap: () => controller.onSelectCoin(index),
isSelected: isSelected,
);
},
),
),
SliverToBoxAdapter(child: 18.sizeBoxH),
SliverToBoxAdapter(
child: EasyRichText(
'*如提示【交易失败】【账户风险】等,可重新发起订单,或在15分钟后重试支付。如支付未到账,请反馈客服订单号',
defaultStyle:
TextStyle(color: Color(0xff666666), fontSize: 12),
patternList: [
EasyRichTextPattern(
targetString: '反馈客服订单号',
style: TextStyle(
color: Color(0xffFFD460),
fontWeight: FontWeight.w500),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
)
],
).paddingSymmetric(horizontal: 16),
),
],
),
),
_buildBottomBar(controller),
],
);
}(),
);
},
);
}
// 底部支付按钮 + 客服入口
Widget _buildBottomBar(MineChargeCoinLogic controller) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => controller.onGotoPay(),
child: Container(
height: 44,
margin: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3)),
alignment: Alignment.center,
child: Text(
'¥${controller.selectedCoin?.moneyYuan ?? 0}/立即支付',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
12.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('支付问题反馈,点击联系 ',
style: textStyle(12, Color(0xffBFBFC1), FontWeight.w400)),
GestureDetector(
onTap: () {
pushToCustomService();
},
child: Text('在线客服',
style: textStyle(12, Color(0xffFFD460), FontWeight.w400)),
)
],
),
// 垫上虚拟导航栏高度,避免底部内容被遮挡(edge-to-edge
(16 + screen.paddingBottom).sizeBoxH,
],
);
}
// 我的金币余额卡片
Widget _buildWallet() {
return Container(
margin: EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
padding: EdgeInsets.fromLTRB(16, 18, 16, 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'我的金币余额',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500),
),
8.sizeBoxH,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('coin_icon.webp'.mineImgPath, width: 36),
4.sizeBoxW,
Consumer<GlobalStore>(builder: (_, store, __) {
final wallet = store.wallet;
final total = (wallet?.amount ?? 0) + (wallet?.income ?? 0);
return Text(
"$total",
style: TextStyle(
color: Color(0xffFFD460),
fontSize: 32,
fontWeight: FontWeight.w600),
);
}),
Spacer(),
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(RecordsPage(RecordType.bill), opaque: false),
child: Container(
alignment: Alignment.center,
height: 30,
width: 90,
decoration: BoxDecoration(
color: Color(0xffFFD460),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'余额明细',
style: TextStyle(color: Color(0xff3D3D3D), fontSize: 14),
),
),
)
],
),
],
));
}
}
@@ -0,0 +1,224 @@
import 'package:carousel_slider/carousel_controller.dart';
import 'package:flutter/widgets.dart';
import 'package:get/get.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/toast.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../alert/vip_guide/guide_countdown_dialog.dart';
import '../../../alert/vip_guide/guide_config.dart';
import 'online_pay_page.dart';
import 'pay_order_source.dart';
import 'vip_card_analytics.dart';
import 'vip_card_item.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
/// 会员卡页/购买弹窗的页面级 Logic:只管「当前选中卡 / 轮播 / 支付触发」。
/// 会员卡列表数据(请求/缓存/组装/选卡)统一在 [VipProductManager]UI 用 Consumer 消费。
class MineChargeVipLogic extends GetxController {
/// 局部刷新 id:只有「跟随选中卡变化」的区域订阅它(卡片选中态 / 价格 / 权益区 / 支付按钮),
/// 滑卡时不必整页重建(背景图、轮播本体、用户信息条、AppBar 都跟选中卡无关)
static const kSelection = 'vip_selection';
/// 跳转指定选中的会员卡ID(构造传入,替代 Get.arguments
final String? vipID;
/// 下单来源(透传 /mine/topay 的 sourcePage
final PaySourcePage sourcePage;
/// 播放页拉起时带上在看的那条内容 id,服务端按它归因;会员中心进来为空
final String? videoId;
/// 入口透传的整份下单上下文,字段比 [videoId] 全时以它为准(短剧付费墙带 mediaId/contentId/checkoutContextId
final PayOrderTrackInfo? orderTrack;
MineChargeVipLogic(
{this.vipID,
this.sourcePage = PaySourcePage.vipCenter,
this.videoId,
this.orderTrack});
/// 当前选中的会员卡(页面级状态,多入口各自独立)
VipProductModel? currentProductModel;
/// 引导弹窗点「立即开通」指定的卡ID,优先级高于构造传入的 vipID(用户显式选择)
String? _guideCardId;
/// 会员卡轮播控制器(页面级,两个入口不能共享)
late final pageCtr = CarouselSliderController();
/// 本页 VIP 卡片统计会话
VipCardAnalyticsSession? _analytics;
//轮播视口占比 / 高度:B 组固定卡面 148×157 + 横向间距 10;高度含角标与底部发光
double get itemRatio => vipProductManager.isNewVipUi
? (148 + 10) / screen.screenWidth
: 137.6 / screen.screenWidth;
double get cardHeight => vipProductManager.isNewVipUi
? 189 // 8角标 + 157卡面 + 24底部 Glow
: (screen.screenWidth * itemRatio) * 150 / 110.6;
/// 进页面是否强制拉最新:会员中心页 true(每次刷新),购买弹窗覆写为 false(优先用缓存)
bool get preferFreshData => true;
/// 进入本页是否触发「优惠倒计时」引导弹窗(VIP_CENTER)。购买弹窗(BuyVipAlertLogic)覆写为 false,避免叠弹。
bool get enableEntryGuidePopup => true;
@override
void onReady() {
super.onReady();
PreSaleProvider().refreshConfig();
loadCards(force: preferFreshData);
if (enableEntryGuidePopup) _tryShowEntryGuide();
}
@override
void onClose() {
// 页面/弹窗销毁 → GetBuilder 按 tag 走 Get.delete → onDelete → 这里补报 CLOSE
_analytics?.reportCloseWithoutPurchaseIfNeeded();
_analytics = null;
super.onClose();
}
/// VIP_CENTER:开关允许时**每次**进会员页都弹优惠倒计时引导
/// (含从别的引导弹窗点「开通」跳进来的那次,运营要追单,不做去重)。
void _tryShowEntryGuide() {
TimedPopupManager().trigger(
canShow: () => GuideManager().canShow(GuideScene.vipCenter),
onShow: () => GuideCountdownDialog.show(
scene: GuideScene.vipCenter,
// 已在会员页:关弹窗 + 就地选中弹窗配置的那张卡,让底部支付按钮直接对上该卡价格
onConfirm: (card) {
Get.back(result: true); // 对齐 show() 的返回语义:true=点了开通
_selectCard(card);
},
),
);
}
/// 选中并滚到指定会员卡:只驱动轮播,选中态由 [onPageChanged] 统一更新(和点卡片一致)。
/// 列表还在加载时轮播没渲染、动不了,先记下 id 交给 [_syncSelection] 落位
void _selectCard(VipProductModel card) {
_guideCardId = card.productID;
if (vipProductManager.isLoading || !pageCtr.ready) return;
final index = vipProductManager.vipCards
.indexWhere((e) => e.productID == card.productID);
if (index >= 0) pageCtr.animateToPage(index, curve: Curves.fastOutSlowIn);
}
/// 拉取会员卡列表并同步选中态
Future<void> loadCards({bool force = false}) async {
await vipProductManager.loadVipCards(force: force);
_syncSelection();
}
/// 数据就绪后按优先级确定默认选中卡,并滚动到对应位置;UI 展示后再上报曝光
void _syncSelection() {
currentProductModel = vipProductManager.defaultVipCard(
vipID: _guideCardId ?? vipID, current: currentProductModel);
final pid = currentProductModel?.productID;
final index = pid == null
? -1
: vipProductManager.vipCards.indexWhere((e) => e.productID == pid);
// 会话尽早创建:避免仅在 post-frame 里建,用户快速返回时 onClose 拿不到 session、CLOSE 丢报。
// 必须等有卡数据再建:首次请求失败时卡列表还是空,会把「无实验」快照锁进 session,
// 之后错误页重试成功也不会上报(快照 late final 不可变)。
if (vipProductManager.vipCards.isNotEmpty)
_analytics ??= VipCardAnalyticsSession();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
// 卡列表为空时页面渲染的是错误页,轮播压根没 buildcontroller 没 attach
// 而 jumpToPage 内部是 _state!.pageController!.page! 三层空断言,会直接崩
if (index >= 0 && pageCtr.ready) pageCtr.jumpToPage(index);
// 曝光必须在 UI 实际展示后上报
_analytics?.reportPageViewAfterPaint();
_analytics?.reportProductImpression(currentProductModel,
afterPaint: true);
});
update();
}
Widget instanceChildItem(int index) {
final data = vipProductManager.vipCards;
if (index > data.length - 1) {
return SizedBox(
height: cardHeight,
width: screen.screenWidth,
);
}
final model = data[index];
final card = VipCardItem(
model,
isSelect: currentProductModel?.productID != null &&
currentProductModel?.productID == model.productID,
callback: () {
pageCtr.animateToPage(index, curve: Curves.fastOutSlowIn);
},
);
// B 组:卡槽 = 148 + 右侧 10 间距
if (vipProductManager.isNewVipUi) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: Align(alignment: Alignment.centerLeft, child: card),
);
}
return card;
}
/// 滑动切卡
onPageChanged(int index) {
final data = vipProductManager.vipCards;
if (data.isEmpty) return;
// 轮播 itemCount = 卡数+2(尾部占位,让最后一张能滑到最左)。padEnds:false 时 PageView 的
// 最大页码 = itemCount - 1/viewportFractionB 组卡槽 158 在 <395 宽的机型上会四舍五入到
// 占位项索引,直接取 dataSource[index] 会越界,这里钳到最后一张真实卡。
currentProductModel = data[index.clamp(0, data.length - 1)];
_analytics?.reportProductImpression(currentProductModel);
update([kSelection]);
}
onInitiatePayAction() async {
if (currentProductModel == null) {
showToast("请选择充值的会员卡~");
return;
}
if (currentProductModel!.rchgTypeUI.isEmpty) {
showToast("未配置支付方式,请联系客服");
return;
}
await Get.bottomSheet(
OnlinePayPage(
vipProductModel: currentProductModel,
orderTrack: buildOrderTrack(),
),
isScrollControlled: true,
);
await PreSaleProvider().refreshConfig();
update();
}
/// 下单埋点:来源 + 卡皮 session + ACTIVE 时的实验字段(普通卡 / 预售卡共用)
PayOrderTrackInfo buildOrderTrack() {
final base = orderTrack;
return PayOrderTrackInfo(
sourcePage: base?.sourcePage ?? sourcePage,
sourceRef: base?.sourceRef ?? videoId,
videoId: base?.videoId ?? videoId,
mediaId: base?.mediaId,
contentId: base?.contentId,
checkoutContextId: base?.checkoutContextId,
sessionId: base?.sessionId ?? _analytics?.sessionId,
experimentId: vipProductManager.isExperimentActive
? (currentProductModel?.experimentId ??
vipProductManager.experimentId)
: null,
experimentVariant: vipProductManager.isExperimentActive
? (currentProductModel?.variant ?? vipProductManager.variant)
: null,
);
}
}
@@ -0,0 +1,471 @@
import 'dart:math' as math;
import 'package:carousel_slider/carousel_slider.dart';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
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/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../routers/jump_router.dart';
import '../../pre_sale/pre_sale_entry.dart';
import '../make_money/mine_withdrawal_record_page.dart';
import 'charge_user_header.dart';
import 'mine_charge_vip_logic.dart';
import 'pay_order_source.dart';
import 'vip_card_item.dart';
import 'vip_pay_button.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
import 'vip_ui_kit.dart';
class MineChargeVipPage extends StatefulWidget {
final String? vipID; //跳转指定选中的会员卡ID
/// 下单来源,默认会员中心
final PaySourcePage sourcePage;
const MineChargeVipPage({
super.key,
this.vipID,
this.sourcePage = PaySourcePage.vipCenter,
});
@override
State<MineChargeVipPage> createState() => _MineChargeVipPageState();
}
// 多入口可叠栈,用 per-实例唯一 tag 隔离 controller,避免轮播/选中卡/onReady 串味
class _MineChargeVipPageState extends State<MineChargeVipPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
init: MineChargeVipLogic(
vipID: widget.vipID, sourcePage: widget.sourcePage),
builder: (logic) => Consumer<VipProductManager>(
builder: (_, mgr, __) {
// ACTIVE+B → 改版 UIA / DISABLED / 无实验 → 旧版 UI
final useNew = mgr.isNewVipUi;
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: useNew ? const Color(0xff0F0F0F) : null,
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
iconTheme: IconThemeData(color: Colors.white),
title: Text('会员中心', style: TextStyle(color: Colors.white)),
actions: [
InkWell(
enableFeedback: false,
onTap: () => Get.to(RecordsPage(RecordType.recharge)),
child: Text('充值记录',
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontSize: 12)),
),
16.sizeBoxW,
],
),
body: useNew
? Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
child: _buildPageBackground(mgr),
),
_buildBody(logic, classic: false),
],
)
: _buildBody(logic, classic: true),
);
},
),
);
}
/// B 组页顶背景:优先 uiConfig.backgroundImage,空则本地默认图
Widget _buildPageBackground(VipProductManager mgr) {
final url = mgr.vipBgImage;
if (url != null) {
return NetworkImageLoader(
imageUrl: url,
width: double.infinity,
fit: BoxFit.fitWidth,
);
}
return Image.asset(
'mine_vip_bg.webp'.mineImgPath,
width: double.infinity,
fit: BoxFit.fitWidth,
alignment: Alignment.topCenter,
);
}
Widget _buildBody(MineChargeVipLogic logic, {required bool classic}) {
if (vipProductManager.isLoading) return LoadingCenterWidget();
if (vipProductManager.vipCards.isEmpty) {
return CErrorWidget(retryOnTap: () => logic.loadCards(force: true));
}
return Stack(
children: [
Column(
children: [
(kToolbarHeight + screen.paddingTop).sizeBoxH,
Expanded(
child: SingleChildScrollView(
child: Column(
children: [
12.sizeBoxH,
UserChargeView(classic: classic),
26.sizeBoxH,
_buildCardSlider(logic),
if (vipProductManager.presaleGroup != null) ...[
20.sizeBoxH,
PreSaleVipEntry().paddingSymmetric(horizontal: 16),
],
// B:卡皮与核心权益标题间距略收;A 保持原 14
(classic ? 14 : 4).sizeBoxH,
_buildPrivileges(classic: classic),
100.sizeBoxH,
],
),
),
),
],
),
Positioned(
bottom: 0, left: 0, right: 0, child: _buildPay(classic: classic)),
],
);
}
//会员卡轮播
Widget _buildCardSlider(MineChargeVipLogic logic) {
return CarouselSlider.builder(
carouselController: logic.pageCtr,
itemCount: vipProductManager.vipCards.length + 2,
//选中态跟着 kSelection 单卡刷新,避免滑一次卡把整个轮播和页面重建一遍
itemBuilder: (_, index, __) => GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: (l) => l.instanceChildItem(index),
),
options: CarouselOptions(
height: logic.cardHeight,
viewportFraction: logic.itemRatio,
enableInfiniteScroll: false,
enlargeCenterPage: false,
padEnds: false,
// 选中卡底部 VIP/Glow Gold 不被 PageView 裁切
clipBehavior: Clip.none,
onPageChanged: (index, __) => logic.onPageChanged(index),
),
);
}
//权益区:跟随选中卡,订阅 kSelection 局部刷新;newPrivilege 只遍历一次按 isCore 分组
Widget _buildPrivileges({required bool classic}) {
return GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: (logic) {
final privileges = logic.currentProductModel?.newPrivilege ?? [];
final coreList = <NewPrivilege>[];
final moreList = <NewPrivilege>[];
for (final p in privileges) {
(p.isCore == true ? coreList : moreList).add(p);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
classic
? _buildClassicCorePrivileges(coreList)
: _buildCorePrivileges(coreList),
classic
? _buildClassicMorePrivileges(moreList)
: _buildMorePrivileges(moreList),
],
);
},
);
}
//核心权益:横向滚动卡片(左图标 + 右文案)—— B 改版
Widget _buildCorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipCoreSectionTitleImage(),
16.sizeBoxH,
SizedBox(
height: 92,
child: ListView.separated(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 2, 16, 26),
itemCount: list.length,
separatorBuilder: (_, __) => 8.sizeBoxW,
itemBuilder: (_, index) => VipCorePrivilegeCard(list[index]),
),
),
16.sizeBoxH,
],
);
}
//核心权益:4 列金边方卡 —— A / DISABLED 旧版
Widget _buildClassicCorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipSectionTitle("我的核心权益", classic: true),
16.sizeBoxH,
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 14),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1,
),
itemCount: list.length,
itemBuilder: (_, index) => FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width: 72,
height: 72,
child: VipCorePrivilegeCard(list[index], classic: true)),
),
),
16.sizeBoxH,
],
);
}
//更多权益:深色圆角容器 + 四列网格 + 展开/收起 —— B 改版
Widget _buildMorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return _VipMorePrivilegesPanel(
list: list,
title: const VipMoreSectionTitleImage(),
);
}
//会员特权:直接四列网格 —— A / DISABLED 旧版
Widget _buildClassicMorePrivileges(List<NewPrivilege> list) {
if (list.isEmpty) return const SizedBox.shrink();
return Column(
children: [
const VipSectionTitle("我的会员特权", classic: true),
12.sizeBoxH,
GridView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 18,
childAspectRatio: 63 / 100,
),
itemCount: list.length,
itemBuilder: (_, index) =>
VipProductPrivilegeItem(list[index], classic: true),
),
],
);
}
//底部支付区域:只有按钮跟选中卡走,渐变底和客服文案是静态的
Widget _buildPay({required bool classic}) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Colors.black.withValues(alpha: 0), Colors.black],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
12.sizeBoxH,
GetBuilder<MineChargeVipLogic>(
tag: uniqueTag,
id: MineChargeVipLogic.kSelection,
builder: (logic) => VipPayButton(logic, classic: classic),
),
12.sizeBoxH,
EasyRichText(
'支付问题反馈,点击联系 在线客服',
patternList: [
EasyRichTextPattern(
targetString: '在线客服',
style: TextStyle(color: Color(0xFFFFD460), fontSize: 12),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
)
],
defaultStyle: TextStyle(color: Color(0xffDCDCDC), fontSize: 12),
),
// 垫上虚拟导航栏高度,避免底部内容被遮挡(edge-to-edge
(12 + screen.paddingBottom).sizeBoxH,
],
),
);
}
}
/// 更多权益:深色圆角面板 + 四列网格,默认展开,可收起为 2 行
class _VipMorePrivilegesPanel extends StatefulWidget {
final List<NewPrivilege> list;
final Widget title;
const _VipMorePrivilegesPanel({required this.list, required this.title});
@override
State<_VipMorePrivilegesPanel> createState() =>
_VipMorePrivilegesPanelState();
}
class _VipMorePrivilegesPanelState extends State<_VipMorePrivilegesPanel> {
static const _collapsedCount = 8; // 收起时展示 2 行 × 4
static const _radius = 24.0;
bool _expanded = true;
/// 描边:上→下 #FFFBE5 30% → 0% → 30%(上下可见,左右中段淡出)
static const _borderGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x4DFFFBE5), Color(0x00FFFBE5), Color(0x4DFFFBE5)],
);
@override
Widget build(BuildContext context) {
final canToggle = widget.list.length > _collapsedCount;
final showCount =
(!_expanded && canToggle) ? _collapsedCount : widget.list.length;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: CustomPaint(
painter: _FigmaGradientBorderPainter(
gradient: _borderGradient,
strokeWidth: 1,
radius: _radius,
inner: true,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: ColoredBox(
color: const Color(0x0DFFFFFF),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 14, 12, 10),
child: Column(
children: [
widget.title,
14.sizeBoxH,
GridView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 10,
childAspectRatio: 72 / 110,
),
itemCount: showCount,
itemBuilder: (_, index) =>
VipProductPrivilegeItem(widget.list[index]),
),
if (canToggle) ...[
8.sizeBoxH,
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_expanded ? '收起特权' : '展开特权',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 12,
),
),
Icon(
_expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 16,
color: Colors.white.withValues(alpha: 0.55),
),
],
),
),
),
],
],
),
),
),
),
),
);
}
}
/// Figma 线性渐变描边:shader 按节点 bounds 映射(与 Figma stroke fill 一致)
class _FigmaGradientBorderPainter extends CustomPainter {
final Gradient gradient;
final double strokeWidth;
final double radius;
final bool inner;
_FigmaGradientBorderPainter({
required this.gradient,
required this.strokeWidth,
required this.radius,
this.inner = true,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
// Inner:描边中心线向内缩 strokeWidth/2,使整条描边落在边界内侧
final inset = inner ? strokeWidth / 2 : 0.0;
final rrect = RRect.fromRectAndRadius(
rect.deflate(inset),
Radius.circular(math.max(0, radius - inset)),
);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..shader = gradient.createShader(rect);
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(covariant _FigmaGradientBorderPainter oldDelegate) {
return oldDelegate.gradient != gradient ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.radius != radius ||
oldDelegate.inner != inner;
}
}
@@ -0,0 +1,597 @@
import 'dart:convert';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.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/toast.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../assets_tool/app_colors.dart';
import '../../../assets_tool/images.dart';
import '../../../config/address.dart';
import '../../../hj_model/mine/exchange/dc_model.dart';
import '../../../hj_model/mine/exchange/recharge_type_list_model.dart';
import '../../../hj_utils/api_service/mine_service.dart';
import '../../../hj_utils/pay/pay_manager.dart';
import '../../../hj_utils/widget_util.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/global_store/store.dart';
import '../../../tools_base/loading/loading_helper.dart';
import '../../../tools_base/net/net_manager.dart';
import '../../main_page/provider/msg_provider.dart';
import '../../web_page/h5_page.dart';
import 'pay_order_source.dart';
import 'pay_success_alert.dart';
import 'vip_card_analytics.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
/// 在线支付弹窗:VIP 会员卡购买(vipProductModel) 或 金币充值(coinRcModel) 二选一,
/// 底部弹出,展示支付方式列表 + 支付按钮;下单渠道分代充(dc)/线上跳链/金币三种。
class OnlinePayPage extends StatefulWidget {
final VipProductModel? vipProductModel; // 传此值:购买 VIP 会员卡
final RechargeTypeModel? coinRcModel; // 传此值:充值金币
/// 下单来源/实验/会话等埋点信息(透传 /mine/topay
final PayOrderTrackInfo? orderTrack;
const OnlinePayPage({
super.key,
this.vipProductModel,
this.coinRcModel,
this.orderTrack,
});
@override
State<OnlinePayPage> createState() => _OnlinePayPageState();
}
class _OnlinePayPageState extends State<OnlinePayPage> {
// 当前选中的支付方式下标
int payIndex = 0;
// 支付请求防重入标记
bool isPaying = false;
// 加赠券:isCouponPanel 控制切到选券面板,coupon 为已选券
bool isCouponPanel = false;
CouponModel? coupon;
// 支付方式列表。会员卡与金币档位二选一,getter 每次访问都会重建列表,故每帧只取一次
List<RchgType> get _payTypes =>
widget.vipProductModel?.rchgTypeUI ??
widget.coinRcModel!.rechargeTypeListUI;
/// 支付按钮文案:预售(尾款/升级/预订) → 会员卡(升级/普通) → 金币充值
String get _payText {
final vip = widget.vipProductModel;
if (vip == null) return '¥${widget.coinRcModel?.moneyYuan ?? 0}/立即支付';
if (!vip.isPreSale)
return '¥${vip.discountedPriceUI}/${vip.isUpgrade == true ? '补差价升级' : '立即支付'}';
// 预售:可付尾款优先,其次升级,最后预订
final preSale = PreSaleProvider();
final detail = preSale.preSaleModel?.detailModel;
if (preSale.canPayBalance) return '¥${detail?.balanceAmount ?? 0}支付尾款';
if (vip.isUpgrade == true) return '¥${(vip.advanceAmount ?? 0) ~/ 10}/立即升级';
return '¥${detail?.advanceAmount ?? 0}立即预订';
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
decoration: ShapeDecoration(
color: AppColors.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
),
// 选中券类型支付方式时切到选券面板(本项目通常无券数据)
child: isCouponPanel
? ChoseCouponView(
onSelect: (model) => setState(() {
isCouponPanel = false;
coupon = model;
}),
)
: _payPanel(),
);
}
//支付主面板
Widget _payPanel() {
final types = _payTypes;
final curType = types[payIndex]; // 当前选中的支付方式,与列表同源,build 时取
return Column(mainAxisSize: MainAxisSize.min, children: [
const SheetHandleBar(),
18.sizeBoxH,
const Text(
'选择支付方式',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.w500, color: Colors.white),
),
18.sizeBoxH,
//支付方式列表
Wrap(children: [
for (var i = 0; i < types.length; i++) _payItem(types[i], i)
]),
//支付小贴士
Padding(
padding: EdgeInsets.only(bottom: 24.h, left: 16, right: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'支付小贴士:',
style: textStyle(
14, Colors.white.withValues(alpha: .9), FontWeight.w500),
),
6.sizeBoxH,
Text(
'''1.因超时支付无法到账,请重新发起。
2.每天发起支付不能超过5次,连续发起且未支付,账号可能被加入黑名单。''',
style: textStyle(
12, Colors.white.withValues(alpha: .45), FontWeight.w400),
),
],
),
),
//支付按钮
GestureDetector(
onTap: () => _onPay(curType),
child: Container(
alignment: Alignment.center,
height: 44,
decoration: ShapeDecoration(
color: AppColors.actionRed,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(3),
),
),
child: Text(
_payText,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 20.sp,
color: Colors.white),
),
),
),
18.sizeBoxH,
//客服入口
Center(
child: EasyRichText(
'支付中如有问题,请咨询 在线客服',
defaultStyle: textStyle(
12, Colors.white.withValues(alpha: .6), FontWeight.w500),
patternList: [
EasyRichTextPattern(
targetString: '在线客服',
style: textStyle(12, Color(0xffFFD460), FontWeight.w500),
recognizer: TapGestureRecognizer()
..onTap = () => pushToCustomService(),
),
],
),
),
]);
}
//单个支付方式
Widget _payItem(RchgType type, int index) {
final icon = type.getPayIcon();
return InkWell(
enableFeedback: false,
onTap: () => setState(() {
//券类型切到选券面板,其余直接切换选中
if (type.type == 'coupon') {
isCouponPanel = true;
} else {
payIndex = index;
}
}),
child: Container(
height: 41,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12),
margin: EdgeInsets.only(bottom: 20.h),
child: Row(
children: [
icon.isEmpty
? const SizedBox(width: 36, height: 36)
: Image.asset(icon, width: 36),
13.sizeBoxW,
Expanded(
child: Text(
"${type.typeName}",
style: textStyle(
16, Colors.white.withValues(alpha: .9), FontWeight.w500),
),
),
Image.asset(
payIndex == index
? 'radio_sel.png'.commonImgPath
: 'mine_withdraw_nor.png'.mineImgPath,
width: 16),
],
),
),
);
}
//按选中的支付方式下单:金币余额 / 代充 / 线上跳链
void _onPay(RchgType type) {
final vip = widget.vipProductModel;
final coinRc = widget.coinRcModel;
if (type.type == "coin") {
_payByCoin();
} else if (type.isOfficial == true) {
// 正常购买:金币 / vip购买
if (coinRc != null) {
_payByDc(false, type, coinRc.daichong, coinRc.money);
} else {
_payByDc(true, type, vip?.daichong, (vip?.discountedPrice ?? 0) * 10);
}
} else {
_payByLink(type);
}
}
///线上跳链支付:创建支付单后跳外部支付页
Future<void> _payByLink(RchgType payType) async {
final vip = widget.vipProductModel; // 非空 = 购买会员卡,空 = 购买金币
final productId = vip != null ? vip.productID! : widget.coinRcModel!.id;
if (isPaying) return;
isPaying = true;
LoadingHelper.showLoading();
// 合并入口透传;仅实验 ACTIVE 时附带卡皮实验信息(copyWith 无法清 null,故重建)
final base = widget.orderTrack ?? const PayOrderTrackInfo();
final orderTrack = PayOrderTrackInfo(
sourcePage: base.sourcePage ?? PaySourcePage.unknown,
sourceRef: base.sourceRef,
videoId: base.videoId,
activityId: base.activityId,
sessionId: base.sessionId,
//短剧付费墙的归因字段,这里重建时漏抄会被服务端 8001 拒单
mediaId: base.mediaId,
contentId: base.contentId,
checkoutContextId: base.checkoutContextId,
experimentId: vipProductManager.isExperimentActive
? (base.experimentId ??
(vip == null
? null
: vip.experimentId ?? vipProductManager.experimentId))
: null,
experimentVariant: vipProductManager.isExperimentActive
? (base.experimentVariant ??
(vip == null ? null : vip.variant ?? vipProductManager.variant))
: null,
);
final urlModel = await MineService.chargeGoldCoin(
payType.type,
productId: productId,
isVip: vip != null,
goldExtraID: coupon?.cId,
finalPayStatus: vip?.isPreSale == true
? PreSaleProvider().preSaleModel?.detailModel?.balancePayment
: null,
orderTrack: orderTrack,
);
LoadingHelper.dismissLoading();
if (urlModel != null) {
if (urlModel.mode == "url") {
await launchUrlToWeb(urlModel.payUrl ?? '');
} else if (urlModel.mode == "sdk") {
showToast("没有找到支付类型为:${payType.type} 的sdk");
Get.back();
isPaying = false;
return;
}
// VIP 已创建支付单:视为产生购买行为,关闭会员卡页不再报无购买关闭
if (vip != null) VipCardAnalyticsSession.markPurchaseOnActiveSessions();
Get.back();
Future.delayed(const Duration(milliseconds: 1500), () {
//支付成功刷新vip到期时间
Get.dialog(
const Center(child: PaySuccessAlert()),
barrierColor: const Color(0x22000000),
barrierDismissible: true,
);
});
}
isPaying = false;
}
///金币余额支付(仅会员卡有该方式)
Future<void> _payByCoin() async {
if (isPaying) return;
isPaying = true;
final vip = widget.vipProductModel;
final track = widget.orderTrack;
final experimentActive = vipProductManager.isExperimentActive;
await PayManager().buyVip(
vip?.productType,
vip?.productID,
vip?.productName,
vip?.discountedPrice,
source: 'online_pay',
jumpWalletOnInsufficient: false, // 当前就在充值页,余额不足不跳转
finalPayStatus: vip?.isPreSale == true
? PreSaleProvider().preSaleModel?.detailModel?.balancePayment
: null,
// 与 /mine/topay 一致:仅实验 ACTIVE 时回传;sessionId 有则带上
experimentId: experimentActive
? (track?.experimentId ??
vip?.experimentId ??
vipProductManager.experimentId)
: null,
experimentVariant: experimentActive
? (track?.experimentVariant ??
vip?.variant ??
vipProductManager.variant)
: null,
sessionId: track?.sessionId,
//短剧付费墙开卡:金币余额支付这条也要带归因,否则只有第三方充值那条统计得到
mediaId: track?.mediaId,
contentId: track?.contentId,
checkoutContextId: track?.checkoutContextId,
onSuccess: (data) async {
VipCardAnalyticsSession.markPurchaseOnActiveSessions();
showToast("购买成功");
globalStore.refreshWallet();
await globalStore.updateUserInfo();
MineMsgProvider().refreshPayPopup(); //会员状态变了,重拉分层与付费引导开关(买完别再弹引导)
Get.back();
},
);
isPaying = false;
}
///代充支付:拼装代充参数后跳 H5 收银台
Future<void> _payByDc(
bool isVip, RchgType payType, DCModel? dcModel, int? money) async {
// daichong 是所有会员卡/金币档位共用的同一实例,下面的改写只为拼给 H5,必须在副本上做:
// 原地改会把 payInfos 截成单条(支付方式列表变短→下标越界)、ordUrl 被重复拼 host
final dc = dcModel!.clone();
final payList = dc.traders![0].payInfos!;
PayInfoModel? payInfo;
for (final model in payList) {
if (model.payMethod == payType.payMethod) {
payInfo = model;
break;
}
}
//设置默认值
if (dc.limit == 0) dc.limit = 500;
final payMoney = money ?? 0;
//超额度走大额通道
if (payInfo!.payType!.contains(2) && payInfo.payType!.contains(3)) {
payInfo.payType = (payMoney / 100) > dc.limit! ? [3] : [2];
}
if (payInfo.payType!.length >= 3) {
payInfo.payType = (payMoney / 100) > dc.limit! ? [1, 3] : [1, 2];
}
dc.traders![0].payInfos = [payInfo];
final host = Address.baseHost!;
dc.ordUrl = host + dc.ordUrl!;
dc.traderUrl = host + dc.traderUrl!;
dc.chargeMoney = payMoney ~/ 100;
final channel = payType.channel!;
dc.channel = channel;
final token = await netManager.getToken();
//bt64
final data = base64Encode(utf8.encode(json.encode(dc)));
if (isVip) VipCardAnalyticsSession.markPurchaseOnActiveSessions();
Get.to(H5Page(title: "代理充值", url: "$channel/?data=$data&token=$token"),
opaque: false);
}
}
/// 加赠券选择面板:拉取用户券列表,选中经 onSelect 回传(传 null 表示返回不选)
class ChoseCouponView extends StatefulWidget {
final Function(CouponModel? coupon) onSelect;
const ChoseCouponView({super.key, required this.onSelect});
@override
State<ChoseCouponView> createState() => _ChoseCouponViewState();
}
class _ChoseCouponViewState extends State<ChoseCouponView> {
bool isLoading = true;
final _dataSource = <CouponModel>[];
int page = 1;
// refreshCtr 由 CustomRefreshView 创建并 dispose,本类只持引用,绝不能再 dispose
RefreshController? refreshCtr;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch());
}
Future<void> _fetch({bool isRefresh = true}) async {
if (isRefresh) page = 1;
final res = await MineService.fetchUserCoupons(1, page: page);
isLoading = false;
if (isRefresh) {
refreshCtr?.refreshCompleted();
_dataSource.clear();
}
_dataSource.length < 20
? refreshCtr?.loadNoData()
: refreshCtr?.loadComplete();
_dataSource.addAll(res);
page += 1;
setState(() {});
}
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(maxHeight: 400),
child: Column(
children: [
//顶部拖动条
Container(
height: 41,
alignment: Alignment.center,
child: const SheetHandleBar(color: Colors.black12),
),
//返回 + 标题
Stack(
children: [
GestureDetector(
onTap: () => widget.onSelect(null),
child: Image.asset(
'common_back.png'.commonImgPath,
width: 18,
height: 18,
color: Colors.black.withValues(alpha: .9),
),
),
const Center(
child: Text(
'选择加赠券',
style: TextStyle(
color: Colors.black,
fontSize: 18,
fontWeight: FontWeight.w600),
),
)
],
),
18.sizeBoxH,
Expanded(
child: pullYsRefresh(
onRefresh: (_) => _fetch(),
onLoading: (_) => _fetch(isRefresh: false),
child: () {
if (isLoading) return const LoadingCenterWidget();
if (_dataSource.isEmpty) return const CErrorWidget();
return ListView.separated(
separatorBuilder: (_, __) => 12.sizeBoxH,
itemCount: _dataSource.length,
itemBuilder: (_, index) => _couponItem(_dataSource[index]),
);
}(),
onInit: (ctr) => refreshCtr = ctr),
)
],
),
);
}
Widget _couponItem(CouponModel model) {
return Container(
height: 72,
padding: const EdgeInsets.only(left: 24, right: 11),
alignment: Alignment.centerLeft,
width: double.infinity,
decoration: BoxDecoration(
color: Color(0xffff891c).withValues(alpha: .4),
borderRadius: BorderRadius.circular(4)),
child: Row(
children: [
//券面额
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Image.asset(
'coin_icon.webp'.mineImgPath,
width: 16,
height: 16,
),
4.sizeBoxW,
Text(
'${model.price ?? 0}',
style: TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w500,
height: 33 / 24),
)
],
),
Text(
'充值加送金币',
style: TextStyle(
color: Colors.black.withValues(alpha: .6), fontSize: 12),
)
],
),
23.sizeBoxW,
SizedBox(
width: 1,
height: 45,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: .5),
Colors.black.withValues(alpha: 0)
], begin: Alignment.topCenter, end: Alignment.bottomCenter),
),
),
),
12.sizeBoxW,
//券说明 + 有效期
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'金币加购券',
style: TextStyle(
color: Colors.black,
fontSize: 12,
),
),
4.sizeBoxH,
Text(
'有效期:${model.expireTime?.utcToYMDHM()}',
style: TextStyle(
color: Colors.black.withValues(alpha: .6),
fontSize: 8,
),
)
],
),
),
11.sizeBoxW,
GestureDetector(
onTap: () => widget.onSelect(model),
child: Container(
decoration: BoxDecoration(
color: Color(0xffF68216),
borderRadius: BorderRadius.circular(22)),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
child: Text(
'立即使用',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500),
),
),
)
],
),
);
}
}
@@ -0,0 +1,86 @@
/// 创建订单(/mine/topay)的来源页,[value] 与后端约定一致
enum PaySourcePage {
homeUserSegment('HOME_USER_SEGMENT'), //用户分层首页弹窗
homeFloatWindow('HOME_FLOAT_WINDOW'), //首页右下角浮窗(分层浮窗 + 活动浮窗内链)
videoBottomBanner('VIDEO_BOTTOM_BANNER'), //视频播放页下方 banner
videoBottomSheet('VIDEO_BOTTOM_SHEET'), //视频播放页底部弹窗(含播放页金币解锁)
vipCenter('VIP_CENTER'), //会员中心
h5Activity('H5_ACTIVITY'), //H5 活动
dramaPaywall('DRAMA_PAYWALL'), //短剧付费墙(金币充值 / 开短剧卡)
unknown('UNKNOWN'); //入口没指定
final String value;
const PaySourcePage(this.value);
}
/// 下单(/mine/topay)透传的埋点信息:来源 / 实验 / 会话等,均为非必填
class PayOrderTrackInfo {
/// 来源页
final PaySourcePage? sourcePage;
/// 来源关联(如 videoId 文案标识)
final String? sourceRef;
/// 关联视频 ID
final String? videoId;
/// 活动 ID
final String? activityId;
/// 实验 IDVIP 卡皮 A/B
final String? experimentId;
/// 实验分组(对应接口 experimentVariant
final String? experimentVariant;
/// 会话 ID(与 VIP 卡片统计 session 对齐)
final String? sessionId;
/// 短剧:当前剧 ID
final String? mediaId;
/// 短剧:当前分集 ID
final String? contentId;
/// 短剧:本次付费墙上下文,服务端据此把充值/开卡订单归因到这一集
final String? checkoutContextId;
const PayOrderTrackInfo({
this.sourcePage,
this.sourceRef,
this.videoId,
this.activityId,
this.experimentId,
this.experimentVariant,
this.sessionId,
this.mediaId,
this.contentId,
this.checkoutContextId,
});
PayOrderTrackInfo copyWith({
PaySourcePage? sourcePage,
String? sourceRef,
String? videoId,
String? activityId,
String? experimentId,
String? experimentVariant,
String? sessionId,
String? mediaId,
String? contentId,
String? checkoutContextId,
}) {
return PayOrderTrackInfo(
sourcePage: sourcePage ?? this.sourcePage,
sourceRef: sourceRef ?? this.sourceRef,
videoId: videoId ?? this.videoId,
activityId: activityId ?? this.activityId,
experimentId: experimentId ?? this.experimentId,
experimentVariant: experimentVariant ?? this.experimentVariant,
sessionId: sessionId ?? this.sessionId,
mediaId: mediaId ?? this.mediaId,
contentId: contentId ?? this.contentId,
checkoutContextId: checkoutContextId ?? this.checkoutContextId,
);
}
}
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/common_dialog.dart';
import '../../../routers/jump_router.dart';
import '../../../tools_base/global_store/store.dart';
class PaySuccessAlert extends StatelessWidget {
const PaySuccessAlert({super.key});
@override
Widget build(BuildContext context) {
return CommonDialog(
child: _buildContent(),
);
}
_buildContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"支付确认",
style: TextStyle(
fontSize: 20,
color: Colors.white.withValues(alpha: .9),
fontWeight: FontWeight.w500,
),
),
12.sizeBoxH,
0.5.line,
12.sizeBoxH,
Text(
'''1.订单支付后,一般会在1-10分钟内到账,如超时未到账,请联系在线客服为您处理。
2.受特殊行业限制,如支付失败可尝试重新发起订单,系统将会随机切换备用的支付通道。
3.本APP有稳定的广告收益,产品稳定安全,请放心充值使用,如遇报毒提示忽略即可。''',
style: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 14,
height: 1.8,
),
),
12.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () => pushToCustomService(),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'支付遇到问题',
style: TextStyle(color: Color(0xff999999), fontSize: 14),
),
2.sizeBoxW,
Icon(
Icons.arrow_forward_ios_outlined,
size: 14,
color: Colors.white,
)
],
)),
24.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () {
globalStore.updateUserInfo();
Get.back();
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(3)),
color: AppColors.actionRed,
),
child: Text(
"支付成功",
style: TextStyle(
fontSize: 16,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
],
);
}
}
@@ -0,0 +1,149 @@
import 'package:flutter/scheduler.dart';
import 'package:hgdj/hj_model/mine/vip_card_analytics_event.dart';
import 'package:hgdj/hj_page/mine/mine_vip/vip_product_manager.dart';
import 'package:hgdj/hj_page/mine/mine_vip/vip_support_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/date_time_util.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:uuid/uuid.dart';
/// VIP 卡片统计:一次进入会员卡页/购买弹窗对应一个 session,关闭时按是否下单上报。
/// 曝光在 UI 实际展示后(post-frame)上报。
class VipCardAnalyticsSession {
VipCardAnalyticsSession() {
// 进页时快照实验上下文:关闭时 manager 的原始数据可能已被重拉/失效,不能再读 live manager
_experimentActive = vipProductManager.isExperimentActive;
_experimentId = vipProductManager.experimentId;
_variant = vipProductManager.variant;
_active.add(this);
}
static final _uuid = Uuid();
static final Set<VipCardAnalyticsSession> _active = {};
/// 当前访问会话 ID(同一次进入会员卡 UI 内事件共用)
final String sessionId = _uuid.v4();
/// 进页瞬间的实验快照(关闭上报必须用这份,避免中途 force 重拉把 live 态冲掉)
late final bool _experimentActive;
late final String? _experimentId;
late final String? _variant;
bool _pageViewReported = false;
bool _purchased = false;
bool _closed = false;
String? _lastImpressedProductId;
/// 同一帧内 jumpToPage + onPageChanged 去重
String? _pendingImpressionProductId;
static String _nowUtc() =>
DateTimeUtil.format2utc(DateTime.now().toUtc()) ?? '';
/// DISABLED / 无实验 ID:不上报(空 experimentId 会参数错误)
bool get _canReport {
if (!_experimentActive) return false;
final id = _experimentId;
return id != null && id.isNotEmpty;
}
VipCardAnalyticsEvent _build({
required String eventName,
String? productId,
int? price,
}) {
return VipCardAnalyticsEvent(
eventId: _uuid.v4(),
eventName: eventName,
sessionId: sessionId,
occurredAt: _nowUtc(),
experimentId: _experimentId,
variant: _variant,
productId: productId,
price: price,
);
}
Future<void> _send(List<VipCardAnalyticsEvent> events) async {
if (!_canReport || events.isEmpty) return;
try {
await MineService.reportAnalyticsEvents(events);
} catch (e) {
debugLog('VipCardAnalytics', e);
}
}
/// 卡皮页展示:UI 就绪后只报一次
void reportPageViewAfterPaint() {
if (_pageViewReported || _closed || !_canReport) return;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_pageViewReported || _closed || !_canReport) return;
_pageViewReported = true;
_send([_build(eventName: VipCardAnalyticsEventName.pageView)]);
});
}
/// 套餐曝光:选中 / 默认选中各算一次;同 product 连续重复不重复报
void reportProductImpression(VipProductModel? product,
{bool afterPaint = false}) {
final productId = product?.productID;
if (productId == null || productId.isEmpty || _closed || !_canReport)
return;
if (_lastImpressedProductId == productId) return;
void doReport() {
if (_closed || !_canReport) return;
if (_lastImpressedProductId == productId) return;
_lastImpressedProductId = productId;
_pendingImpressionProductId = null;
_send([
_build(
eventName: VipCardAnalyticsEventName.productImpression,
productId: productId,
price: product?.discountedPrice,
),
]);
}
if (afterPaint) {
_pendingImpressionProductId = productId;
SchedulerBinding.instance.addPostFrameCallback((_) {
if (_pendingImpressionProductId != productId) return;
doReport();
});
} else {
doReport();
}
}
/// 下单成功:本访问内关闭不再报「无购买关闭」
void markPurchased() => _purchased = true;
/// 活跃会话均标记已购买(支付页成功回调用)
static void markPurchaseOnActiveSessions() {
for (final s in _active) {
s.markPurchased();
}
}
/// 无购买关闭
void reportCloseWithoutPurchaseIfNeeded() {
if (_closed) return;
_closed = true;
_active.remove(this);
if (_purchased) {
debugLog(
'VipCardAnalytics', 'skip CLOSE_WITHOUT_PURCHASE: already purchased');
return;
}
if (!_canReport) {
debugLog(
'VipCardAnalytics',
'skip CLOSE_WITHOUT_PURCHASE: canReport=false active=$_experimentActive id=$_experimentId',
);
return;
}
// 关闭瞬间触发上报;不 await,避免卡住 onClose,但请求走全局 http 不会随页面 dispose 取消
_send([_build(eventName: VipCardAnalyticsEventName.closeWithoutPurchase)]);
}
}
@@ -0,0 +1,883 @@
import 'dart:async';
import 'dart:math' as 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_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../main_page/provider/msg_provider.dart';
import 'vip_product_manager.dart';
import 'vip_support_model.dart';
import 'vip_ui_kit.dart';
class VipCardItem extends StatefulWidget {
final Function()? callback;
final bool isSelect;
final VipProductModel model;
const VipCardItem(this.model,
{super.key, this.callback, this.isSelect = false});
@override
State<StatefulWidget> createState() {
return _VipCardItemState();
}
}
class _VipCardItemState extends State<VipCardItem> {
/// 分层倒计时停表后(如已是会员),新人卡仍需本地秒级刷新
final ValueNotifier<int> _localTick = ValueNotifier<int>(0);
Timer? _localTimer;
/// 仅按套餐 badgeType 决定角标,不看名称 / sort / 默认选中 / actionDesc
String get _badgeType => (widget.model.badgeType ?? '').trim().toUpperCase();
String? get _badgeLabel {
switch (_badgeType) {
case 'MOST_POPULAR':
case 'NEW_USER_OFFER':
final text = widget.model.badgeText?.trim() ?? '';
if (text.isNotEmpty) return text;
// NEW_USER_OFFER 无文案时兜底
return _badgeType == 'NEW_USER_OFFER' ? '新人特惠' : null;
default:
// 角标只认 badgeType / badgeText,不回退 actionDesc / desc
return null;
}
}
bool get _isMostPopularBadge => _badgeType == 'MOST_POPULAR';
/// 仅 productType==5(新人卡)展示倒计时
bool get _isNewerOfferCard => widget.model.productType == 5;
/// 当前应展示的倒计时:仅新人卡;优先分层 lastDiscountTime;会员态无分层时用卡 showCountdownTime(小时) 滚动
({String hour, String min, String sec})? get _countdownParts {
if (!_isNewerOfferCard) return null;
final layered = MineMsgProvider().countdownConfig;
if (layered != null) {
return (
hour: layered.discountHour,
min: layered.discountMin,
sec: layered.discountSec
);
}
final hours = widget.model.showCountdownTime ?? 0;
if (hours <= 0) return null;
final period = hours * 3600;
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
var remain = period - (nowSec % period);
if (remain <= 0) remain = period;
final h = (remain ~/ 3600).toString().padLeft(2, '0');
final m = ((remain ~/ 60) % 60).toString().padLeft(2, '0');
final s = (remain % 60).toString().padLeft(2, '0');
return (hour: h, min: m, sec: s);
}
bool get _needLocalTick =>
_isNewerOfferCard &&
MineMsgProvider().countdownConfig == null &&
(widget.model.showCountdownTime ?? 0) > 0;
@override
void initState() {
super.initState();
_syncLocalTimer();
}
@override
void didUpdateWidget(covariant VipCardItem oldWidget) {
super.didUpdateWidget(oldWidget);
_syncLocalTimer();
}
@override
void dispose() {
_localTimer?.cancel();
_localTick.dispose();
super.dispose();
}
void _syncLocalTimer() {
if (_needLocalTick) {
_localTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
_localTick.value++;
});
} else {
_localTimer?.cancel();
_localTimer = null;
}
}
String get _title => (widget.model.newName?.trim().isNotEmpty == true)
? widget.model.newName!.trim()
: (widget.model.productName ?? '');
@override
Widget build(BuildContext context) {
_syncLocalTimer();
// variant=B:组合卡 UIA / DISABLED:接口卡图
if (vipProductManager.isNewVipUi) {
return _buildVariantBCard();
}
return _buildClassicImageCard();
}
/// 旧版:选中/未选中靠接口下发卡面图 + 角标/新人倒计时叠加
Widget _buildClassicImageCard() {
final badgeLabel = _badgeLabel;
return Material(
color: Colors.transparent,
child: InkWell(
enableFeedback: false,
onTap: widget.callback,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Stack(
children: [
Positioned(
left: 0,
top: 9,
bottom: 10,
right: 0,
child: NetworkImageLoader(
imageUrl: widget.isSelect
? (widget.model.realSelectVipImage().isEmpty
? widget.model.realNormalVipImage()
: widget.model.realSelectVipImage())
: widget.model.realNormalVipImage(),
fit: BoxFit.fill,
),
),
if (badgeLabel != null)
Positioned(
right: 0,
top: 0,
child: _buildBadge(badgeLabel),
),
if (_isNewerOfferCard)
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
alignment: Alignment.bottomCenter,
child: _buildNewerTimer(),
),
),
],
),
),
),
);
}
/// variant=B148×157 组合卡;选中金渐变+#B2FFF6DC 边;未选中 #211C12+#14FFFFFF 边
Widget _buildVariantBCard() {
final selected = widget.isSelect;
final badgeLabel = _badgeLabel;
const cardW = 148.0;
const cardH = 157.0;
const badgeTop = 8.0;
// VIP/Glow GoldY10+Blur28,底部预留绘制空间,避免被轮播裁切
const glowPadBottom = 24.0;
return Material(
color: Colors.transparent,
child: InkWell(
enableFeedback: false,
onTap: widget.callback,
child: SizedBox(
width: cardW,
height: cardH + badgeTop + glowPadBottom,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
top: badgeTop,
height: cardH,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
boxShadow: selected
? const [
// VIP/Glow GoldY10 / Blur28 / Spread-6 / #DEAB54 35%
BoxShadow(
color: Color(0x59DEAB54),
offset: Offset(0, 10),
blurRadius: 28,
spreadRadius: -6,
),
]
: null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
// 底色 / 渐变
DecoratedBox(
decoration: BoxDecoration(
color: selected ? null : const Color(0xff211C12),
gradient: selected
? const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffFBF0CD),
Color(0xffE5B75D)
],
)
: null,
),
),
// 内容(含底栏 desc
Column(
children: [
Expanded(child: _buildBCardBody(selected)),
_buildBCardFooter(selected),
],
),
// 边框置顶,避免被底栏遮住;新人卡选中不描边
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
border: (selected && _isNewerOfferCard)
? null
: Border.all(
color: selected
? const Color(0xB2FFF6DC)
: const Color(0x14FFFFFF),
width: 1,
),
),
),
),
),
],
),
),
),
),
if (badgeLabel != null)
Positioned(
right: 0,
top: 0,
child: _buildBadge(badgeLabel),
),
],
),
),
),
);
}
Widget _buildBCardBody(bool selected) {
final titleColor = selected ? const Color(0xff3B2B0E) : Color(0xffF6EEDC);
final priceColor =
selected ? const Color(0xff3B2B0E) : const Color(0xffF6EEDC);
final originColor =
selected ? const Color(0xff6C644F) : const Color(0xff9A927C);
final showOrigin = widget.model.originalPriceUI > 0 &&
widget.model.originalPriceUI != widget.model.discountedPriceUI;
return Padding(
padding: const EdgeInsets.fromLTRB(10, 22, 10, 8),
child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: titleColor,
fontSize: 15,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
10.sizeBoxH,
EasyRichText(
'¥${widget.model.discountedPriceUI}',
defaultStyle: TextStyle(
color: priceColor,
fontSize: 40,
fontWeight: FontWeight.w700,
height: 1,
),
patternList: [
EasyRichTextPattern(
targetString: '¥',
matchWordBoundaries: false,
matchOption: 'first',
style: TextStyle(
color: priceColor,
fontSize: 17,
fontWeight: FontWeight.w800,
height: 1,
),
),
],
),
if (showOrigin) ...[
4.sizeBoxH,
Text(
'¥${widget.model.originalPriceUI}',
style: TextStyle(
color: originColor,
fontSize: 13,
decoration: TextDecoration.lineThrough,
decorationColor: originColor,
height: 1.2,
),
),
] else ...[
// 无原价时占位短横,对齐设计稿
10.sizeBoxH,
Text(
'-',
style: TextStyle(color: originColor, fontSize: 13, height: 1.2),
),
],
],
),
);
}
Widget _buildBCardFooter(bool selected) {
if (!_isNewerOfferCard) return _buildBDurationFooter(selected);
return ValueListenableBuilder<int>(
valueListenable: _needLocalTick ? _localTick : MineMsgProvider().tick,
builder: (_, __, ___) {
final cd = _countdownParts;
if (cd != null) {
if (selected) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 7),
alignment: Alignment.center,
color: const Color(0xffC14A38),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_timerBox(cd.hour),
_timerColon(),
_timerBox(cd.min),
_timerColon(),
_timerBox(cd.sec),
4.sizeBoxW,
const Text(
'结束',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600),
),
],
),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 8),
alignment: Alignment.center,
color: const Color(0xff16140F),
child: Text(
'${cd.hour}:${cd.min}:${cd.sec}后失效',
style: const TextStyle(
color: Color(0xff8A8A8A),
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
);
}
return _buildBDurationFooter(selected);
},
);
}
/// 无倒计时时的底栏:取套餐 desc
Widget _buildBDurationFooter(bool selected) {
final text = widget.model.desc?.trim() ?? '';
if (text.isEmpty) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
alignment: Alignment.center,
color: selected ? const Color(0xff2B2314) : const Color(0xff1A150C),
child: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: selected ? const Color(0xffFFFFFF) : const Color(0xff9A927C),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildBadge(String label) {
final isMostPopular = _isMostPopularBadge;
final useB = vipProductManager.isNewVipUi;
// B 未选中:统一深底金边/金字;B 选中 / A:优先 uiConfig.badgeStyles
final useApiStyle = useB ? widget.isSelect : true;
Color? apiBg;
Color? apiFg;
if (useApiStyle) {
final style = vipProductManager.badgeStyleFor(_badgeType);
apiBg = _parseHexColor(style?.backgroundColor);
apiFg = _parseHexColor(style?.textColor);
}
final Color bg;
final Color fg;
final Border? border;
if (useB && !widget.isSelect) {
bg = const Color(0xCC1A1A1A);
fg = const Color(0xFFE8C078);
border = Border.all(color: const Color(0xFFE8C078), width: 0.8);
} else {
bg = apiBg ??
(isMostPopular ? const Color(0xCC1A1A1A) : const Color(0xffE1351F));
fg = apiFg ??
(isMostPopular ? const Color(0xFFE8C078) : const Color(0xE5FFFFFF));
border = (apiBg == null && isMostPopular)
? Border.all(color: const Color(0xFFE8C078), width: 0.8)
: null;
}
return Container(
constraints: const BoxConstraints(maxWidth: 111),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: bg,
border: border,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: fg,
fontWeight: FontWeight.w500,
fontSize: 10.0,
),
),
);
}
/// 解析 #RRGGBB / RRGGBB / #AARRGGBB;非法或空返回 null
Color? _parseHexColor(String? raw) {
var hex = (raw ?? '').trim().toUpperCase().replaceAll('#', '');
if (hex.isEmpty) return null;
if (hex.length == 6) hex = 'FF$hex';
if (hex.length != 8) return null;
final value = int.tryParse(hex, radix: 16);
return value == null ? null : Color(value);
}
/// A / DISABLED 旧版新人倒计时:卡底橙色小标签「HH:MM:SS」
Widget _buildNewerTimer() {
return ValueListenableBuilder<int>(
valueListenable: _needLocalTick ? _localTick : MineMsgProvider().tick,
builder: (_, __, ___) {
final cd = _countdownParts;
if (cd == null) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: const BoxDecoration(
color: Color(0xffF68804),
borderRadius: BorderRadius.all(Radius.circular(4)),
),
child: Text(
'${cd.hour}:${cd.min}:${cd.sec}',
style: const TextStyle(color: Color(0xff000000), fontSize: 10),
),
);
},
);
}
Widget _timerBox(String value) {
return Container(
width: 17,
height: 16,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Colors.white, width: 1),
),
child: Text(
value,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w500,
height: 1.0),
),
);
}
Widget _timerColon() => const Padding(
padding: EdgeInsets.symmetric(horizontal: 3),
child: Text(':',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w600)),
);
}
class VipProductPrivilegeItem extends StatelessWidget {
final NewPrivilege? model;
final bool isShowOnePrivilege;
/// true:旧版白字圆图标样式(会员中心 A / DISABLED
final bool classic;
const VipProductPrivilegeItem(
this.model, {
super.key,
this.isShowOnePrivilege = false,
this.classic = false,
});
@override
Widget build(BuildContext context) {
if (classic) {
return Column(
children: [
NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 50,
height: 50,
borderRadius: 25,
),
3.sizeBoxH,
Text(
model?.privilegeName ?? '',
maxLines: 1,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: .9)),
),
if (!isShowOnePrivilege) ...[
4.sizeBoxH,
Text(
_descLine(0),
maxLines: 1,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 10,
),
textAlign: TextAlign.center,
),
4.sizeBoxH,
Text(
_descLine(1),
maxLines: 2,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 9,
),
textAlign: TextAlign.center,
),
2.sizeBoxH,
],
],
);
}
return Column(
children: [
NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 44,
height: 44,
borderRadius: 22,
),
6.sizeBoxH,
VipGradientMask(
child: Text(
model?.privilegeName ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12, fontWeight: FontWeight.w500, color: Colors.white),
),
),
if (!isShowOnePrivilege) ...[
4.sizeBoxH,
Text(
_descLine(0),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 10,
),
textAlign: TextAlign.center,
),
2.sizeBoxH,
Text(
_descLine(1),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white.withValues(alpha: .45),
fontWeight: FontWeight.w400,
fontSize: 9,
),
textAlign: TextAlign.center,
),
],
],
);
}
String _descLine(int index) {
final raw = model?.privilegeDesc ?? '';
final bySpace = raw.split(' ');
if (bySpace.length > 1) {
return index < bySpace.length ? bySpace[index].trim() : '';
}
final byLine = raw.split('\n');
return index < byLine.length ? byLine[index].trim() : '';
}
}
/// 核心权益卡片
/// - classic=false:左图标 + 右标题/描述(会员中心 B / 购买弹窗)
/// - classic=true72×72 金边方卡(会员中心 A / DISABLED
class VipCorePrivilegeCard extends StatelessWidget {
final NewPrivilege? model;
final bool classic;
const VipCorePrivilegeCard(this.model, {super.key, this.classic = false});
static const _radius = 16.0;
static const _fillGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x12FFFFFF), // #FFFFFF 7.06%
Color(0x05FFFFFF), // #FFFFFF 7.06%
],
);
static const _borderGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x29FFFFFF), // #FFFFFF 16.08%
Color(0x0AFFFFFF), // #FFFFFF 3.92%
],
);
@override
Widget build(BuildContext context) {
if (classic) return _buildClassic();
return Container(
width: 168,
height: 64,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_radius),
boxShadow: const [
BoxShadow(
color: Color(0x1AFFFFFF),
offset: Offset(0, 1),
blurRadius: 0,
),
BoxShadow(
color: Color(0x4D000000),
offset: Offset(0, 8),
blurRadius: 20,
spreadRadius: -8,
),
],
),
// 渐变描边用 CustomPaint 画 stroke,避免双层 Container「假边框」透出底层背景
child: CustomPaint(
painter: _GradientBorderPainter(
gradient: _borderGradient,
strokeWidth: 1,
radius: _radius,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(_radius),
child: DecoratedBox(
decoration: const BoxDecoration(gradient: _fillGradient),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Row(
children: [
_buildIcon(),
10.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
model?.privilegeName ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xffF6EEDC),
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
4.sizeBoxH,
_buildDesc(model?.privilegeDesc ?? ''),
],
),
),
],
),
),
),
),
),
);
}
/// 旧版:正方形金框 + 深棕底图 + 图标/标题/描述
Widget _buildClassic() {
return Container(
width: 72,
height: 72,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('core_privilege_bg.webp'.videoPath),
fit: BoxFit.fill,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xffFFF576), width: 0.5),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 22,
height: 22,
borderRadius: 0),
5.sizeBoxH,
ShaderMask(
shaderCallback: (bounds) => const LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xffFF9642), Color(0xffFFE7BD), Color(0xffFCCF36)],
stops: [0.066, 0.49, 0.914],
).createShader(bounds),
blendMode: BlendMode.srcIn,
child: Text(
model?.privilegeName ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
3.sizeBoxH,
Text(
model?.privilegeDesc ?? '',
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xffB7A463), fontSize: 9),
),
],
),
);
}
Widget _buildIcon() {
return Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0x14FFFFFF),
border: Border.all(color: const Color(0x66DCAD55), width: 0.8),
),
child: NetworkImageLoader(
imageUrl: model?.img ?? '',
width: 26,
height: 26,
borderRadius: 0,
),
);
}
/// 设计稿描述:前半灰、后半金(支持空格分隔;否则前 4 字灰、其余金)
Widget _buildDesc(String raw) {
if (raw.isEmpty) return const SizedBox.shrink();
return Text(
raw,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Color(0xff9A927C), fontSize: 11, height: 1.2),
);
}
}
/// 1px 内渐变描边(stroke),不占用布局厚度,避免双层 Container 透底
class _GradientBorderPainter extends CustomPainter {
final Gradient gradient;
final double strokeWidth;
final double radius;
_GradientBorderPainter({
required this.gradient,
required this.strokeWidth,
required this.radius,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final inset = strokeWidth / 2;
final rrect = RRect.fromRectAndRadius(
rect.deflate(inset),
Radius.circular(math.max(0, radius - inset)),
);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..shader = gradient.createShader(rect);
canvas.drawRRect(rrect, paint);
}
@override
bool shouldRepaint(covariant _GradientBorderPainter oldDelegate) {
return oldDelegate.gradient != gradient ||
oldDelegate.strokeWidth != strokeWidth ||
oldDelegate.radius != radius;
}
}
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_alert.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import 'mine_charge_vip_logic.dart';
import 'vip_support_model.dart';
/// 会员支付按钮:会员卡页 MineChargeVipPage 与购买弹窗 BuyVipAlert 共用
/// [classic] true:旧版红底/升级金橙;false:改版金渐变 CTA
class VipPayButton extends StatelessWidget {
final MineChargeVipLogic logic;
final bool classic;
const VipPayButton(this.logic, {super.key, this.classic = false});
static const _ctaGradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xffFBEDC2),
Color(0xffEFCB84),
Color(0xffD9A346),
],
);
@override
Widget build(BuildContext context) {
final product = logic.currentProductModel;
//预售首付或非升级预售 → 预售按钮(透传会员中心 sessionId / 实验字段)
if (product?.isPreSale == true && product?.isUpgrade == false) {
return PreSaleActivityButton(onTap: () async {
await PreSaleProvider()
.startPay(product!, orderTrack: logic.buildOrderTrack());
logic.update();
});
}
if (classic) return _buildClassic(product);
return GestureDetector(
onTap: logic.onInitiatePayAction,
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(22),
boxShadow: const [
BoxShadow(
color: Color(0x66DEAB54),
offset: Offset(0, 10),
blurRadius: 30,
spreadRadius: -6,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
alignment: Alignment.center,
children: [
const DecoratedBox(
decoration: BoxDecoration(gradient: _ctaGradient),
child: SizedBox.expand(),
),
Positioned(
top: 0,
left: 0,
right: 0,
height: 3,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0x8CFFFFFF),
const Color(0x00FFFFFF),
],
),
),
),
),
),
Text(
_payText,
style: const TextStyle(
color: Color(0xff3D2914),
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
/// 旧版:升级卡金橙渐变 / 普通卡红底
Widget _buildClassic(VipProductModel? product) {
final isUpgrade = product?.isUpgrade ?? false;
return GestureDetector(
onTap: logic.onInitiatePayAction,
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
gradient: isUpgrade
? const LinearGradient(
colors: [Color(0xFFFFAF50), Color(0xFFE75100)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
)
: null,
color: isUpgrade ? null : AppColors.actionRed,
),
child: Text(
_payText,
style: const TextStyle(
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
),
),
);
}
String get _payText {
final product = logic.currentProductModel;
if (product == null) return '';
if (product.isPreSale) {
if (product.isUpgrade ?? false) {
return '${(product.advanceAmount ?? 0) ~/ 10}元/立即升级';
}
if (PreSaleProvider().canPayBalance) {
return '¥${PreSaleProvider().preSaleModel?.detailModel?.balanceAmount ?? 0}/支付尾款';
}
return '¥${PreSaleProvider().preSaleModel?.detailModel?.advanceAmount ?? 0}/立即预订';
}
if (product.isUpgrade ?? false) {
return '¥${product.discountedPriceUI}/补差价升级';
}
return '¥${product.discountedPriceUI}/立即支付';
}
}
@@ -0,0 +1,252 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_helper.dart';
import 'package:hgdj/tools_base/toast.dart';
import '../../../hj_page/main_page/provider/msg_provider.dart';
import '../../../hj_page/pre_sale/pre_sale_provider.dart';
import 'online_pay_page.dart';
import 'pay_order_source.dart';
import 'vip_card_analytics.dart';
import 'vip_support_model.dart';
/// 会员卡 / 短剧卡列表统一数据源(全站唯一):请求 / 缓存 / 组装 / 选卡兜底 / 按卡ID直接拉支付。
/// 会员中心页、购买弹窗、分层横幅等「需要卡列表」的地方用 Consumer<VipProductManager> 消费。
/// 页面级状态(当前选中卡 / 轮播控制器 / 支付触发)仍留在各自 Logic,避免多入口串味。
class VipProductManager with ChangeNotifier {
static final VipProductManager _instance = VipProductManager._();
factory VipProductManager() => _instance;
VipProductManager._();
/// 按卡 id 取卡,会员卡和短剧卡共用。id 空/匹配不上都给 null,让调用方用 `??` 往下一级兜。
/// 空串必须当「未指定」:分层/实验/短剧默认卡没配时下发的都是空串,
/// 不拦会跟 productID 为空的脏数据撞上
VipProductModel? _pick(List<VipProductModel> list, String? id) =>
(id ?? '').isEmpty
? null
: list.firstWhereOrNull((e) => e.productID == id);
// ===== 会员卡数据 =====
/// 原始接口数据(含代充/预售卡组/升级卡等 / 实验态)
VipSupportModel? _raw;
/// 分层变化等场景标记需重拉;不清 _raw,避免已挂载会员页丢失实验态(B 皮闪回 A)
bool _needsReload = false;
/// 组装后的会员卡列表(常规卡 + 符合条件的预售卡,按 sort 排序)——全站共享一份
final vipCards = <VipProductModel>[];
/// 加载态
bool isLoading = true;
/// 预售卡分组(会员中心页据此判断是否显示预售入口)
VipProductListModel? presaleGroup;
// ===== A/B 实验 =====
/// 实验是否生效(ACTIVE);DISABLED 时不挂实验字段、不上报 analytics、topay 不传实验参数
bool get isExperimentActive => _raw?.isExperimentActive == true;
/// 会员中心 UI:仅 ACTIVE 且 variant=B 用改版;A / DISABLED / 无实验时 variant 恒为 null,自然走旧版
bool get isNewVipUi => (variant ?? '').trim().toUpperCase() == 'B';
/// 实验字段(仅 ACTIVE 时有值,下单时原样回传)
String? get experimentId => isExperimentActive ? _raw?.experimentId : null;
String? get variant => isExperimentActive ? _raw?.variant : null;
/// 实验指定的默认卡 id。和短剧卡的 [_dramaCardId] 是两码事,别混
String? get _abCardId => isExperimentActive ? _raw?.defaultProductId : null;
/// 皮肤配置:ACTIVE 时才消费;DISABLED / 无实验不走接口皮肤
VipUiConfig? get _uiConfig => isExperimentActive ? _raw?.uiConfig : null;
/// 会员中心背景图:接口给了就用网络图,否则 null(走本地默认)
String? get vipBgImage {
final url = (_uiConfig?.backgroundImage ?? '').trim();
return url.isEmpty ? null : url;
}
/// 角标配色:按套餐 badgeType 匹配皮肤配置
VipBadgeStyle? badgeStyleFor(String? badgeType) =>
_uiConfig?.styleFor(badgeType);
// ===== 会员卡:请求 / 组装 =====
/// 请求会员卡列表。[force] 为 false 且缓存有效时,直接用缓存重组,不发请求。
Future<void> loadVipCards({bool force = false}) async {
if (_raw != null && !force && !_needsReload) {
_rebuild();
return;
}
isLoading = true;
notifyListeners();
final result = await MineService.getVipProduct();
// 容错:请求失败或返回空数据时,保留上次成功的数据,只复位加载态,不覆盖 _raw/vipCards
if (result == null || (result.list?.isEmpty ?? true)) {
isLoading = false;
notifyListeners();
return;
}
_raw = result;
_needsReload = false;
_rebuild();
}
/// 缓存失效:标记下次 load 强制重拉;保留 _raw/vipCards,避免购卡后已挂载页实验皮闪回 A。
/// (分层状态变化时调用)
void invalidate() {
_needsReload = true;
}
/// 切换账号后:清掉上个用户的数据并按新用户权限重新拉取。
/// 卡列表(尤其预售卡)由后端按用户权限/购买状态返回,切号必须重拉,否则残留上个账号的卡。
Future<void> reloadForUser() async {
_raw = null;
_needsReload = true;
vipCards.clear();
await loadVipCards(force: true);
}
/// 用 _raw 组装 vipCards(会员卡组 + 符合条件的预售卡)
void _rebuild() {
isLoading = false;
presaleGroup = _raw?.list?.firstWhereOrNull((e) => e.position == '预售卡');
vipCards
..clear()
..addAll(_assemble(_raw));
notifyListeners();
}
/// 纯组装:会员卡组 + 符合条件的预售卡。无副作用,供 _rebuild 与 payByVipCard 共用。
/// 卡序统一按 sort 升序(含卡皮 ACTIVE
List<VipProductModel> _assemble(VipSupportModel? raw) {
final cardGroup = raw?.list?.firstWhereOrNull((e) => e.position == '会员卡');
final presale = raw?.list?.firstWhereOrNull((e) => e.position == '预售卡');
final list = <VipProductModel>[...(cardGroup?.list ?? [])];
// 优先添加预售卡(预售活动中且未付全款)
if ((presale?.list ?? []).isNotEmpty &&
PreSaleProvider().advanceStatus?.activityStatus == true &&
!PreSaleProvider().isPayAll) {
list.add(presale!.list!.first..isPreSale = true);
}
list.sort((a, b) => (a.sort ?? 0).compareTo(b.sort ?? 0));
return list;
}
// ===== 会员卡:选卡 / 支付 =====
/// 默认选中卡:外部指定卡ID > 分层卡 > 实验默认卡 > 升级差价卡 > 保留当前 > 第一张。
/// 选中态归页面 Logic,这里只按 vipCards 计算,[vipID]/[current] 由调用方透传。
VipProductModel? defaultVipCard({String? vipID, VipProductModel? current}) {
if (vipCards.isEmpty) return null;
return _pick(vipCards, vipID) ??
_pick(vipCards, MineMsgProvider().payTier?.config?.vipCard) ??
_pick(vipCards, _abCardId) ??
(globalStore.meInfo?.isUpgrade == true
? _cheapestUpgrade(vipCards)
: null) ??
_pick(vipCards, current?.productID) ?? // 二次刷新时保留当前选中
vipCards.first;
}
/// 按卡 ID 直接拉起支付弹窗(跳过会员中心列表页)。无数据时先请求一次。
/// 选卡:传入 [cardId] > 分层卡 > 实验默认卡 > 升级差价卡 > 第一张
/// [reportAnalytics] 是否上报 VIP 卡皮事件(PAGE_VIEW / 曝光 / CLOSE);视频底部分层 banner 等入口传 false
Future<void> payByVipCard(
String? cardId, {
PayOrderTrackInfo? orderTrack,
bool reportAnalytics = true,
}) async {
var raw = _raw;
if (raw == null || _needsReload) {
LoadingHelper.showLoading();
try {
raw = await MineService.getVipProduct();
} finally {
LoadingHelper.dismissLoading(); //无论成功/异常都关 loading,避免请求抛异常时卡死
}
// 缓存供后续复用,但不触碰 isLoading/vipCards/notify,避免打扰已挂载的会员卡列表 UI
if (raw != null && (raw.list?.isNotEmpty ?? false)) {
_raw = raw;
_needsReload = false;
}
}
// 用当前预售状态现算列表(不依赖共享 vipCards,也不受其重拉影响)
final list = _assemble(raw);
if (list.isEmpty) {
showToast("未获取到会员卡~");
return;
}
final card = _pick(list, cardId) ??
_pick(list, MineMsgProvider().payTier?.config?.vipCard) ??
_pick(list, _abCardId) ??
(globalStore.meInfo?.isUpgrade == true
? _cheapestUpgrade(list)
: null) ??
list.first;
if (card.rchgTypeUI.isEmpty) {
showToast("未配置支付方式,请联系客服");
return;
}
// 直拉支付弹窗没有 MineChargeVipLogic:需要卡皮埋点时自建 session,关弹窗报 CLOSE
final analytics = reportAnalytics ? VipCardAnalyticsSession() : null;
final base = orderTrack ?? const PayOrderTrackInfo();
final track = PayOrderTrackInfo(
sourcePage: base.sourcePage ?? PaySourcePage.unknown,
sourceRef: base.sourceRef,
videoId: base.videoId,
activityId: base.activityId,
sessionId: base.sessionId ?? analytics?.sessionId,
//短剧付费墙的归因字段,重建时漏抄就丢了(同 OnlinePayPage._payByLink)
mediaId: base.mediaId,
contentId: base.contentId,
checkoutContextId: base.checkoutContextId,
// DISABLED:正常下单但不传实验字段
experimentId: isExperimentActive
? (base.experimentId ?? card.experimentId ?? experimentId)
: null,
experimentVariant: isExperimentActive
? (base.experimentVariant ?? card.variant ?? variant)
: null,
);
analytics?.reportPageViewAfterPaint();
analytics?.reportProductImpression(card, afterPaint: true);
try {
await Get.bottomSheet(
OnlinePayPage(vipProductModel: card, orderTrack: track),
isScrollControlled: true,
);
} finally {
analytics?.reportCloseWithoutPurchaseIfNeeded();
}
}
/// 多张可升级卡挑一张:待付价低优先,同价预售卡优先;无可升级卡返回 null
VipProductModel? _cheapestUpgrade(List<VipProductModel> list) {
final upgrades = list.where((e) => e.isUpgrade == true).toList();
if (upgrades.isEmpty) return null;
upgrades.sort((a, b) {
final cmp = _upgradePrice(a).compareTo(_upgradePrice(b));
if (cmp != 0) return cmp;
return a.isPreSale ? -1 : 1; //同价预售卡优先
});
return upgrades.first;
}
/// 升级比价用价:预售卡用实际待付(已付首款=尾款 balanceAmount / 未付=定金 advanceAmount),普通卡用 discountedPrice(对齐 mrhs)
int _upgradePrice(VipProductModel card) {
if (card.isPreSale) {
return PreSaleProvider().isPayFirst
? (card.balanceAmount ?? 0)
: (card.advanceAmount ?? 0);
}
return card.discountedPrice ?? 0;
}
}
/// 全站唯一实例(对齐 presaleProvider / globalStore 的顶层单例引用)
final vipProductManager = VipProductManager();
@@ -0,0 +1,778 @@
//会员支持model
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_page/pre_sale/pre_sale_provider.dart';
import '../../../hj_model/mine/exchange/dc_model.dart';
/// /vip/product 实验状态:ACTIVE 生效,DISABLED 不可用(回原套餐,无实验字段)
abstract class VipExperimentStatus {
static const active = 'ACTIVE';
static const disabled = 'DISABLED';
}
/// uiConfig.badgeStyles 单项:按 badgeType 匹配角标底色/字色
class VipBadgeStyle {
String? badgeType;
String? backgroundColor;
String? textColor;
VipBadgeStyle({this.badgeType, this.backgroundColor, this.textColor});
VipBadgeStyle.fromJson(Map<String, dynamic>? json) {
badgeType = json?['badgeType']?.toString();
backgroundColor = json?['backgroundColor']?.toString();
textColor = json?['textColor']?.toString();
}
Map<String, dynamic> toJson() => {
'badgeType': badgeType,
'backgroundColor': backgroundColor,
'textColor': textColor,
};
}
/// /vip/product.data.uiConfig:皮肤背景 + 角标样式表
class VipUiConfig {
String? backgroundImage;
List<VipBadgeStyle>? badgeStyles;
VipUiConfig({this.backgroundImage, this.badgeStyles});
VipUiConfig.fromJson(Map<String, dynamic>? json) {
backgroundImage = json?['backgroundImage']?.toString();
final raw = json?['badgeStyles'];
if (raw is List) {
badgeStyles = raw
.whereType<Map>()
.map((e) => VipBadgeStyle.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
}
Map<String, dynamic> toJson() => {
'backgroundImage': backgroundImage,
'badgeStyles': badgeStyles?.map((e) => e.toJson()).toList(),
};
/// 按 badgeType 查找样式(大小写不敏感)
VipBadgeStyle? styleFor(String? badgeType) {
final key = (badgeType ?? '').trim().toUpperCase();
if (key.isEmpty) return null;
for (final s in badgeStyles ?? const <VipBadgeStyle>[]) {
if ((s.badgeType ?? '').trim().toUpperCase() == key) return s;
}
return null;
}
}
class VipSupportModel {
DCModel? daichong;
List<IntegralList>? integralList; //积分兑换列表
bool? isNewUser;
List<VipProductListModel>? list;
List<VipProductModel>? upgradeableVipCardList;
/// A/B 实验状态:ACTIVE / DISABLED
String? experimentStatus;
/// A/B 实验:实验 ID(下单时原样回传;DISABLED 时后端不返回)
String? experimentId;
/// A/B 实验:分组 A / B(下单时原样回传)
String? variant;
/// A/B 实验:默认选中卡 ID
String? defaultProductId;
/// 皮肤 key,如 vip-card-skin-b(视觉皮肤标识,UI 分支仍以 variant 为准)
String? skinKey;
/// 皮肤 UI 配置(背景图 / 角标样式表)
VipUiConfig? uiConfig;
/// 实验是否生效(仅 ACTIVE 才挂实验字段 / 上报埋点 / 下单回传实验信息)
bool get isExperimentActive => experimentStatus == VipExperimentStatus.active;
VipSupportModel({
this.daichong,
this.integralList,
this.isNewUser,
this.list,
this.upgradeableVipCardList,
this.experimentStatus,
this.experimentId,
this.variant,
this.defaultProductId,
this.skinKey,
this.uiConfig,
});
VipSupportModel.fromJson(Map<String, dynamic>? json) {
daichong =
json?['daichong'] != null ? DCModel.fromJson(json?['daichong']) : null;
if (json?['integralList'] != null) {
integralList = <IntegralList>[];
json?['integralList'].forEach((v) {
integralList!.add(IntegralList.fromJson(v));
});
}
isNewUser = json?['isNewUser'];
experimentStatus = json?['experimentStatus']?.toString();
experimentId = json?['experimentId']?.toString();
variant = json?['variant']?.toString();
defaultProductId = json?['defaultProductId']?.toString();
skinKey = json?['skinKey']?.toString();
uiConfig = json?['uiConfig'] != null
? VipUiConfig.fromJson(json?['uiConfig'])
: null;
/// 升级卡
upgradeableVipCardList = (json?['upgradeableVipCardList'] as List?)
?.map((e) => VipProductModel.fromJson(e['product'] ?? {}))
.toList();
if (json?['list'] != null) {
list = <VipProductListModel>[];
json?['list'].forEach((v) {
VipProductListModel vipModel =
VipProductListModel.fromJson(v, daichong);
list!.add(vipModel);
});
}
// 仅 ACTIVE 时把实验上下文挂到卡上;DISABLED 时后端不带实验字段,也不 stamp
if (isExperimentActive) {
_stampAbExperiment();
}
}
void _stampAbExperiment() {
void stamp(VipProductModel p) {
p.experimentId = experimentId;
p.variant = variant;
}
for (final group in list ?? <VipProductListModel>[]) {
for (final p in group.list ?? <VipProductModel>[]) {
stamp(p);
}
}
for (final p in upgradeableVipCardList ?? <VipProductModel>[]) {
stamp(p);
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
if (daichong != null) {
data['daichong'] = daichong!.toJson();
}
if (integralList != null) {
data['integralList'] = integralList!.map((v) => v.toJson()).toList();
}
data['isNewUser'] = isNewUser;
data['experimentStatus'] = experimentStatus;
data['experimentId'] = experimentId;
data['variant'] = variant;
data['defaultProductId'] = defaultProductId;
data['skinKey'] = skinKey;
if (uiConfig != null) data['uiConfig'] = uiConfig!.toJson();
if (list != null) {
data['list'] = list!.map((v) => v.toJson()).toList();
}
return data;
}
}
class IntegralList {
String? desc;
int? duration;
String? id;
String? img;
String? name;
int? price;
int? type;
String? bgImg;
IntegralList(
{this.desc,
this.duration,
this.id,
this.img,
this.name,
this.price,
this.type});
IntegralList.fromJson(Map<String, dynamic> json) {
desc = json['desc'];
duration = json['duration'];
id = json['id'];
img = json['img'];
name = json['name'];
price = json['price'];
bgImg = json['bgImg'];
type = json['type'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['desc'] = desc;
data['duration'] = duration;
data['id'] = id;
data['img'] = img;
data['name'] = name;
data['price'] = price;
data['type'] = type;
return data;
}
}
class VipProductListModel {
List<VipProductModel>? list;
String? position;
String? positionID;
int? showType;
VipProductListModel(
{this.list, this.position, this.positionID, this.showType});
VipProductListModel.fromJson(Map<String, dynamic> json, DCModel? daichong) {
if (json['list'] != null) {
list = <VipProductModel>[];
json['list'].forEach((v) {
VipProductModel pModel = VipProductModel.fromJson(v);
pModel.daichong = daichong;
list!.add(pModel);
});
}
position = json['position'];
positionID = json['positionID'];
showType = json['showType'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
if (list != null) {
data['list'] = list!.map((v) => v.toJson()).toList();
}
data['position'] = position;
data['positionID'] = positionID;
data['showType'] = showType;
return data;
}
}
class VipProductModel {
String? actionDesc;
/// 角标类型,如 MOST_POPULAR
String? badgeType;
/// 角标文案,如「最受欢迎」
String? badgeText;
int? aiUndressCount;
String? alias;
String? bgImg;
int? chanSplitMod;
int? chatPrice;
String? createdAt;
String? desc;
int? discountedPrice;
int? discountedPriceAnd;
int? discountedPriceIos;
int? downloadCount;
int? duration;
int? everyDayGiveCoin;
String? exclusiveOffer;
int? giveCoin;
int? giveFruitCoin;
int? goldVideoCouponCount;
int? goldVideoCouponNum;
int? goldVideoFreeDay;
bool? isAmountPay; // true 支持金币支付
String? newBgImg;
String? newName;
List<NewPrivilege>? newPrivilege;
int? originalPrice;
int? payVidDiscount;
String? position;
List<int>? privilege;
String? privilegeDesc;
String? productID;
String? productName;
int? productType; //21- 预售卡
List<RchgType>? rchgType;
int? serviceTime;
int? showCountdownTime;
int? signDays;
int? sort;
bool? status;
int? timesAWeek;
int? type; //会员卡类型 1、会员卡 2、礼包卡
bool? unitPriceDisplay;
String? updatedAt;
int? videoDiscount;
String? vipCardDesc;
int? vipLevel;
String? tag;
DCModel? daichong; // 从上级数据结构手动赋值过来
/// VIP 卡皮 A/B:由 /vip/product 响应挂到卡上,下单原样回传
String? experimentId;
String? variant;
//是否VIP升级
bool? isUpgrade;
//当前VIP卡名称
String? currentVipName;
//当前VIP卡价格
String? currentVipPrice;
// 原价购买价格
int? purchasePrice;
// 预付升级价格
int? advanceAmount;
// 预售尾款
int? balanceAmount;
int get discountedPriceUI {
return (discountedPrice ?? 0) ~/ 10;
} // 现价 单位角(金币)
int? prepaidPrice;
int get prepaidPriceUI {
return (prepaidPrice ?? 0) ~/ 10;
}
int get originalPriceUI {
return (originalPrice ?? 0) ~/ 10;
} // 现价 单位角(金币)
bool isPreSale = false;
// 预售新增字段
String? endBgImg;
String? endBgSelectImg;
List<RchgType> get rchgTypeUI {
List<RchgType> payList = [];
List<String> payNameArr = [
"支付宝(人工充值)",
"微信(人工充值)",
"银联(人工充值)",
"信用卡(人工充值)",
"花呗(人工充值)",
"云闪付(人工充值)",
"QQ錢包(人工充值)",
"京东支付(人工充值)"
];
for (RchgType rechargeTypeBean in (rchgType ?? [])) {
if (rechargeTypeBean.type == 'daichong') {
if (daichong?.traders?.isNotEmpty == true) {
PayForModel dcPayModel = daichong!.traders![0];
if (dcPayModel.payInfos?.isNotEmpty == true) {
for (PayInfoModel payInfoModel in dcPayModel.payInfos!) {
var payType = RchgType();
payType.isOfficial = true;
payType.channel = rechargeTypeBean.channel;
payType.incrAmount = rechargeTypeBean.incrAmount;
payType.incTax = rechargeTypeBean.incTax;
payType.payMethod = payInfoModel.payMethod;
if (payInfoModel.payMethod == 101) {
payType.type = 'alipy';
payType.typeName = payNameArr[0];
} else if (payInfoModel.payMethod == 102) {
payType.type = 'wechat';
payType.typeName = payNameArr[1];
} else if (payInfoModel.payMethod == 103) {
payType.type = 'union';
payType.typeName = payNameArr[2];
} else if (payInfoModel.payMethod == 104) {
payType.type = 'credit';
payType.typeName = payNameArr[3];
} else if (payInfoModel.payMethod == 105) {
payType.type = 'huabei';
payType.typeName = payNameArr[4];
} else if (payInfoModel.payMethod == 106) {
payType.type = 'yunSanPay';
payType.typeName = payNameArr[5];
} else if (payInfoModel.payMethod == 107) {
payType.type = 'qqWallet';
payType.typeName = payNameArr[6];
} else if (payInfoModel.payMethod == 108) {
payType.type = 'jindongPay';
payType.typeName = payNameArr[7];
}
payList.add(payType);
}
}
}
} else {
payList.add(rechargeTypeBean);
}
}
if (isAmountPay == true) {
RchgType coinType = RchgType();
coinType.type = "coin";
coinType.typeName = "金币";
payList.add(coinType);
}
return payList;
}
VipProductModel({
this.actionDesc,
this.badgeType,
this.badgeText,
this.aiUndressCount,
this.alias,
this.bgImg,
this.chanSplitMod,
this.chatPrice,
this.createdAt,
this.desc,
this.discountedPrice,
this.discountedPriceAnd,
this.discountedPriceIos,
this.downloadCount,
this.duration,
this.everyDayGiveCoin,
this.exclusiveOffer,
this.giveCoin,
this.giveFruitCoin,
this.goldVideoCouponCount,
this.goldVideoCouponNum,
this.goldVideoFreeDay,
this.isAmountPay,
this.newBgImg,
this.newName,
this.newPrivilege,
this.originalPrice,
this.payVidDiscount,
this.position,
this.privilege,
this.privilegeDesc,
this.productID,
this.productName,
this.productType,
this.rchgType,
this.serviceTime,
this.showCountdownTime,
this.signDays,
this.sort,
this.status,
this.timesAWeek,
this.type,
this.unitPriceDisplay,
this.updatedAt,
this.videoDiscount,
this.vipCardDesc,
this.vipLevel,
this.isPreSale = false,
this.tag,
this.isUpgrade,
this.currentVipName,
this.currentVipPrice,
this.purchasePrice,
this.advanceAmount,
});
VipProductModel.fromJson(Map<String, dynamic> json) {
prepaidPrice = json['prepaidPrice'];
endBgImg = json['endBgImg'];
endBgSelectImg = json['endBgSelectImg'];
actionDesc = json['actionDesc'];
badgeType = json['badgeType'];
badgeText = json['badgeText'];
aiUndressCount = json['aiUndressCount'];
alias = json['alias'];
bgImg = json['bgImg'];
chanSplitMod = json['chanSplitMod'];
chatPrice = json['chatPrice'];
createdAt = json['createdAt'];
desc = json['desc'];
discountedPrice = json['discountedPrice'];
discountedPriceAnd = json['discountedPriceAnd'];
discountedPriceIos = json['discountedPriceIos'];
downloadCount = json['downloadCount'];
duration = json['duration'];
everyDayGiveCoin = json['everyDayGiveCoin'];
exclusiveOffer = json['exclusiveOffer'];
giveCoin = json['giveCoin'];
giveFruitCoin = json['giveFruitCoin'];
goldVideoCouponCount = json['goldVideoCouponCount'];
goldVideoCouponNum = json['goldVideoCouponNum'];
goldVideoFreeDay = json['goldVideoFreeDay'];
isAmountPay = json['isAmountPay'];
newBgImg = json['newBgImg'];
newName = json['newName'];
tag = json['tag'];
if (json['newPrivilege'] != null) {
newPrivilege = <NewPrivilege>[];
json['newPrivilege'].forEach((v) {
newPrivilege!.add(NewPrivilege.fromJson(v));
});
}
originalPrice = json['originalPrice'];
payVidDiscount = json['payVidDiscount'];
position = json['position'];
if (json['privilege'] != null) {
privilege = (json['privilege'] as List?)?.map((v) {
return int.tryParse(v.toString()) ?? 0;
}).toList();
}
privilegeDesc = json['privilegeDesc'];
productID = json['productID'];
productName = json['productName'];
productType = json['productType'];
if (json['rchgType'] != null) {
rchgType = <RchgType>[];
json['rchgType'].forEach((v) {
rchgType!.add(RchgType.fromJson(v));
});
}
serviceTime = json['serviceTime'];
showCountdownTime = json['showCountdownTime'];
signDays = json['signDays'];
sort = json['sort'];
status = json['status'];
timesAWeek = json['timesAWeek'];
type = json['type'];
unitPriceDisplay = json['unitPriceDisplay'];
updatedAt = json['updatedAt'];
videoDiscount = json['videoDiscount'];
vipCardDesc = json['vipCardDesc'];
vipLevel = json['vipLevel'];
isPreSale = false;
isUpgrade = json['isUpgrade'];
currentVipName = json['currentVipName'];
currentVipPrice = "${json['currentVipPrice']}";
purchasePrice = json['purchasePrice'];
advanceAmount = json['advanceAmount'];
balanceAmount = json['balanceAmount'];
}
//是否有折扣
bool hasDiscout() {
return discountedPrice != originalPrice;
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['actionDesc'] = actionDesc;
data['badgeType'] = badgeType;
data['badgeText'] = badgeText;
data['aiUndressCount'] = aiUndressCount;
data['alias'] = alias;
data['bgImg'] = bgImg;
data['chanSplitMod'] = chanSplitMod;
data['chatPrice'] = chatPrice;
data['createdAt'] = createdAt;
data['desc'] = desc;
data['discountedPrice'] = discountedPrice;
data['discountedPriceAnd'] = discountedPriceAnd;
data['discountedPriceIos'] = discountedPriceIos;
data['downloadCount'] = downloadCount;
data['duration'] = duration;
data['everyDayGiveCoin'] = everyDayGiveCoin;
data['exclusiveOffer'] = exclusiveOffer;
data['giveCoin'] = giveCoin;
data['giveFruitCoin'] = giveFruitCoin;
data['goldVideoCouponCount'] = goldVideoCouponCount;
data['goldVideoCouponNum'] = goldVideoCouponNum;
data['goldVideoFreeDay'] = goldVideoFreeDay;
data['isAmountPay'] = isAmountPay;
data['newBgImg'] = newBgImg;
data['newName'] = newName;
if (newPrivilege != null) {
data['newPrivilege'] = newPrivilege!.map((v) => v.toJson()).toList();
}
data['originalPrice'] = originalPrice;
data['payVidDiscount'] = payVidDiscount;
data['position'] = position;
if (privilege != null) {
data['privilege'] = privilege!.map((v) => v).toList();
}
data['privilegeDesc'] = privilegeDesc;
data['productID'] = productID;
data['productName'] = productName;
data['productType'] = productType;
if (rchgType != null) {
data['rchgType'] = rchgType!.map((v) => v.toJson()).toList();
}
data['serviceTime'] = serviceTime;
data['showCountdownTime'] = showCountdownTime;
data['signDays'] = signDays;
data['sort'] = sort;
data['status'] = status;
data['timesAWeek'] = timesAWeek;
data['type'] = type;
data['unitPriceDisplay'] = unitPriceDisplay;
data['updatedAt'] = updatedAt;
data['videoDiscount'] = videoDiscount;
data['vipCardDesc'] = vipCardDesc;
data['vipLevel'] = vipLevel;
data['isUpgrade'] = isUpgrade;
data['currentVipName'] = currentVipName;
data['currentVipPrice'] = currentVipPrice;
data['purchasePrice'] = purchasePrice;
data['advanceAmount'] = advanceAmount;
data['balanceAmount'] = balanceAmount;
return data;
}
}
extension PreSale on VipProductModel {
String realNormalVipImage() {
if (!isPreSale) return bgImg ?? '';
if (PreSaleProvider().canPayBalance) return endBgImg ?? '';
return bgImg ?? '';
}
String realSelectVipImage() {
if (!isPreSale) return newBgImg ?? '';
if (PreSaleProvider().canPayBalance) return endBgSelectImg ?? '';
return newBgImg ?? '';
}
}
class NewPrivilege {
String? id;
String? img;
String? privilegeDesc;
String? privilegeName;
int? privilege;
String? uncheckedImg;
bool? isCore; //是否核心权益(true 进「我的核心权益」横向卡片,false 进「更多权益」网格)
NewPrivilege({this.img, this.privilegeDesc, this.privilegeName});
NewPrivilege.fromJson(Map<String, dynamic> json) {
id = json['id'];
img = json['img'];
privilegeDesc = json['privilegeDesc'];
privilegeName = json['privilegeName'];
privilege = json['privilege'];
uncheckedImg = json['uncheckedImg'];
isCore = json['isCore'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['id'] = id;
data['img'] = img;
data['privilegeDesc'] = privilegeDesc;
data['privilegeName'] = privilegeName;
data['uncheckedImg'] = uncheckedImg;
data['privilege'] = privilege;
data['isCore'] = isCore;
return data;
}
}
class RchgType {
String? channel; //渠道类型 鲨鱼 金鱼
int? incTax; //按比率增加额外优惠额 0-1之间 ,如果 incrAmount 与 incrTax 同时存在 以 incrAmount 为准
int? incrAmount; //增加的优惠额度
String? type; //充值方式 //coin 金币方式
String? typeName; //支付宝,微信,银联
//daichong 业务字段
bool? isOfficial = false; // 官方推荐
int? payMethod;
RchgType(
{this.channel, this.incTax, this.incrAmount, this.type, this.typeName});
RchgType.fromJson(Map<String, dynamic> json) {
channel = json['channel'];
incTax = json['incTax'];
incrAmount = json['incrAmount'];
type = json['type'];
typeName = json['typeName'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data['channel'] = channel;
data['incTax'] = incTax;
data['incrAmount'] = incrAmount;
data['type'] = type;
data['typeName'] = typeName;
return data;
}
String getPayIcon() {
if (isOfficial == true) {
// 代充
return "pay_icon103.png".mineImgPath;
} else if (type == 'alipay') {
return "ic_alipay.png".mineImgPath;
} else if (type == 'union') {
return "pay_icon103.png".mineImgPath;
} else if (type == 'wechat') {
return "ic_wechat.png".mineImgPath; //AssetsSvg.SVG_PAY_ICON102;
} else if (type == 'coin') {
//金币
return "ic_coin.webp".mineImgPath; //AssetsSvg.SVG_PAY_ICON102;
} else if (type == 'usdt') {
return "ic_usdt.png".mineImgPath;
} else {
return "";
// return "ic_coupon.png".mineImgPath;
}
}
}
/// 加赠券
class CouponModel {
String? cId;
int? count;
String? createTime;
String? expireTime;
String? id;
String? name;
int? price;
int? type;
bool? used;
int? value;
CouponModel(
{this.cId,
this.count,
this.createTime,
this.expireTime,
this.id,
this.name,
this.price,
this.type,
this.used,
this.value});
CouponModel.fromJson(Map<String, dynamic> json) {
cId = json['cId'];
count = json['count'];
createTime = json['createTime'];
expireTime = json['expireTime'];
id = json['id'];
name = json['name'];
price = json['price'];
type = json['type'];
used = json['used'];
value = json['value'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['cId'] = this.cId;
data['count'] = this.count;
data['createTime'] = this.createTime;
data['expireTime'] = this.expireTime;
data['id'] = this.id;
data['name'] = this.name;
data['price'] = this.price;
data['type'] = this.type;
data['used'] = this.used;
data['value'] = this.value;
return data;
}
}
+136
View File
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
/// VIP 金色标题渐变 #F5E7BC → #DCAD55
/// 会员中心 / 购买弹窗 / 权益标题等金色文案共用,调色只改此处
const LinearGradient kVipTitleGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xffF5E7BC), Color(0xffDCAD55)],
);
/// 金渐变着色包装:给文字 / 线条 / 菱形套上 [kVipTitleGradient]
class VipGradientMask extends StatelessWidget {
final Widget child;
const VipGradientMask({super.key, required this.child});
@override
Widget build(BuildContext context) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) => kVipTitleGradient.createShader(bounds),
child: child,
);
}
}
/// B 组「核心权益」标题图(会员中心 / 购买弹窗共用)
class VipCoreSectionTitleImage extends StatelessWidget {
const VipCoreSectionTitleImage({super.key});
@override
Widget build(BuildContext context) {
return Image.asset(
'vip_special_title.webp'.mineImgPath,
width: double.infinity,
fit: BoxFit.fitWidth,
);
}
}
/// B 组「更多权益」标题图(会员中心 / 购买弹窗共用)
class VipMoreSectionTitleImage extends StatelessWidget {
const VipMoreSectionTitleImage({super.key});
@override
Widget build(BuildContext context) {
return Image.asset(
'vip_more_title.webp'.mineImgPath,
width: double.infinity,
fit: BoxFit.fitWidth,
);
}
}
/// 区块标题:两侧「金渐变横线 + 菱形」+ 居中金渐变文字(会员中心 B / 购买弹窗共用)
class VipSectionTitle extends StatelessWidget {
final String text;
/// true:旧版橙色标题(会员中心 A / DISABLED
final bool classic;
const VipSectionTitle(this.text, {super.key, this.classic = false});
static const _classicOrange = Color(0xffF68804);
@override
Widget build(BuildContext context) {
if (classic) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_classicDeco(lineFirst: true),
8.sizeBoxW,
Text(text,
style: const TextStyle(
color: _classicOrange,
fontSize: 18,
fontWeight: FontWeight.w600)),
8.sizeBoxW,
_classicDeco(lineFirst: false),
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildDeco(lineFirst: true),
8.sizeBoxW,
VipGradientMask(
child: Text(
text,
style: const TextStyle(
color: Colors.white, fontSize: 20, fontWeight: FontWeight.w600),
),
),
8.sizeBoxW,
_buildDeco(lineFirst: false),
],
);
}
Widget _classicDeco({required bool lineFirst}) {
final line = Container(width: 40, height: 0.5, color: _classicOrange);
final diamond = Transform.rotate(
angle: 0.7853981633974483,
child: Container(width: 5, height: 5, color: _classicOrange),
);
return Row(
mainAxisSize: MainAxisSize.min,
children: lineFirst ? [line, diamond] : [diamond, line],
);
}
// 标题两侧装饰:金渐变细横线 + 菱形(lineFirst=true 横线在外侧,false 镜像)
Widget _buildDeco({required bool lineFirst}) {
const line = VipGradientMask(
child: SizedBox(
width: 40, height: 0.5, child: ColoredBox(color: Colors.white)),
);
return Row(
mainAxisSize: MainAxisSize.min,
children: lineFirst ? [line, _diamond] : [_diamond, line],
);
}
// 金渐变小菱形装饰(45° 旋转的小方块)
Widget get _diamond => Transform.rotate(
angle: 0.7853981633974483,
child: const VipGradientMask(
child: SizedBox(
width: 5, height: 5, child: ColoredBox(color: Colors.white)),
),
);
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/mine/exchange/recharge_type_list_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
// 金币充值档位卡片
class CoinItem extends StatelessWidget {
final RechargeTypeModel model;
final bool isSelected; // 是否选中
final VoidCallback? onTap;
const CoinItem(this.model, {super.key, this.onTap, this.isSelected = false});
@override
Widget build(BuildContext context) {
final couponDesc = model.couponDesc;
return GestureDetector(
onTap: onTap,
child: Stack(
children: [
Container(
width: double.infinity,
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
border: isSelected
? Border.all(width: 1, color: const Color(0xffF9C142))
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset('coin_icon.webp'.mineImgPath, width: 36),
4.sizeBoxH,
Text(
"${model.amount}金币",
style: const TextStyle(color: Colors.white, fontSize: 14),
),
4.sizeBoxH,
Text(
'¥${model.moneyYuan}',
style: const TextStyle(
color: Color(0xffF9C142), fontWeight: FontWeight.w500),
),
4.sizeBoxH,
],
),
),
// 左上角优惠角标
if (couponDesc?.isNotEmpty == true)
Positioned(
top: 0,
left: 0,
child: Container(
height: 20,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 5),
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
bottomRight: Radius.circular(12)),
color: Color(0xffFFD460),
),
child: Text(
couponDesc!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xff292929),
fontWeight: FontWeight.w500,
height: 1,
fontSize: 10.0,
),
),
),
),
],
),
);
}
}
@@ -0,0 +1,266 @@
import 'package:flutter/material.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/hj_utils/widget_util.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:provider/provider.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import '../../make_money/mine_withdrawal_record_page.dart';
import '../mine_charge_coin_logic.dart';
import '../pay_order_source.dart';
import 'coin_item.dart';
/// 金币支付底部弹窗(按设计稿:余额卡片 + 档位网格 + 立即支付)
class CoinPayBottomSheet extends StatelessWidget {
/// 下单来源,默认播放页底部弹窗——本弹窗只在播放页(购买弹窗/解锁蒙层)用
final PaySourcePage sourcePage;
/// 下单埋点上下文(短剧付费墙充金币要带剧/集/付费墙上下文)
final PayOrderTrackInfo? orderTrack;
const CoinPayBottomSheet(
{super.key,
this.sourcePage = PaySourcePage.videoBottomSheet,
this.orderTrack});
/// 弹窗展示中,防连点叠多个 bottomSheet
static bool _isShowing = false;
static Future<T?> show<T>({
PaySourcePage sourcePage = PaySourcePage.videoBottomSheet,
PayOrderTrackInfo? orderTrack,
}) async {
if (_isShowing) return null;
_isShowing = true;
try {
return await Get.bottomSheet<T>(
CoinPayBottomSheet(sourcePage: sourcePage, orderTrack: orderTrack),
isScrollControlled: true,
backgroundColor: Colors.transparent,
);
} finally {
_isShowing = false;
}
}
@override
Widget build(BuildContext context) {
return GetBuilder<MineChargeCoinLogic>(
init: MineChargeCoinLogic(sourcePage: sourcePage, orderTrack: orderTrack),
global: false,
builder: (logic) {
// Material 填深色底:圆角抗锯齿对着 #040018,避免透明底导致左右上角异色
return Material(
color: const Color(0xff040018),
borderRadius: const BorderRadius.vertical(top: Radius.circular(18)),
clipBehavior: Clip.antiAlias,
child: Container(
constraints: BoxConstraints(maxHeight: Get.height * 0.85),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xff040018), Color(0xff060606)],
),
),
child: Stack(
children: [
// 顶部径向黄光:#FACC15 16% → 0%
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
center: Alignment.topCenter,
radius: 1.2,
colors: [
Color(0x29FACC15), // #FACC15 @ 16%
Color(0x00FACC15), // #FACC15 @ 0%
],
),
),
),
),
SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
12.sizeBoxH,
const SheetHandleBar(),
16.sizeBoxH,
const Text(
'金币支付',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
16.sizeBoxH,
Flexible(child: _buildBody(logic)),
_buildBottomBar(logic),
],
),
),
],
),
),
);
},
);
}
Widget _buildBody(MineChargeCoinLogic logic) {
if (logic.isInitLoading) {
return const SizedBox(height: 220, child: LoadingCenterWidget());
}
if (logic.model?.list?.isEmpty != false) {
return SizedBox(
height: 220,
child: CErrorWidget(retryOnTap: () => logic.loadData()),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_buildWallet(),
16.sizeBoxH,
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 6,
childAspectRatio: 111 / 138,
),
itemCount: logic.model!.list!.length,
itemBuilder: (context, index) {
final model = logic.model!.list![index];
final isSelected = logic.selectedCoin?.id == model.id;
return CoinItem(
model,
isSelected: isSelected,
onTap: () => logic.onSelectCoin(index),
);
},
),
18.sizeBoxH,
],
),
);
}
/// 我的金币余额卡片
Widget _buildWallet() {
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'我的金币余额',
style: TextStyle(
color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
),
8.sizeBoxH,
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset('coin_icon.webp'.mineImgPath, width: 36),
4.sizeBoxW,
Consumer<GlobalStore>(
builder: (_, store, __) {
final wallet = store.wallet;
final total = (wallet?.amount ?? 0) + (wallet?.income ?? 0);
return Text(
'$total',
style: const TextStyle(
color: Color(0xffFFD460),
fontSize: 32,
fontWeight: FontWeight.w600,
),
);
},
),
const Spacer(),
InkWell(
enableFeedback: false,
onTap: () =>
Get.to(RecordsPage(RecordType.bill), opaque: false),
child: Container(
alignment: Alignment.center,
height: 30,
width: 90,
decoration: BoxDecoration(
color: const Color(0xffFFD460),
borderRadius: BorderRadius.circular(999),
),
child: const Text(
'余额明细',
style: TextStyle(color: Color(0xff3D3D3D), fontSize: 14),
),
),
),
],
),
],
),
);
}
/// 立即支付 + 在线客服
Widget _buildBottomBar(MineChargeCoinLogic logic) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => logic.onGotoPay(),
child: Container(
height: 44,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(6),
),
alignment: Alignment.center,
child: Text(
'¥${logic.selectedCoin?.moneyYuan ?? 0}/立即支付',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
12.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('支付问题反馈,点击联系 ',
style: textStyle(12, const Color(0xffBFBFC1), FontWeight.w400)),
GestureDetector(
onTap: pushToCustomService,
child: Text('在线客服',
style:
textStyle(12, const Color(0xffFFD460), FontWeight.w400)),
),
],
),
16.sizeBoxH,
],
);
}
}
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/screen.dart';
class FeedbackQuestionCategoryView extends StatefulWidget {
final Function(String category)? choseCategory;
const FeedbackQuestionCategoryView({super.key, this.choseCategory});
@override
State<FeedbackQuestionCategoryView> createState() =>
_FeedbackQuestionCategoryViewState();
}
class _FeedbackQuestionCategoryViewState
extends State<FeedbackQuestionCategoryView> {
final dataSource = [
'账号问题',
'影视资源',
'APP体验',
'播放失败',
'播放卡顿',
'分类有误',
'充值问题',
'其他'
];
String? selectCategory;
@override
Widget build(BuildContext context) {
return GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 5,
childAspectRatio: 84 / 34),
itemCount: dataSource.length,
itemBuilder: (BuildContext context, int index) {
final category = dataSource[index];
final select = category == selectCategory;
return GestureDetector(
onTap: () {
selectCategory = category;
setState(() {});
widget.choseCategory?.call(category);
},
child: Container(
decoration: BoxDecoration(
color: select
? Color(0xFFF68804)
: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(3),
),
alignment: Alignment.center,
child: Text(
category,
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
);
},
);
}
}
class InfomationInputView extends StatefulWidget {
final String title;
final String hint;
final TextEditingController controller;
const InfomationInputView(this.controller,
{super.key, this.title = '', this.hint = ''});
@override
State<InfomationInputView> createState() => _InfomationInputViewState();
}
class _InfomationInputViewState extends State<InfomationInputView> {
@override
Widget build(BuildContext context) {
return Column(
children: [
12.sizeBoxH,
Row(
children: [
SizedBox(
width: 90,
child: Text(
widget.title,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
fontWeight: FontWeight.w500),
),
),
Expanded(
child: TextField(
maxLines: null,
style: TextStyle(color: Colors.white, fontSize: 12),
maxLength: 20,
controller: widget.controller,
decoration: InputDecoration(
border: InputBorder.none,
hintText: widget.hint,
hintStyle:
TextStyle(color: Color(0xff525252), fontSize: 12),
counterText: '',
contentPadding: EdgeInsets.zero,
isDense: true),
),
)
],
),
12.sizeBoxH,
Divider(
height: 0.5,
color: Colors.black.withValues(alpha: 0.05),
)
],
);
}
}
@@ -0,0 +1,95 @@
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart';
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
class MineFeedbackLogic extends GetxController {
/// 问题描述
TextEditingController inputFeedback = TextEditingController();
/// 区域
TextEditingController areaFeedback = TextEditingController();
/// 设备
TextEditingController deviceFeedback = TextEditingController();
/// 网络运营商
TextEditingController netFeedback = TextEditingController();
///联系方式
TextEditingController contactFeedback = TextEditingController();
///封面本地地址
List<String> _localPicList = [];
String _questionCategory = '';
@override
void onClose() {
inputFeedback.dispose();
areaFeedback.dispose();
deviceFeedback.dispose();
netFeedback.dispose();
contactFeedback.dispose();
super.onClose();
}
/// 主要问题
updateQuestionCategory(String category) => _questionCategory = category;
/// 资源图片变化
updateQuestionImages(List<String> images) => _localPicList = images;
Future<void> onSubmit() async {
if (_questionCategory.isEmpty) {
showToast("请选择遇到的问题分类");
return;
}
if (inputFeedback.text.isEmpty) {
showToast("请填写问题描述");
return;
}
// 图片可选:无图直接提交;有图先上传再提交,上传失败仍照常提交(不阻断反馈)
if (_localPicList.isEmpty) {
_submitFeedback([]);
return;
}
FileUploadTool().uploadImagesWithProgress(
_localPicList,
onSuccess: (urls) => _submitFeedback(urls),
onFailure: () => _submitFeedback([]),
);
}
/// 提交反馈([images] 为已上传的图片 url,可为空)
Future<void> _submitFeedback(List<String> images) async {
LoadingAlertWidget.show(title: "正在提交...");
try {
FocusScope.of(Get.context!).unfocus();
final success = await MineService.feedback(inputFeedback.text,
location: areaFeedback.text,
device: deviceFeedback.text,
carrier: netFeedback.text,
img: images,
fType: _questionCategory,
contact: contactFeedback.text);
if (success) {
Get.back();
showToast('提交成功');
} else {
showToast('提交失败');
}
} on DioException catch (e) {
showToast(e.message ?? '');
} catch (e) {
showToast(e.toString());
} finally {
LoadingAlertWidget.cancel();
}
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'feedback_question_category_view.dart';
import 'mine_feedback_logic.dart';
import 'mine_qa_page.dart';
import 'photo_manage_view.dart';
class MineFeedbackPage extends StatelessWidget {
const MineFeedbackPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<MineFeedbackLogic>(
init: MineFeedbackLogic(),
builder: (controller) => Scaffold(
appBar: AppBar(
title: Text('意见反馈'),
actions: [
InkWell(
enableFeedback: false,
onTap: () => Get.to(() => MineQAPage()),
child: Text(
'Q&A',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
),
16.sizeBoxW,
],
),
body: Column(
children: [
Expanded(
child: SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
child: Padding(
padding: EdgeInsets.only(left: 10, right: 10, top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'遇到的问题',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
12.sizeBoxH,
FeedbackQuestionCategoryView(
choseCategory: controller.updateQuestionCategory,
),
24.sizeBoxH,
Text(
'问题描述(必填)',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 18,
fontWeight: FontWeight.w600),
),
12.sizeBoxH,
Container(
constraints: BoxConstraints(minHeight: 160),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 11),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
TextField(
maxLines: 8,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 12),
maxLength: 300,
controller: controller.inputFeedback,
decoration: InputDecoration(
border: InputBorder.none,
hintText:
'请详细描述您的问题,无效信息无法帮助技术人员排查问题。
有效信息:机型,系统,地区,问题症状...',
hintStyle: TextStyle(
color: Color(0xff525252), fontSize: 12),
counterStyle: TextStyle(fontSize: 8),
counter: SizedBox(),
contentPadding: EdgeInsets.zero,
isDense: true),
),
Positioned(
bottom: 0,
right: 0,
// 字数计数:只监听不接管所有权(释放归 MineFeedbackLogic.onClose)
child: ValueListenableBuilder(
valueListenable: controller.inputFeedback,
builder: (_, value, __) => Text(
'${value.text.length}/300',
style: TextStyle(
color:
Colors.black.withValues(alpha: 0.5),
fontSize: 12,
),
),
),
)
],
),
),
14.sizeBoxH,
InfomationInputView(controller.areaFeedback,
title: '所在地区', hint: '例:浙江杭州'),
InfomationInputView(controller.deviceFeedback,
title: '设备信息', hint: '例:苹果14'),
InfomationInputView(controller.netFeedback,
title: '网络运营商', hint: '电信/联通/移动'),
InfomationInputView(controller.contactFeedback,
title: '联系方式', hint: 'QQ/微信/邮箱等'),
24.sizeBoxH,
Text(
'上传图片',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 18,
fontWeight: FontWeight.w700),
),
12.sizeBoxH,
PhotoManageView(
resourceOnchanged: controller.updateQuestionImages,
),
24.sizeBoxH,
Text(
'以方便我们给您回复,有效的改进建议,有惊喜赠送哟!',
style:
TextStyle(color: Color(0xff525252), fontSize: 12),
),
60.sizeBoxH,
],
),
)),
),
GestureDetector(
onTap: controller.onSubmit,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 45),
alignment: Alignment.center,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
child: Text(
'提交意见',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600),
),
),
),
20.sizeBoxH,
],
),
),
);
}
}
@@ -0,0 +1,146 @@
import 'dart:math';
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
///帮助反馈
class MineQAPage extends StatefulWidget {
const MineQAPage({super.key});
@override
State<StatefulWidget> createState() => _MineQAPageState();
}
class _MineQAPageState extends State<MineQAPage> {
final String tg = 'https://t.me/mu02guang';
final String email = 'xzhan5555@gmail.com';
final String url = 'XV9.FM';
late final questionList = [
{
"question": "描述文件安装失败?",
"answer": "若提示【新的MDM有效负载与旧的有效负载不匹配】,请移除移动设备管理步骤:【设置-通用-设备管理-移动设备管理-移除管理】",
'isOpen': true
},
{
"question": "怎么找回账号?",
"answer":
"本平台登录会自动创建账号,需保存账号凭证或绑定手机号码,才能记录之前的账号信息。受行业限制,APP无法正常使用时需升级,未绑定手机号码会导致账号信息丢失。请及时绑定手机号码或保存账号凭证,以免VIP信息丢失,造成巨大财产损失!账号丢失的用户可在 『我的』页面-账号找回,原账号的VIP信息会转移至新账号上。『账号凭证』『手机绑定』都没有的情况下,如账号VIP信息丢失,可以提供VIP支付充值凭证截图联系在线客服为您查询核实恢复VIP。",
'isOpen': false
},
{
"question": "怎么支付不成功?",
"answer":
"1.因超时支付无法到账,请重新发起。\n2.每天发起支付不能超过5次,连续发起且未支付,账号可能被加入黑名单。\n3.支付通道在夜间比较忙碌,尝试多次发起,后台会为您自动切换不同支付通道。\n4.若充值成功,用户权益通常会在数分钟内到账,请刷新APP或重启。\n6.若支付成功并重启APP后依然没有到账,请联系在线客服并提供付款成功凭证截图。",
'isOpen': false
},
{
"question": "收到手机报毒提醒?",
"answer":
"本平台有主要收益为广告赞助,且保证APP安全无毒,因平台主要展示内容为色情属于特殊行业,某些杀毒软件会误报毒提醒,如遇此类提醒请忽略继续使用。",
'isOpen': false
},
{
"question": "联系方式?",
"answer": "商务合作TG: $tg\n官方邮箱: $email\n永久下载地址: $url",
'isOpen': false
},
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("常见问题"),
),
body: SingleChildScrollView(
padding: EdgeInsets.fromLTRB(16, 12, 16, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...List.generate(
questionList.length,
(index) => _getItem(index),
),
],
),
),
);
}
Widget _getItem(int index) {
final item = questionList[index];
final isOpen = item["isOpen"] as bool;
return InkWell(
enableFeedback: false,
onTap: () {
item["isOpen"] = !isOpen;
setState(() {});
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
item["question"] as String,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
Spacer(),
Transform.rotate(
angle: isOpen ? (pi * -0.5) : 0,
child: Icon(
Icons.arrow_forward_ios_rounded,
size: 14,
color: Colors.white,
),
),
],
),
if (isOpen) ...[
10.sizeBoxH,
EasyRichText(
'${item["answer"] as String}',
defaultStyle: TextStyle(
color: Color(0xff989898),
fontWeight: FontWeight.w400,
fontSize: 12,
),
patternList: [
EasyRichTextPattern(
targetString: tg,
style: TextStyle(color: AppColors.primaryHighColor),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlToWeb(tg);
},
),
EasyRichTextPattern(
targetString: url,
style: TextStyle(color: AppColors.primaryHighColor),
recognizer: TapGestureRecognizer()
..onTap = () {
launchUrlToWeb('https://$url');
},
),
],
),
],
12.sizeBoxH,
Divider(
height: .5,
color: Colors.black.withValues(alpha: .04),
),
19.sizeBoxH,
],
),
);
}
}
@@ -0,0 +1,89 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:image_pickers/image_pickers.dart';
import 'package:hgdj/tools_base/widget/add_media_source_button.dart';
class PhotoManageView extends StatefulWidget {
final Function(List<String> resources)? resourceOnchanged;
final int max;
const PhotoManageView({super.key, this.resourceOnchanged, this.max = 9});
@override
State<PhotoManageView> createState() => _PhotoManageViewState();
}
class _PhotoManageViewState extends State<PhotoManageView> {
final dataSource = <String>[];
@override
Widget build(BuildContext context) {
return GridView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 1),
itemCount: min(dataSource.length + 1, 9),
itemBuilder: (BuildContext context, int index) {
if (index == dataSource.length)
return AddMediaSourceButton(
isVideo: false,
onTap: _addAlbumPhotos,
backgroundColor: Colors.black.withValues(alpha: .04),
);
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
children: [
Image.file(
File(dataSource[index]),
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Positioned(
right: 6,
top: 6,
child: GestureDetector(
onTap: () {
dataSource.removeAt(index);
setState(() {});
},
child: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .8),
borderRadius: BorderRadius.circular(10)),
alignment: Alignment.center,
child: const Icon(
Icons.close,
size: 14,
),
),
))
],
),
);
},
);
}
_addAlbumPhotos() async {
// image_picker 走系统相册 intent,选图不需要存储权限,直接调起
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 9 - dataSource.length,
showCamera: false,
);
if (listMedia.isEmpty) return;
final ret = listMedia.map((e) => e.path ?? '').toList();
dataSource.addAll(ret);
setState(() {});
widget.resourceOnchanged?.call(dataSource);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import '../../../hj_model/mine/happy/happy_model.dart';
import '../../../hj_model/splash/ads_model.dart';
/// 应用推荐页:按分类拉广告位数据,每个分类一个 tab
class LouFengAdLogic extends GetxController
with GetSingleTickerProviderStateMixin {
bool isLoading = true;
HappyModel? model; // null 表示加载失败,页面展示重试
final tabs = <String>[]; // 有内容的分类名
final configs = <AdTabConfig>[]; // 与 tabs 一一对应的渲染配置
TabController? tabCtr;
@override
void onInit() {
super.onInit();
loadData();
}
@override
void onClose() {
tabCtr?.dispose();
super.onClose();
}
/// 拉数据并重建分类,失败时 model 为 null 由页面兜底
Future<void> loadData() async {
model = await CommonService.happyList();
isLoading = false;
tabs.clear();
configs.clear();
if (model != null) _buildTabs();
update();
}
/// 「应用」固定展示,其余分类有 banner 或有应用才建 tab
void _buildTabs() {
_add(
'应用',
AdTabConfig(
bannerAds: _banners(1),
hengAds: model?.hengApp != null
? AdGroup(items: model?.hengApp, title: '官方推荐')
: null,
shuAds: model?.shuApp != null
? AdGroup(items: model?.shuApp, title: '热门应用')
: null,
));
final others = [
('炮台', 3, model?.ypApp),
('棋牌', 4, model?.qpApp),
('直播', 5, model?.zbApp),
('游戏', 2, model?.gameApp),
];
for (final (name, moduleType, apps) in others) {
final banners = _banners(moduleType);
if (banners.isEmpty && (apps?.isEmpty ?? true)) continue;
_add(
name,
AdTabConfig(
bannerAds: banners,
shuAds: apps != null ? AdGroup(items: apps, title: '热门推荐') : null,
));
}
// tab 数量随数据变化,controller 要跟着重建
tabCtr?.dispose();
tabCtr = TabController(length: tabs.length, vsync: this);
}
void _add(String name, AdTabConfig config) {
tabs.add(name);
configs.add(config);
}
/// 取该分类下的 banner 广告
List<AdsInfoModel> _banners(int moduleType) =>
model?.adv?.where((it) => it.moduleType == moduleType).toList() ?? [];
}
@@ -0,0 +1,75 @@
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/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'lou_feng_ad_logic.dart';
import 'widget/recommend_app_page.dart';
/// 应用推荐页,可从广告链接再次进入(taskhall),故用 uniqueTag 隔离多实例
class LouFengAdPage extends StatefulWidget {
const LouFengAdPage({super.key});
@override
State<LouFengAdPage> createState() => _LouFengAdPageState();
}
class _LouFengAdPageState extends State<LouFengAdPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<LouFengAdLogic>(
tag: uniqueTag,
init: LouFengAdLogic(),
builder: (logic) {
if (logic.isLoading) return const LoadingCenterWidget();
if (logic.model == null)
return CErrorWidget(retryOnTap: logic.loadData);
//只有「应用」一个分类时不展示 tab
if (logic.tabs.length <= 1) return _adView(logic, 0);
return Column(
children: [
14.sizeBoxH,
//分类 tab:无指示器,靠文字颜色区分选中态
Container(
margin: EdgeInsets.symmetric(horizontal: 9),
child: TabBar(
controller: logic.tabCtr,
padding: EdgeInsets.zero,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelStyle: TextStyle(fontSize: 14, color: Color(0xE5FFFFFF)),
unselectedLabelStyle:
TextStyle(fontSize: 14, color: Color(0xffACBABF)),
indicatorSize: TabBarIndicatorSize.tab,
indicatorColor: Color(0x00FFFFFF),
indicatorWeight: 1,
labelColor: Color(0xE5FFFFFF),
labelPadding: EdgeInsets.zero,
tabs: logic.tabs
.map((it) => Padding(
padding:
EdgeInsets.symmetric(vertical: 5, horizontal: 10),
child: Text(it),
))
.toList(),
),
),
Expanded(
child: TabBarView(
controller: logic.tabCtr,
children: List.generate(
logic.configs.length, (i) => _adView(logic, i).keepAlive),
),
),
],
);
},
);
}
Widget _adView(LouFengAdLogic logic, int index) =>
RecommendAppPage(logic.configs[index]);
}
@@ -0,0 +1,199 @@
import 'package:easy_rich_text/easy_rich_text.dart';
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/global_store/store.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../config/config.dart';
import '../mine_share/mine_share_record_logic.dart';
import '../mine_share/mine_share_record_page.dart';
import '../widgets/mine_share_qr_view.dart';
class MineIncomePage extends StatefulWidget {
const MineIncomePage({super.key});
@override
State<MineIncomePage> createState() => _MineIncomePageState();
}
class _MineIncomePageState extends State<MineIncomePage> with UniqueTagMixin {
@override
void initState() {
super.initState();
globalStore.refreshWallet();
}
@override
Widget build(BuildContext context) {
return GetBuilder<MineShareRecordLogic>(
tag: uniqueTag,
init: MineShareRecordLogic(),
builder: (logic) => SingleChildScrollView(
child: Container(
padding: EdgeInsets.only(left: 0, right: 0, top: 0),
child: Column(
children: [
Stack(
children: [
Positioned.fill(
child: Image.asset("proxy_header_bg.webp".mineImgPath,
fit: BoxFit.fill),
),
Container(
padding: EdgeInsets.fromLTRB(10, 23, 10, 6),
// decoration: BoxDecoration(color: Color(0xFF030F18)),
child: Column(
children: [
Consumer<GlobalStore>(
builder: (_, store, __) => Row(
children: [
NetworkImageLoader(
imageUrl: store.meInfo?.portrait ?? '',
width: 60,
height: 60,
borderRadius: 30,
),
10.sizeBoxW,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
globalStore.meInfo?.name ?? "",
style: const TextStyle(
color: Color(0xFFFFFFFF),
fontWeight: FontWeight.w700,
fontSize: 18.0),
),
5.sizeBoxH,
Text(
"开通会员 畅享专属特权",
style: const TextStyle(
color: Color(0xff989898),
fontWeight: FontWeight.w400,
fontSize: 12),
)
],
),
Spacer(),
GestureDetector(
onTap: () {
Get.to(() => MineShareRecordPage());
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"邀请人数",
style: const TextStyle(
color: Color(0xB2FFFFFF),
fontWeight: FontWeight.w500,
fontSize: 12.0),
),
SizedBox(height: 1),
Text(
"${logic.model?.totalInviteUserCount ?? 0}",
style: const TextStyle(
color: Color(0xffF68804),
fontWeight: FontWeight.w500,
fontSize: 24.0),
)
],
),
)
],
),
),
if (Config.proxyBanner?.additionalProp != null)
Column(
children: [
29.sizeBoxH,
InkWell(
enableFeedback: false,
onTap: () {
pushToPageByLink(
Config.proxyBanner?.additionalProp?.url ??
"");
},
child: Config.proxyBanner?.additionalProp
?.banner !=
null
? NetworkImageLoader(
imageUrl: Config.proxyBanner
?.additionalProp?.banner ??
'',
width: double.infinity,
borderRadius: 0,
)
: Image.asset(
'proxy_banner.webp'.mineImgPath,
width: double.infinity,
),
),
],
)
],
),
),
],
),
12.sizeBoxH,
Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Row(
children: [
Text(
"规则说明",
style: const TextStyle(
color: Color(0xFFFFFFFF),
fontWeight: FontWeight.bold,
fontSize: 18.0),
),
],
),
),
Container(
padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10),
width: double.infinity,
child: EasyRichText(
'每邀请3名好友成功注册即可获得3天VIP',
textAlign: TextAlign.left,
defaultStyle:
TextStyle(fontSize: 12, color: Color(0x73FFFFFF)),
patternList: [
EasyRichTextPattern(
targetString: '3名好友',
style: TextStyle(color: Color(0xFFF68804)),
),
],
),
),
Container(
padding: EdgeInsets.symmetric(vertical: 5, horizontal: 10),
width: double.infinity,
child: EasyRichText(
'邀请说明:点击【保存二维码】或【复制推广链接】分享给朋友下载即可',
textAlign: TextAlign.left,
defaultStyle:
TextStyle(fontSize: 12, color: Color(0x73FFFFFF)),
),
),
MineShareQRView(),
12.sizeBoxH,
Image.asset(
"invite_steps.webp".mineImgPath,
fit: BoxFit.fill,
),
24.sizeBoxH,
],
),
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:get/get.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:pull_to_refresh/pull_to_refresh.dart';
//免费专区排序 tab(标题与接口 sort 值绑定)
const _sortTabs = [SortTab('最多收藏', 1), SortTab('最新上架', 2), SortTab('最多观看', 3)];
class MoreFreeVideoLogic extends GetxController
with GetTickerProviderStateMixin {
final String vId;
List<String> get sortTitles => _sortTabs.map((e) => e.name).toList();
MoreFreeVideoLogic(this.vId);
bool isLoading = true;
int page = 1;
int sort = 0;
late final TabController tabController =
TabController(length: _sortTabs.length, vsync: this);
RefreshController? refreshController;
List<VideoModel> dataSource = [];
fetchPageData({bool isRefresh = true, bool showLoading = false}) async {
if (isRefresh) {
page = 1;
}
if (showLoading) {
isLoading = true;
update();
}
final res = await VidService.fetchFreeSourceList(vId,
page: page, sortType: _sortTabs[sort].sort);
isLoading = false;
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
res?.hasNext ?? false
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.list ?? []);
page += 1;
update();
}
}
@@ -0,0 +1,104 @@
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 '../../home/home_cell_style/video_simple_cell.dart';
import 'more_free_video_logic.dart';
class MoreFreeVideoPage extends StatelessWidget {
final String title;
final String vId;
const MoreFreeVideoPage(this.title, this.vId, {super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<MoreFreeVideoLogic>(
init: MoreFreeVideoLogic(vId),
builder: (controller) => Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Column(
children: [
Container(
padding: EdgeInsets.symmetric(vertical: 12),
child: Container(
width: 182,
height: 31,
padding: EdgeInsets.all(2),
decoration: ShapeDecoration(
shape: StadiumBorder(),
color: Color(0xFF20252F),
),
child: TabBar(
tabAlignment: TabAlignment.fill,
tabs: controller.sortTitles.map((e) {
return Container(
alignment: Alignment.center,
child: Text(e),
);
}).toList(),
labelStyle: TextStyle(
color: Color(0xFFF1F3F4),
fontSize: 12,
),
unselectedLabelStyle: TextStyle(
color: Color(0xFFACBABF),
fontSize: 12,
),
controller: controller.tabController,
padding: EdgeInsets.zero,
isScrollable: false,
onTap: (index) {
controller.sort = index;
controller.fetchPageData(showLoading: true);
},
labelPadding: EdgeInsets.zero,
indicator: ShapeDecoration(
shape: StadiumBorder(),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFF83A4F8), Color(0xFF2A5CDE)],
),
),
),
),
),
Expanded(
child: pullYsRefresh(
onInit: (ctr) => controller.refreshController = ctr,
onRefresh: (_) => controller.fetchPageData(),
onLoading: (_) => controller.fetchPageData(isRefresh: false),
child: () {
if (controller.isLoading) return LoadingCenterWidget();
if (controller.dataSource.isEmpty) return CErrorWidget();
return GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 10),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 7,
mainAxisSpacing: 12,
childAspectRatio: 191 / 174,
),
itemCount: controller.dataSource.length,
itemBuilder: (BuildContext context, int index) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: VideoSimpleCell(
videoModel: controller.dataSource[index],
),
);
},
);
}(),
),
)
],
),
),
);
}
}
@@ -0,0 +1,241 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/user/user_income_info_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import '../make_money/withdrawal_page.dart';
import 'share_details_page.dart';
class IncomeData {
String value;
String title;
IncomeData(this.title, this.value);
}
class ShareDataListPage extends StatefulWidget {
const ShareDataListPage({super.key});
@override
State<ShareDataListPage> createState() => _ShareDataListPageState();
}
class _ShareDataListPageState extends State<ShareDataListPage> {
UserIncomeModel? _model;
bool isLoading = true;
final incomeList = <IncomeData>[];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
final res = await MineService.fetchIncomeInfo();
isLoading = false;
_model = res;
incomeList.add(IncomeData("当月收益(元)", _model?.monthIncomeAmount ?? '0'));
incomeList.add(IncomeData("当月推广数", _model?.monthInviteUserCount ?? '0'));
incomeList.add(IncomeData("今日收益(元)", _model?.todayIncomeAmount ?? '0'));
incomeList.add(IncomeData("今日推广数", _model?.todayInviteUserCount ?? '0'));
if (mounted) setState(() {});
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('推广数据'),
actions: [
GestureDetector(
onTap: () => Get.to(() => ShareDetailsPage(model: _model)),
child: Text(
'收益明细',
style: TextStyle(color: Color(0xFF989898), fontSize: 12),
),
),
16.sizeBoxW
],
),
body: () {
if (isLoading) return LoadingCenterWidget();
if (_model == null) return CErrorWidget();
return Padding(
padding: EdgeInsets.only(left: 12, right: 12, top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border:
Border.all(color: const Color(0xff1e262e), width: 1),
color: Color(0xFF131b23)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"钱包余额",
style: const TextStyle(
color: const Color(0xffb8bbbd),
fontWeight: FontWeight.w900,
fontSize: 12.0),
),
Text(
"${_model?.totalAmount}",
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w500,
fontSize: 24.0),
)
],
),
),
SizedBox(
height: 57,
width: .5,
child: DecoratedBox(
decoration:
BoxDecoration(color: Color(0xff444444))),
),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"累积收益",
style: const TextStyle(
color: Color(0xffb8bbbd),
fontWeight: FontWeight.w900,
fontSize: 12.0),
),
Text(
"${_model?.totalIncomeAmount}",
style: const TextStyle(
color: Color(0xffffffff),
fontWeight: FontWeight.w500,
fontSize: 24.0),
)
],
),
),
],
),
),
InkWell(
enableFeedback: false,
onTap: () => Get.to(WithdrawalPage()),
child: Container(
width: 276,
height: 36,
margin: EdgeInsets.symmetric(vertical: 15),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(18)),
color: Color(0xFFcf452f),
),
child: Center(
child: Text(
"立即提现",
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w900,
fontSize: 12.0),
),
),
),
)
],
),
),
12.sizeBoxH,
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(6)),
border:
Border.all(color: const Color(0xff1e262e), width: 1),
color: Color(0xFF131b23),
),
child: GridView.count(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
crossAxisCount: 2,
childAspectRatio: 5 / 3,
children:
incomeList.map((e) => _buildIncomeGItem(e)).toList(),
),
),
15.sizeBoxH,
Text(
"推广总统计",
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w600,
fontSize: 16.0),
),
15.sizeBoxH,
_buildTGItem("累计推广用户", _model?.totalInviteUserCount ?? '0'),
_buildTGItem("累计付费用户", _model?.totalPayUserCount ?? '0'),
],
),
);
}());
}
Widget _buildTGItem(String title, String value) {
return Container(
height: 35,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(
color: const Color(0xffb8bbbd),
fontWeight: FontWeight.w400,
fontSize: 12.0),
),
Text(
"$value",
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w500,
fontSize: 18.0),
),
],
),
);
}
Widget _buildIncomeGItem(IncomeData item) {
return Container(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${item.value}",
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w900,
fontSize: 18.0),
),
Text(
item.title,
style: const TextStyle(
color: const Color(0xffb8bbbd),
fontWeight: FontWeight.w400,
fontSize: 12.0),
)
],
),
);
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/user/user_income_info_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.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:pull_to_refresh/pull_to_refresh.dart';
import 'share_data_list_page.dart';
class ShareDetailsPage extends StatefulWidget {
final UserIncomeModel? model;
const ShareDetailsPage({super.key, this.model});
@override
State<ShareDetailsPage> createState() => _ShareDetailsPageState();
}
class _ShareDetailsPageState extends State<ShareDetailsPage> {
RefreshController? _controller;
final incomeList = <IncomeData>[];
int page = 1;
bool isLoading = true;
final dataSource = [];
@override
void initState() {
super.initState();
final model = widget.model;
incomeList.add(IncomeData("总推广人数", model?.totalInviteUserCount ?? '0'));
incomeList.add(IncomeData("总推广收益", model?.totalIncomeAmount ?? '0'));
incomeList.add(IncomeData("今日推广人数", model?.todayInviteUserCount ?? '0'));
incomeList.add(IncomeData("今日推广收益", model?.todayIncomeAmount ?? '0'));
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_fetchPageData();
});
}
_fetchPageData({bool isRefresh = true}) async {
if (isRefresh) {
page = 1;
}
final res = await MineService.fetchIncomeList(page: 1);
isLoading = false;
if (isRefresh) {
dataSource.clear();
}
res?.hasNext ?? false
? _controller?.loadComplete()
: _controller?.loadNoData();
dataSource.addAll(res?.items ?? []);
page += 1;
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('推广数据'),
),
body: Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: pullYsRefresh(
onLoading: (_) => _fetchPageData(isRefresh: false),
onRefresh: (_) => _fetchPageData(),
child: CustomScrollView(
slivers: [
SliverList.separated(
itemCount: incomeList.length,
itemBuilder: (_, index) => _buildIncomeItem(incomeList[index]),
separatorBuilder: (_, index) => 10.sizeBoxH,
),
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 10),
// 收益明细
Text(
"收益明细",
style: const TextStyle(
color: const Color(0xffbec4d6),
fontWeight: FontWeight.w500,
fontSize: 18.0),
),
SizedBox(height: 10),
],
),
),
() {
if (isLoading)
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: LoadingCenterWidget(),
),
);
if (dataSource.isEmpty)
return SliverToBoxAdapter(
child: SizedBox(height: 300, child: CErrorWidget()));
return SliverList.builder(
itemCount: dataSource.length,
itemBuilder: (_, index) {
final item = dataSource[index];
return _buildRecordItem(item.userName, item.incomeAmount);
});
}()
],
),
onInit: (_) => _controller = _,
),
),
);
}
Widget _buildRecordItem(String title, int value) {
return Container(
height: 35,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: const TextStyle(
color: const Color(0xffffffff),
fontWeight: FontWeight.w400,
fontSize: 14.0),
),
Text(
"收益+$value元",
style: const TextStyle(
color: const Color(0xffF68804),
fontWeight: FontWeight.w400,
fontSize: 14.0),
),
],
),
);
}
Widget _buildIncomeItem(IncomeData item) {
return Container(
height: 65,
padding: EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(4)),
border: Border.all(color: const Color(0xff1e262e), width: 1),
color: Color(0xFF131b23),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
item.title,
style: const TextStyle(
color: const Color(0xE5FFFFFF),
fontWeight: FontWeight.w400,
fontSize: 14.0),
),
Text(
"${item.value}",
style: const TextStyle(
color: const Color(0x8CFFFFFF),
fontWeight: FontWeight.w500,
fontSize: 20.0),
),
],
),
);
}
}
@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../hj_model/mine/task_center_data.dart';
import '../../../hj_utils/api_service/common_service.dart';
import '../../../hj_utils/api_service/mine_service.dart';
class SignDailyPageLogic extends GetxController with GetTickerProviderStateMixin {
int shareCount = 0;
List<DailyTask>? taskList;
List<DailyTask>? dailyTask;
final outerCtr = ScrollController();
final tabKey = GlobalKey();
String signBackgroundImage = '';
final tabs = <String>["福利任务", "积分兑换"];
late final TabController tabCtr = TabController(
initialIndex: 0,
length: tabs.length,
vsync: this,
);
/// AppBar 背景透明度,由滚动驱动:0.0 透明 → 1.0 不透明
double appbarOpacity = 0.0;
/// 滚动多少像素后 AppBar 完全不透明
static const double _kAppBarFadeMaxOffset = 100.0;
@override
void onInit() {
super.onInit();
outerCtr.addListener(_onOuterScroll);
}
@override
void onReady() {
super.onReady();
loadData();
}
@override
void onClose() {
outerCtr.removeListener(_onOuterScroll);
outerCtr.dispose();
tabCtr.dispose();
super.onClose();
}
/// 监听外层滚动,按偏移量更新 AppBar 透明度并局部刷新
void _onOuterScroll() {
final newOpacity = (outerCtr.offset / _kAppBarFadeMaxOffset).clamp(0.0, 1.0);
if ((newOpacity - appbarOpacity).abs() < 0.01) return;
appbarOpacity = newOpacity;
update(['appbar']);
}
///初始化任务列表
Future<void> loadData() async {
final signResult = await MineService.getSignList();
signBackgroundImage = signResult?.config?.backgroundImage ?? '';
final result = await CommonService.getTaskList();
taskList ??= [];
taskList?.clear();
result?.dailyTask?.forEach((element) {
element.doType = 1;
});
taskList?.addAll(result?.dailyTask ?? []);
result?.growthTasks?.forEach((element) {
element.doType = 3;
});
taskList?.addAll(result?.growthTasks ?? []);
result?.onceTask?.forEach((element) {
element.doType = 2;
});
taskList?.addAll(result?.onceTask ?? []);
update();
}
}
@@ -0,0 +1,288 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart';
import 'package:flutter/cupertino.dart';
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/mine/widgets/integral_exchange_page.dart';
import 'package:hgdj/hj_utils/free_play_manager.dart';
import 'package:hgdj/hj_utils/screen.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 '../../../tools_base/global_store/store.dart';
import '../../../tools_base/indicator/custom_tab_indicator.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../../main_page/provider/msg_provider.dart';
import '../../pre_sale/pre_sale_provider.dart';
import '../mine_vip/mine_charge_vip_page.dart';
import '../widgets/exchange_vip_page.dart';
import 'sign_daily_logic.dart';
import 'widget/task_widgets.dart';
//每日签到
class SignDailyPage extends StatelessWidget {
const SignDailyPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<SignDailyPageLogic>(
init: SignDailyPageLogic(),
global: false,
builder: (logic) {
return Scaffold(
extendBodyBehindAppBar: true,
appBar: _buildAppBar(logic),
body: _buildBody(logic),
);
},
);
}
/// AppBar:背景透明度跟随滚动变化(局部刷新 id='appbar'
PreferredSizeWidget _buildAppBar(SignDailyPageLogic logic) {
return PreferredSize(
preferredSize: const Size.fromHeight(kToolbarHeight),
child: GetBuilder<SignDailyPageLogic>(
init: logic,
global: false,
id: 'appbar',
builder: (_) => AppBar(
title: const Text('每日签到'),
backgroundColor:
const Color(0xff030F18).withValues(alpha: logic.appbarOpacity),
elevation: 0,
),
),
);
}
Widget _buildBody(SignDailyPageLogic logic) {
if (logic.taskList == null) return const LoadingCenterWidget();
if (logic.taskList!.isEmpty) return const CErrorWidget();
return ExtendedNestedScrollView(
controller: logic.outerCtr,
physics: const BouncingScrollPhysics(),
onlyOneScrollInBody: true,
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
CupertinoSliverRefreshControl(
onRefresh: () async {
await logic.loadData();
},
),
SliverToBoxAdapter(child: _buildVIPNewGuide(logic)),
SliverToBoxAdapter(child: MineSignView(logic: logic)),
SliverToBoxAdapter(
child: Container(
key: logic.tabKey,
margin: const EdgeInsets.fromLTRB(16, 15, 0, 6),
child: _buildTab(logic),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'我的积分 ',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w400),
),
Consumer<GlobalStore>(
builder: (_, provider, __) => Text(
'${globalStore.wallet?.integral ?? 0}',
style: const TextStyle(
color: Color(0xffF68804),
fontSize: 24,
fontWeight: FontWeight.w700),
),
),
],
),
),
),
];
},
body: TabBarView(
controller: logic.tabCtr,
children: [
MineTaskView(logic.taskList!).keepAlive,
const IntegralExchangePage().keepAlive,
],
),
);
}
_buildTab(SignDailyPageLogic logic) {
return Row(
children: [
TabBar(
padding: EdgeInsets.zero,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelStyle: const TextStyle(
fontSize: 16,
color: Color(0xffF68804),
fontWeight: FontWeight.w500),
unselectedLabelStyle: const TextStyle(
fontSize: 16,
color: Color(0xff999999),
),
controller: logic.tabCtr,
tabs: logic.tabs.map((it) => Text(it)).toList(),
labelPadding: const EdgeInsets.fromLTRB(0, 0, 20, 6),
indicator: CustomIndicator(
width: 18,
height: 4,
borderRadius: const BorderRadius.all(Radius.circular(2)),
),
),
const Spacer(),
GestureDetector(
onTap: () => Get.to(ExchangeVipPage()),
child: const Padding(
padding: EdgeInsets.only(right: 16),
child: Text(
'兑换记录',
style: TextStyle(
color: Color(0xFF999999),
fontSize: 12,
fontWeight: FontWeight.w400,
),
),
))
],
);
}
_buildVIPNewGuide(SignDailyPageLogic logic) {
return Stack(
children: [
logic.signBackgroundImage.isNotEmpty
? NetworkImageLoader(
width: double.infinity,
height: 240,
fit: BoxFit.cover,
imageUrl: logic.signBackgroundImage,
)
: Image.asset(
"welfare_vip_background.webp".mineImgPath,
width: double.infinity,
height: 240,
fit: BoxFit.fitWidth,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
110.sizeBoxH,
Consumer<MineMsgProvider>(
builder: (context, provider, child) => Container(
margin: const EdgeInsets.only(left: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
NetworkImageLoader(
imageUrl: globalStore.meInfo?.portrait ?? '',
width: 60,
height: 60,
borderRadius: 30,
),
10.sizeBoxW,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
globalStore.meInfo?.name ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500),
),
if ((globalStore.meInfo?.vipLevel ?? 0) > 0) ...[
4.sizeBoxW,
Image.asset(
globalStore.meInfo?.vipImageName ?? "",
width: 30,
height: 18),
],
],
),
5.sizeBoxH,
Consumer<PreSaleProvider>(
builder: (context, provider, child) {
final isVip = globalStore.isVIP;
final watch =
FreePlayManager().remain?.watchCount ?? 0;
final aiFree =
globalStore.wallet?.aiUndressFreeTimes ?? 0;
final aiToday =
provider.remain?.todayAiUndressCount ?? 0;
return Text(
isVip
? "剩余AI次数:${aiFree + aiToday}"
: "剩余观看次数:$watch",
style: const TextStyle(
fontSize: 12, color: Color(0xff656565)),
);
},
)
],
),
],
),
),
),
22.sizeBoxH,
if (!globalStore.isVIP)
GestureDetector(
onTap: () {
Get.to(MineChargeVipPage());
},
child: Container(
height: 44,
width: double.infinity,
alignment: Alignment.center,
padding:
EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h),
margin: EdgeInsets.symmetric(horizontal: 40.w, vertical: 0),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(90)),
gradient: LinearGradient(
colors: [Color(0xFFFF9077), Color(0xFFFF6E4E)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Text(
globalStore.isVIP ? '已开通会员' : '开通VIP不限次数免费观看',
style: TextStyle(
color: Color(
globalStore.isVIP ? 0xfffffffff : 0xfffffffff),
fontSize: 16,
fontWeight: FontWeight.w700),
),
),
),
if (globalStore.isVIP) TaskCenterVIPGuideView(),
20.sizeBoxW,
],
),
)
],
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import '../../../tools_base/indicator/custom_tab_indicator.dart';
import 'lou_feng_ad_page.dart';
import 'mine_income_page.dart';
class WelfareHomePage extends StatefulWidget {
///默认选中的 tab:0 分享邀请 / 1 应用推荐
final int index;
const WelfareHomePage({super.key, this.index = 0});
@override
State<StatefulWidget> createState() {
return _WelfareHomePageState();
}
}
class _WelfareHomePageState extends State<WelfareHomePage>
with TickerProviderStateMixin {
final tabs = <String>['分享邀请', "应用推荐"];
late final TabController _tabController = TabController(
initialIndex: widget.index, length: tabs.length, vsync: this);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false, // 不自动显示返回按钮
toolbarHeight: 56, // 高度保持默认
titleSpacing: 0, // 去除左右空隙
title: Row(
children: [
// 返回按钮
IconButton(
icon: Icon(Icons.arrow_back_ios),
onPressed: () => Get.back(),
),
Expanded(
child: TabBar(
padding: EdgeInsets.symmetric(horizontal: 0, vertical: 5),
isScrollable: false,
labelStyle: TextStyle(
fontSize: 14,
color: Color(0xE5FFFFFF),
fontWeight: FontWeight.w400),
unselectedLabelStyle:
TextStyle(fontSize: 14, color: Color(0x73FFFFFF)),
indicator: CustomIndicator(
width: 18,
height: 4,
isGradient: true,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(2),
topRight: Radius.circular(2),
bottomLeft: Radius.circular(0),
bottomRight: Radius.circular(2),
),
gradientColors: const [Color(0x55F68804), Color(0xffF68804)],
offsetY: -8,
),
controller: _tabController,
tabs: tabs.map((it) => Text(it)).toList(),
labelPadding: EdgeInsets.symmetric(horizontal: 14, vertical: 2),
),
),
SizedBox(width: 54),
],
),
),
body: Column(
children: [
Expanded(
child: TabBarView(
controller: _tabController,
children: [
MineIncomePage().keepAlive,
LouFengAdPage().keepAlive,
],
),
),
],
),
);
}
}
@@ -0,0 +1,277 @@
/// 签到列表响应模型
class CheckinPrizeResp {
CheckinInfo? checkin;
CheckinConfig? config;
List<CheckinPrize>? prizes;
List<CheckinPrize>? bigPrizes;
CheckinPrizeResp({this.checkin, this.config, this.prizes});
CheckinPrizeResp.fromJson(Map<String, dynamic> json) {
checkin = json['checkin'] != null
? CheckinInfo.fromJson(json['checkin'])
: null;
config = json['config'] != null
? CheckinConfig.fromJson(json['config'])
: null;
if (json['prizes'] != null) {
prizes = <CheckinPrize>[];
json['prizes'].forEach((v) {
prizes!.add(CheckinPrize.fromJson(v));
});
}
if (json['bigPrizes'] != null) {
bigPrizes = <CheckinPrize>[];
json['bigPrizes'].forEach((v) {
bigPrizes!.add(CheckinPrize.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (checkin != null) {
data['checkin'] = checkin!.toJson();
}
if (config != null) {
data['config'] = config!.toJson();
}
if (prizes != null) {
data['prizes'] = prizes!.map((v) => v.toJson()).toList();
}
if (bigPrizes != null) {
data['bigPrizes'] = bigPrizes!.map((v) => v.toJson()).toList();
}
return data;
}
}
/// 签到信息
class CheckinInfo {
int? continuouslyDays; // 连续签到天数
int? cumulativeDays; // 累计签到天数(本月)
bool? todayChecked; // 今日是否已经签过到
bool? doubleReward;
CheckinInfo({this.continuouslyDays, this.cumulativeDays, this.todayChecked,this.doubleReward});
CheckinInfo.fromJson(Map<String, dynamic> json) {
continuouslyDays = json['continuouslyDays'];
cumulativeDays = json['cumulativeDays'];
todayChecked = json['todayChecked'];
doubleReward=json['doubleReward'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['continuouslyDays'] = continuouslyDays;
data['cumulativeDays'] = cumulativeDays;
data['todayChecked'] = todayChecked;
data['doubleReward']=doubleReward;
return data;
}
}
/// 签到配置
class CheckinConfig {
String? backgroundImage; // 背景图片
String? description; // 规则说明
bool? enable; // 是否启用
String? rewardBgVideoUrl; //视频
List<IntegerExchange>? integerExchangeList;
CheckinConfig({this.backgroundImage, this.description, this.enable,this.rewardBgVideoUrl});
CheckinConfig.fromJson(Map<String, dynamic> json) {
backgroundImage = json['backgroundImage'];
description = json['description'];
enable = json['enable'];
rewardBgVideoUrl = json['rewardBgVideoUrl'];
if (json['integerExchangeList'] != null) {
integerExchangeList = <IntegerExchange>[];
json['integerExchangeList'].forEach((v) {
integerExchangeList!.add(IntegerExchange.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['backgroundImage'] = backgroundImage;
data['description'] = description;
data['enable'] = enable;
data['rewardBgVideoUrl'] = rewardBgVideoUrl;
if (integerExchangeList != null) {
data['integerExchangeList'] = integerExchangeList!.map((v) => v.toJson()).toList();
}
return data;
}
}
class IntegerExchange{
String? name;
String? icon;
IntegerExchange({this.name, this.icon});
IntegerExchange.fromJson(Map<String, dynamic> json) {
name = json['name'];
icon = json['icon'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['name'] = name;
data['icon'] = icon;
return data;
}
}
/// 签到奖励
class CheckinPrize {
bool? bigPrize; // 大奖,有问题提示的
int? checkinDays; // 签到天数
int? checkinType; // 签到类型 Enum: 0, 1, 2
String? createdAt;
String? id; // ID
String? image; // 图片
String? prizeId; // 奖品ID
bool? status; // 状态(是否已领取)
String? title; // 标题
String? updatedAt;
int? score; // 签到奖励积分数量
String? prizeName;
bool? isCheckedIn;
bool? canClaim;//是否可以补领
bool? isReceive;//是否可以补领
bool? isExpired;//是否过期
CheckinPrize({
this.bigPrize,
this.checkinDays,
this.checkinType,
this.createdAt,
this.id,
this.image,
this.prizeId,
this.status,
this.title,
this.updatedAt,
this.score,
this.prizeName,
this.isCheckedIn,
this.isReceive,
this.canClaim,
this.isExpired,
});
CheckinPrize.fromJson(Map<String, dynamic> json) {
bigPrize = json['bigPrize'];
checkinDays = json['checkinDays'];
checkinType = json['checkinType'];
createdAt = json['createdAt'];
id = json['id'];
image = json['image'];
prizeId = json['prizeId'];
status = json['status'];
title = json['title'];
updatedAt = json['updatedAt'];
score = json['score'];
prizeName = json['prizeName'];
isCheckedIn = json['isCheckedIn'];
canClaim = json['canClaim'];
isReceive = json['isReceive'];
isExpired = json['isExpired'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['bigPrize'] = bigPrize;
data['checkinDays'] = checkinDays;
data['checkinType'] = checkinType;
data['createdAt'] = createdAt;
data['id'] = id;
data['image'] = image;
data['prizeId'] = prizeId;
data['status'] = status;
data['title'] = title;
data['updatedAt'] = updatedAt;
data['score'] = score;
data['prizeName'] = prizeName;
data['isCheckedIn'] = isCheckedIn;
data['canClaim'] = canClaim;
data['isReceive'] = isReceive;
data['isExpired'] = isExpired;
return data;
}
}
/// 执行签到响应
class CheckinDoResp {
CheckinInfo? checkin;
String? message;
List<CheckinRewardPrize>? prizes;
String? prizeVideo;
CheckinDoResp({this.checkin, this.message, this.prizes});
CheckinDoResp.fromJson(Map<String, dynamic> json) {
checkin = json['checkin'] != null
? CheckinInfo.fromJson(json['checkin'])
: null;
message = json['message'];
prizeVideo = json['prizeVideo'];
if (json['prizes'] != null) {
prizes = <CheckinRewardPrize>[];
json['prizes'].forEach((v) {
prizes!.add(CheckinRewardPrize.fromJson(v));
});
}
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
if (checkin != null) {
data['checkin'] = checkin!.toJson();
}
data['message'] = message;
data['prizeVideo'] = prizeVideo;
if (prizes != null) {
data['prizes'] = prizes!.map((v) => v.toJson()).toList();
}
return data;
}
}
/// 签到奖励详情
class CheckinRewardPrize {
bool? countRand;
int? prizeCount;
String? prizeImage;
String? prizeTitle;
int? prizeType;
CheckinRewardPrize({
this.countRand,
this.prizeCount,
this.prizeImage,
this.prizeTitle,
this.prizeType,
});
CheckinRewardPrize.fromJson(Map<String, dynamic> json) {
countRand = json['countRand'];
prizeCount = json['prizeCount'];
prizeImage = json['prizeImage'];
prizeTitle = json['prizeTitle'];
prizeType = json['prizeType'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = {};
data['countRand'] = countRand;
data['prizeCount'] = prizeCount;
data['prizeImage'] = prizeImage;
data['prizeTitle'] = prizeTitle;
data['prizeType'] = prizeType;
return data;
}
}
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:video_player/video_player.dart';
import 'checkin_model.dart';
class CongratulationsRewardDialog extends StatelessWidget {
final VoidCallback? onExchange;
final List<IntegerExchange>? prizeList;
// final String? videoUrl;
// final String? videoToken;
final VideoPlayerController? videoPlayerCtr;
const CongratulationsRewardDialog({
super.key,
this.onExchange,
this.prizeList,
// this.videoUrl,
// this.videoToken,
this.videoPlayerCtr,
});
@override
Widget build(BuildContext context) {
// 不包 Dialog / 不铺满屏幕 → 点击 content 之外的区域由 Get.dialog 的 barrierDismissible 关闭
return Container(
padding: EdgeInsets.symmetric(horizontal: 27),
alignment: Alignment.center,
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 356,
width: 319,
child: VideoPlayer(videoPlayerCtr!),
),
SizedBox(height: 18),
Text(
"- 积分可兑换以下豪礼 -",
style: TextStyle(color: Color(0xffFFD900), fontSize: 14),
),
SizedBox(height: 12),
// 奖励列表
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start,
children: prizeList
?.map((e) => _buildRewardItem(e.name ?? "", e.icon ?? ""))
.toList() ??
[],
),
SizedBox(height: 36),
// 底部按钮
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
Get.back();
onExchange?.call();
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: const Color(0xFFFFD700), width: 2),
),
child: Text(
"兑换好礼",
style: TextStyle(
color: const Color(0xFFFFD700),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => Get.back(),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color(0xffFFD900),
borderRadius: BorderRadius.circular(24),
),
child: Text(
"继续领积分",
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
).paddingSymmetric(horizontal: 33),
],
),
),
);
}
Widget _buildRewardItem(String title, String iconPath) {
return Column(
children: [
Container(
width: 53,
height: 53,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: .5), width: 0.5)),
padding: EdgeInsets.all(6),
child: NetworkImageLoader(imageUrl: iconPath, fit: BoxFit.contain),
),
SizedBox(height: 6),
SizedBox(
width: 65,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
);
}
}
@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/screen.dart';
class CustomProgressBar extends StatelessWidget {
final double progress; // 0 ~ 1
const CustomProgressBar({super.key, required this.progress});
@override
Widget build(BuildContext context) {
return Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
double width = constraints.maxWidth;
return Stack(
alignment: Alignment.centerLeft,
children: [
SizedBox(height: 18),
// 背景
Container(
height: 6,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .1),
borderRadius: BorderRadius.circular(8),
),
),
// 进度
Container(
height: 6,
width: width * progress,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Color(0xffF68804),
),
),
// 小图标(跟随进度,两端不溢出)
Positioned(
left: (width - 18) * progress,
child: Image.asset("ic_sign_indicator.webp".mineImgPath,
width: 18, height: 18, fit: BoxFit.cover),
),
],
);
},
),
),
8.sizeBoxW,
// 右侧百分比
Text(
"${(progress * 100).toInt()}%",
style: TextStyle(color: Colors.white, fontSize: 12),
),
],
),
);
}
}
@@ -0,0 +1,323 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/config/address.dart';
import 'package:hgdj/hj_page/mine/mine_share/mine_share_page.dart';
import 'package:hgdj/hj_page/mine/welfare/sign_daily_logic.dart';
import 'package:hgdj/hj_page/mine/welfare/widget/checkin_model.dart';
import 'package:hgdj/hj_page/mine/welfare/widget/congratulations_reward_dialog.dart';
import 'package:hgdj/hj_page/mine/welfare/widget/sign_dialog.dart';
import 'package:hgdj/hj_page/mine/welfare/widget/sign_in_model.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:linked_scroll_controller/linked_scroll_controller.dart';
import 'package:video_player/video_player.dart';
import 'package:hgdj/hj_utils/video_view_type.dart';
/// 每日签到 Logic
/// 负责签到列表加载、签到/补领接口、双倍奖励视频播放、跳转积分兑换 Tab
class MineSignLogic extends GetxController {
/// 父级页面 Logic(用于切 Tab + 滚动到 Tab 位置)
final SignDailyPageLogic? parentLogic;
MineSignLogic({this.parentLogic});
// ========== 状态 ==========
bool isSigned = false; // 今日是否签到
bool doubleReward = false; // 是否触发双倍奖励
int continuousDays = 0; // 连续签到天数
int cumulativeDays = 0; // 累计签到天数(本月)
String? videoUrl; // 双倍奖励视频 ID
List<CheckinPrize>? checkList; // 普通签到奖励
List<CheckinPrize>? vipCheckList; // 会员签到奖励(大礼)
List<IntegerExchange>? prizeLists; // 积分兑换列表
final extraRewardList = <ExtraSignRewardItem>[]; // 额外签到奖励
// ========== 滚动控制器(三排联动) ==========
late final LinkedScrollControllerGroup scrollGroup;
late final ScrollController titleScrollCtr; // 第 0 排:天数标题
late final ScrollController normalScrollCtr; // 第 1 排:普通奖励
late final ScrollController vipScrollCtr; // 第 2 排:会员奖励
VideoPlayerController? videoPlayerCtr;
// ========== 生命周期 ==========
@override
void onInit() {
super.onInit();
scrollGroup = LinkedScrollControllerGroup();
titleScrollCtr = scrollGroup.addAndGet();
normalScrollCtr = scrollGroup.addAndGet();
vipScrollCtr = scrollGroup.addAndGet();
getSignList();
getExtralSignList();
}
@override
void onClose() {
titleScrollCtr.dispose();
normalScrollCtr.dispose();
vipScrollCtr.dispose();
videoPlayerCtr?.dispose();
super.onClose();
}
// ========== 公开方法 ==========
/// 获取签到主列表(普通 + 会员奖励 + 积分兑换配置)
Future<void> getSignList() async {
final res = await MineService.getSignList();
if (res == null) return;
isSigned = res.checkin?.todayChecked ?? false;
continuousDays = res.checkin?.continuouslyDays ?? 0;
cumulativeDays = res.checkin?.cumulativeDays ?? 0;
doubleReward = res.checkin?.doubleReward ?? false;
prizeLists = res.config?.integerExchangeList;
checkList = res.prizes;
vipCheckList = res.bigPrizes;
update();
}
/// 获取额外签到奖励列表
Future<void> getExtralSignList() async {
try {
final res = await MineService.getExtraDayMark();
if (res != null) {
extraRewardList
..clear()
..addAll(res);
update();
}
} catch (_) {}
}
/// 点击签到按钮
Future<void> doSignIn() async {
if (isSigned) {
showToast('今日已签到');
return;
}
final res = await MineService.postDayMark();
if (res?.checkin == null) return;
showToast('签到成功');
// 同步签到状态
continuousDays = res!.checkin!.continuouslyDays ?? 0;
cumulativeDays = res.checkin!.cumulativeDays ?? 0;
isSigned = res.checkin!.todayChecked ?? false;
doubleReward = res.checkin!.doubleReward ?? false;
videoUrl = res.prizeVideo ?? '';
getSignList();
update();
globalStore.refreshWallet();
// 第 8 天起仅 toast 不弹窗;前 7 天:双倍奖励弹特效,否则弹普通签到弹窗
if (continuousDays > 7) return;
if (doubleReward) {
_showDoubleRewardDialog();
} else {
_showNormalSignDialog();
}
}
/// 会员奖励补领
Future<void> doCheckinClaimVip() async {
final resp = await MineService.claimCheckinVip();
if (resp?.checkin == null) {
showToast(resp?.message ?? '补领失败');
return;
}
showToast(resp?.message ?? '补领成功');
continuousDays = resp!.checkin!.continuouslyDays ?? 0;
cumulativeDays = resp.checkin!.cumulativeDays ?? 0;
isSigned = resp.checkin!.todayChecked ?? false;
doubleReward = resp.checkin!.doubleReward ?? false;
videoUrl = resp.prizeVideo ?? '';
update();
await getSignList();
globalStore.refreshWallet();
}
/// 补签:调用接口 + 弹"邀请分享"引导弹窗
Future<void> doResign(SignInListItem item) async {
await MineService.postReSign(item.id ?? '');
Get.dialog(
_ResignGuideDialog(
onConfirm: () {
Get.back();
Get.to(() => MineSharePage());
},
onCancel: Get.back,
),
);
}
/// 切到"积分兑换" Tab,并把页面滚到 Tab 栏刚好贴在 AppBar 下方
void gotoExchangeTab() {
final parent = parentLogic;
if (parent == null) return;
parent.tabCtr.animateTo(1);
// 等一帧让弹窗 dismiss 后 layout 稳定,再算位置
WidgetsBinding.instance
.addPostFrameCallback((_) => _scrollTabKeyBelowAppBar());
}
/// 让 parent.tabKey 对应的 widget 滚到 AppBar 下沿
void _scrollTabKeyBelowAppBar() {
final parent = parentLogic;
if (parent == null || !parent.outerCtr.hasClients) return;
final ctx = parent.tabKey.currentContext;
if (ctx == null) return;
final box = ctx.findRenderObject() as RenderBox?;
if (box == null || !box.attached) return;
// tabKey widget 当前在屏幕中的 Y 坐标
final dy = box.localToGlobal(Offset.zero).dy;
// AppBar 总高度 = 状态栏 + 工具栏
final appBarHeight = MediaQuery.of(ctx).padding.top + kToolbarHeight;
// 目标偏移 = 当前偏移 + (widget 屏幕 Y - AppBar 高度)
// 滚完后 widget 顶端正好贴 AppBar 底部
final target = (parent.outerCtr.offset + dy - appBarHeight).clamp(
0.0,
parent.outerCtr.position.maxScrollExtent,
);
parent.outerCtr.animateTo(
target,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
// ========== 私有方法 ==========
/// 弹双倍奖励特效弹窗(视频循环播放)
/// 弹窗关闭后会自动暂停并释放 videoPlayerCtr,避免后台仍在播放
void _showDoubleRewardDialog() async {
videoPlayerCtr?.dispose();
final ctr = PlayerFactory.network(
'${Address.baseApiPath}/vid/h5/m3u8/$videoUrl?token=${Address.token}&c=${Address.cdnAddress}');
videoPlayerCtr = ctr;
final dialogFuture = Get.dialog(
CongratulationsRewardDialog(
onExchange: gotoExchangeTab,
prizeList: prizeLists,
videoPlayerCtr: ctr,
),
);
try {
await ctr.initialize();
await ctr.setLooping(true);
await ctr.play();
} catch (e) {
// 弹窗已用此 ctr 构建,无法换 view 重试,仅内存切换;落本地由后续成功播放的播放器确认
if (isDecoderError(e)) switchToPlatformView();
}
// 等弹窗 dismiss(点击关闭/兑换/外部点击/back 都会触发)
await dialogFuture;
await ctr.pause();
await ctr.dispose();
// 防止与下次双倍奖励的新实例冲突
if (identical(videoPlayerCtr, ctr)) videoPlayerCtr = null;
}
/// 弹普通签到弹窗
void _showNormalSignDialog() {
Get.dialog(
SignInDialog(
signInList: checkList,
continuousDays: continuousDays,
prizeList: prizeLists,
callback: gotoExchangeTab,
),
);
}
}
/// 补签引导弹窗(私有给 Logic 用)
class _ResignGuideDialog extends StatelessWidget {
final VoidCallback onConfirm;
final VoidCallback onCancel;
const _ResignGuideDialog({required this.onConfirm, required this.onCancel});
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
children: [
Container(
width: 300,
margin: const EdgeInsets.only(left: 32, right: 32, bottom: 100),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(20)),
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
margin: const EdgeInsets.only(top: 30),
padding: const EdgeInsets.fromLTRB(24, 100, 24, 24),
decoration: BoxDecoration(
image: DecorationImage(
image: AssetImage('icon_resign_bg.webp'.mineImgPath),
fit: BoxFit.fill,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 260),
Row(
children: [
Expanded(
child: _btn(onCancel, '离开', isPrimary: false)),
const SizedBox(width: 16),
Expanded(
child: _btn(onConfirm, '立即邀请', isPrimary: true)),
],
),
],
),
),
],
),
),
Positioned(
top: 0,
right: 10,
child: GestureDetector(
onTap: onCancel,
child: Image.asset('icon_resign_close.png'.mineImgPath,
width: 30, height: 30),
),
),
],
),
);
}
Widget _btn(VoidCallback onTap, String text, {required bool isPrimary}) {
return GestureDetector(
onTap: onTap,
child: Container(
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: isPrimary ? null : Colors.white.withValues(alpha: 0.8),
gradient: isPrimary
? const LinearGradient(
colors: [Color(0xFFF68804), Color(0xFFF68804)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
)
: null,
borderRadius: BorderRadius.circular(25),
border: isPrimary
? null
: Border.all(color: const Color(0xFFB7B7B7), width: 1),
),
child: Text(
text,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: isPrimary ? Colors.white : const Color(0xFF666666),
),
),
),
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_model/mine/happy/happy_model.dart';
import 'package:hgdj/hj_model/splash/ads_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/banner/ads_banner_widget.dart';
import '../../../../tools_base/banner/ads_item.dart';
class RecommendAppPage extends StatelessWidget {
final AdTabConfig model;
const RecommendAppPage(this.model, {super.key});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
if (model.bannerAds?.isNotEmpty ?? false)
SliverPadding(
padding: EdgeInsets.only(left: 16, right: 16, top: 12),
sliver: SliverToBoxAdapter(
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: AspectRatio(
aspectRatio: 343 / 143,
child: AdsBannerWidget(
model.bannerAds ?? [],
),
),
),
),
),
if (model.hengAds != null)
SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: _buildNormalTitle(model.hengAds?.title ?? ''),
),
SliverToBoxAdapter(
child: 10.sizeBoxH,
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverGrid.builder(
itemCount: model.hengAds?.items?.length,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 2,
mainAxisSpacing: 10,
childAspectRatio: 82 / 105,
),
itemBuilder: (_, index) =>
_buildVItem(model.hengAds!.items![index], index)),
)
],
),
if (model.shuAds != null)
SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: _buildNormalTitle(model.shuAds?.title ?? ''),
),
SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.separated(
itemCount: model.shuAds?.items?.length ?? 0,
separatorBuilder: (_, __) =>
model.shuAds?.title == '热门推荐' ? 10.sizeBoxH : 1.line,
itemBuilder: (_, index) {
final info = model.shuAds!.items![index];
if (model.shuAds?.title == '热门推荐')
return _buildHBItem(info, index);
return _buildHItem(info, index);
},
),
)
],
),
],
);
}
Widget _buildNormalTitle(String title) {
return Padding(
padding: EdgeInsets.fromLTRB(0, 20, 0, 0),
child: Row(
children: [
15.sizeBoxW,
Text(
title,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 16.0,
),
),
],
),
);
}
/// 竖版的广告
_buildVItem(AdsInfoModel item, int index) {
return AdsItem(
adInfo: item,
clickType: 0,
);
}
_buildHItem(AdsInfoModel item, int index) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
12.sizeBoxH,
AdsItem(
adInfo: item,
showType: AdShowType.hor,
clickType: 0,
),
12.sizeBoxH,
],
);
}
_buildHBItem(AdsInfoModel item, int index) {
return AdsItem(
adInfo: item,
showType: AdShowType.vImgText,
clickType: 0,
);
}
}
@@ -0,0 +1,287 @@
import 'package:easy_rich_text/easy_rich_text.dart';
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 '../../../../tools_base/widget/net_image_widget.dart';
import 'checkin_model.dart';
class SignInDialog extends StatelessWidget {
final List<CheckinPrize>? signInList; // 签到日历数据
final int? continuousDays; // 连续签到天数
final Function? callback;
final List<IntegerExchange>? prizeList;
SignInDialog(
{super.key,
this.signInList,
this.continuousDays,
this.callback,
this.prizeList});
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.symmetric(horizontal: 30),
child: Stack(
children: [
Container(
padding: EdgeInsets.only(left: 12, right: 12, top: 12, bottom: 40),
margin: EdgeInsets.only(top: 50),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Color(0xff0F0F0F),
border: Border.all(width: 1, color: Color(0xffFFE7B4))),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 描述
EasyRichText(
'已连续签到 $continuousDays 天,第7天有惊喜好礼相送!',
defaultStyle: TextStyle(color: Colors.white, fontSize: 14),
patternList: [
EasyRichTextPattern(
stringBeforeTarget: '已连续签到 ',
targetString: '$continuousDays',
style: TextStyle(color: Color(0xffFFDA0B), fontSize: 14),
),
EasyRichTextPattern(
targetString: '第7天有惊喜好礼相送!',
style: TextStyle(color: Color(0xffFFDA0B), fontSize: 14),
),
],
),
12.sizeBoxH,
Divider(height: 1, color: Colors.white.withValues(alpha: .1)),
12.sizeBoxH,
// Grid 奖励
_buildGrid(),
13.sizeBoxH,
Container(
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Color(0xffFFD900).withValues(alpha: .1),
borderRadius: BorderRadius.circular(12)),
child: Column(
children: [
Text(
"- 积分可兑换以下豪礼 -",
style: TextStyle(
color: Color(0xffFFD900),
fontSize: 12,
),
),
SizedBox(height: 12),
// 奖励列表
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start,
children: prizeList
?.map((e) => _buildRewardItem(
e.name ?? "", e.icon ?? ""))
.toList() ??
[],
),
],
),
),
SizedBox(height: 24),
// 按钮
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () {
Get.back();
callback?.call();
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: const Color(0xFFFFD700), width: 2),
),
child: Text(
"兑换好礼",
style: TextStyle(
color: const Color(0xFFFFD700),
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () {
Get.back();
},
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Color(0xffFFD900),
borderRadius: BorderRadius.circular(24),
),
child: Text(
"继续领积分",
style: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
],
),
),
Positioned(
left: 0,
right: 0,
child: Image.asset('sign_take_top.webp'.mineImgPath,
height: 30, fit: BoxFit.contain)),
],
),
);
}
// =========================
// Grid
// =========================
Widget _buildGrid() {
final list = signInList?.take(6).toList() ?? [];
return Container(
height: 68,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(20)),
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
physics: const ScrollPhysics(),
itemCount: list.length,
padding: EdgeInsets.zero,
itemBuilder: (_, i) {
final item = list[i];
return _buildItem(item, i);
},
),
);
}
// =========================
// 单个格子
// =========================
Widget _buildItem(CheckinPrize item, int index) {
final isSigned = (continuousDays ?? 0) > index;
bool active = continuousDays == index;
return Container(
margin: EdgeInsets.only(right: 7),
width: 42,
height: 68,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Colors.white.withValues(alpha: .1),
border: active
? Border.all(color: const Color(0xffFFDA0B), width: 0.5)
: null,
),
// padding: EdgeInsets.all(5),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
(item.image != null && item.image!.isNotEmpty)
? ColorFiltered(
colorFilter: isSigned == true
? ColorFilter.matrix([
0.2126,
0.7152,
0.0722,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0.2126,
0.7152,
0.0722,
0,
0,
0,
0,
0,
1,
0,
])
: ColorFilter.mode(
Colors.transparent, BlendMode.multiply),
child: NetworkImageLoader(
imageUrl: item.image!,
width: 34,
height: 34,
fit: BoxFit.contain))
: Image.asset(
isSigned == true
? 'checked_in_coin.webp'.mineImgPath
: 'check_in_coin.webp'.mineImgPath,
width: 34,
height: 34,
fit: BoxFit.cover),
Text(
item.prizeName ?? '',
style: TextStyle(
color: isSigned == true ? Colors.white : Color(0xffFFD900),
fontSize: 10),
),
],
),
);
}
Widget _buildRewardItem(String title, String iconPath) {
return Column(
children: [
Container(
width: 53,
height: 53,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: Colors.white.withValues(alpha: .2), width: 0.5)),
padding: EdgeInsets.all(6),
child: NetworkImageLoader(imageUrl: iconPath, fit: BoxFit.contain),
),
SizedBox(height: 6),
SizedBox(
width: 60,
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
],
);
}
}

Some files were not shown because too many files have changed in this diff Show More