初始化

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,30 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/config/config.dart';
import '../../main_page/provider/bottom_bar_provider.dart';
class CommunityMainModuleLogic extends GetxController
with GetTickerProviderStateMixin {
final int defaultIndex;
final modules = [];
late final TabController tabController;
CommunityMainModuleLogic(this.defaultIndex);
@override
void onInit() {
super.onInit();
modules.addAll(Config.plateModule?.community ?? []);
tabController = TabController(
length: modules.length, vsync: this, initialIndex: defaultIndex);
tabController.addListener(() {
if (!tabController.indexIsChanging) {
CommunityBottomProvider().subIndex = tabController.index;
}
});
}
void skipToTabIndex(int index) {
tabController.index = index;
}
}
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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 '../../../tools_base/loading/loading_center_widget.dart';
import '../../home/widget/publist_entry_alert.dart';
import 'community_module_logic.dart';
import 'community_tab_page.dart';
//社区帖子亚模块
class CommunityModulePage extends StatelessWidget {
final int defaultIndex;
const CommunityModulePage({super.key, this.defaultIndex = 0});
@override
Widget build(BuildContext context) {
return GetBuilder<CommunityMainModuleLogic>(
init: CommunityMainModuleLogic(defaultIndex),
builder: (logic) {
if (logic.modules.isEmpty) return CErrorWidget();
return Stack(
children: [
Column(
children: [
TabBar(
labelPadding: EdgeInsets.zero,
padding: EdgeInsets.zero,
tabAlignment: TabAlignment.start,
indicator: CustomIndicator(offsetY: 6),
indicatorWeight: 1,
unselectedLabelStyle:
TextStyle(color: Color(0x8cFFFFFF), fontSize: 16),
labelStyle: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 16,
fontWeight: FontWeight.w500),
isScrollable: true,
tabs: logic.modules
.map(
(e) => Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.w, vertical: 13.h),
child: Text(e.moduleName ?? ''),
),
)
.toList(),
controller: logic.tabController,
),
Expanded(
child: TabBarView(
controller: logic.tabController,
children: logic.modules
.asMap()
.map(
(key, value) => MapEntry(
key,
CommunityTabPage(
model: value,
index: key,
cancelCallBack: () {
logic.tabController.index =
logic.tabController.previousIndex;
},
).keepAlive,
),
)
.values
.toList(),
),
)
],
),
Positioned(
right: 20,
bottom: 50,
child: PublishButton(entry: PublishEntry.community),
),
],
);
},
);
}
}
@@ -0,0 +1,139 @@
import 'dart:async';
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:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../home/widget/collect_status_wrapper.dart';
import '../../main_page/provider/bottom_bar_provider.dart';
class CommunityTabLogic extends GetxController {
final ModuleData? model;
final int index;
CommunityTabLogic(this.model, {this.index = 0});
RefreshController? refreshController;
late ScrollController sctrl =
CommunityBottomProvider().scrollCtr(MediaStyle.Community, index);
int page = 1;
bool isLoading = true;
/// 索引
int sort = 0;
//标题与后端 moduleSort 绑在一起:原来标题在 CommunityMiddleHeader、值在这里,
//两个数组分家,任一边加减一项就会整体错位
static const sortTabs = [
SortTab('推荐', 7),
SortTab('最新', 1),
SortTab('最热', 2),
SortTab('热评', 9),
];
int? tagIndex;
String? tagId;
List<VideoModel> dataSource = [];
/// 顶部的专题
final List<TagsBean> specials = [];
StreamSubscription? _collectStatusSub;
void _onCollectStatusChanged(CollectStatusModel event) {
if (event.isLiked == null && event.likeCountDelta == null) return;
var changed = false;
for (final item in dataSource) {
if (item.id == event.id) {
applyVideoModelCollectStatus(item, event);
changed = true;
}
}
if (changed) update();
}
@override
void onInit() {
super.onInit();
_collectStatusSub =
eventBus.on<CollectStatusModel>(_onCollectStatusChanged);
}
@override
void onClose() {
_collectStatusSub?.cancel();
super.onClose();
}
@override
void onReady() {
super.onReady();
tagId = model?.defaultTagId;
fetchPagedata();
}
void changeSpecialsIndex(int index) {
if (tagIndex == index) {
tagId = null;
tagIndex = null;
} else {
tagIndex = index;
tagId = specials[index].id;
}
fetchPagedata(needLoading: true);
}
void scrollerToTop() {
sctrl.jumpTo(0);
}
Future<void> fetchPagedata(
{bool isRefresh = true, bool needLoading = false}) async {
if (isRefresh) {
page = 1;
}
if (needLoading) {
isLoading = true;
update();
}
try {
final res = await VidService.getModuleDetail(
model?.id ?? '',
pageNumber: page,
moduleSort: sortTabs[sort].sort,
tagId: tagId,
);
if (isRefresh) {
dataSource.clear();
refreshController?.refreshCompleted();
}
(res?.hasNext ?? false)
? refreshController?.loadComplete()
: refreshController?.loadNoData();
dataSource.addAll(res?.allVideoInfo ?? []);
if ((res?.allSection?.isNotEmpty ?? false) && specials.isEmpty) {
specials.addAll(res!.allSection?.firstOrNull?.allTags ?? []);
if (model?.defaultTagId?.isNotEmpty == true) {
tagIndex = specials
.indexWhere((element) => element.id == model?.defaultTagId);
}
}
page += 1;
} catch (e) {
// 接口异常兜底:结束刷新/加载态,避免 loading、下拉/上拉指示器卡死
debugLog(e);
isRefresh
? refreshController?.refreshCompleted()
: refreshController?.loadComplete();
} finally {
isLoading = false;
update();
}
}
}
@@ -0,0 +1,223 @@
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/hj_utils/screen.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import '../../../hj_model/home/plate_model.dart';
import '../../../hj_model/video_model.dart';
import '../../../hj_utils/sliver_delegate.dart';
import '../../../tools_base/widget/net_image_widget.dart';
import '../widget/community_middle_header.dart';
import '../widget/community_post_widget.dart';
import 'community_tab_logic.dart';
import 'package:hgdj/extension/extensions.dart';
class CommunityTabPage extends StatelessWidget {
final ModuleData model;
final int index;
final Function()? cancelCallBack;
const CommunityTabPage({
super.key,
required this.model,
this.index = 0,
this.cancelCallBack,
});
@override
Widget build(BuildContext context) {
return GetBuilder<CommunityTabLogic>(
tag: 'community_tab_${model.id ?? index}',
init: CommunityTabLogic(model, index: index),
builder: (logic) {
return pullYsRefresh(
onInit: (ctr) => logic.refreshController = ctr,
onRefresh: (_) => logic.fetchPagedata(),
onLoading: (_) => logic.fetchPagedata(isRefresh: false),
child: CustomScrollView(
controller: logic.sctrl,
slivers: [
if (model.pureVersion != true)
SliverToBoxAdapter(
child: AdsGridViewWidget(
7,
accordingAdsType: true,
padding: EdgeInsets.fromLTRB(16.w, 0, 16.w, 10.h),
),
),
SliverToBoxAdapter(
child: _buildSpecialView(logic),
),
SliverPersistentHeader(
pinned: true,
floating: true,
delegate: MySliverDelegate(
maxHeight: 34,
minHeight: 34,
child: CommunityMiddleHeader(
sortOnTap: (sort) {
logic.sort = sort;
logic.fetchPagedata(needLoading: true);
},
),
),
),
_buildTableView(logic),
],
),
);
},
);
}
Widget _buildSpecialView(CommunityTabLogic logic) {
if (logic.specials.isEmpty) return SizedBox.shrink();
return GridView.builder(
physics: NeverScrollableScrollPhysics(),
padding: EdgeInsets.only(left: 16.w, right: 16.w, bottom: 18.h),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 7,
crossAxisSpacing: 7,
childAspectRatio: 120 / 57,
),
itemCount: logic.specials.length,
shrinkWrap: true,
itemBuilder: (BuildContext context, int index) {
return InkWell(
enableFeedback: false,
onTap: () => logic.changeSpecialsIndex(index),
child: _buildSpecialTagCell(logic, index),
);
},
);
}
Widget _buildSpecialTagCell(CommunityTabLogic logic, int index) {
TagsBean model = logic.specials[index];
return Stack(
fit: StackFit.expand,
children: [
Container(
decoration: logic.tagIndex == index
? BoxDecoration(
color: Color(0xff303030),
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.all(
color: AppColors.actionRed,
width: 1,
))
: BoxDecoration(
color: Color(0xff303030),
),
padding: EdgeInsets.only(bottom: 0),
child: Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: model.coverImg ?? '',
borderRadius: 6,
width: double.infinity,
height: double.infinity,
),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Colors.black.withValues(alpha: .6)),
),
if (model.hotMark?.isNotEmpty == true)
Positioned(
top: 0,
left: 0,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color.fromRGBO(252, 118, 118, 1),
Color.fromRGBO(212, 39, 39, 1),
],
),
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(6),
topLeft: Radius.circular(6),
),
),
child: Text(
"${model.hotMark}",
style: TextStyle(color: Colors.white, fontSize: 11),
),
),
),
],
),
),
Stack(
fit: StackFit.expand,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${model.tagName}",
style: TextStyle(
color: logic.tagIndex == index
? AppColors.actionRed
: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
2.sizeBoxH,
Text(
"${model.videoCount?.countStr}个帖子",
style: TextStyle(
color: logic.tagIndex == index
? AppColors.actionRed
: Colors.white,
fontSize: 12),
),
],
),
],
),
],
);
}
Widget _buildTableView(CommunityTabLogic logic) {
if (logic.isLoading) {
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: LoadingCenterWidget(),
),
);
}
if (logic.dataSource.isEmpty) {
return SliverToBoxAdapter(
child: SizedBox(
height: 300,
child: CErrorWidget(
retryOnTap: () => logic.fetchPagedata(),
),
),
);
}
return SliverList.builder(
itemCount: logic.dataSource.length,
itemBuilder: (BuildContext context, int index) {
VideoModel videoModel = logic.dataSource[index];
return CommunityPostWidget(
videoModel: videoModel,
videoModels: logic.dataSource,
showLine: true,
);
},
);
}
}
@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/const.dart';
import '../../main_page/main_logic.dart';
import '../../main_page/provider/bottom_bar_provider.dart';
class CommunityMainLogic extends GetxController
with GetTickerProviderStateMixin {
final int index;
CommunityMainLogic({this.index = 0});
List<MainTabModel> tabs = [
MainTabModel(
title: '社区',
imageNor: 'community_main_sel.png'.communityPath,
imageSel: 'community_main_sel.png'.communityPath,
),
MainTabModel(
title: '色图',
imageNor: 'community_pic_sel.png'.communityPath,
imageSel: 'community_pic_sel.png'.communityPath,
),
MainTabModel(
title: '小说',
imageNor: 'community_noval_sel.png'.communityPath,
imageSel: 'community_noval_sel.png'.communityPath,
),
];
// tab 顺序对应的媒体类型:社区/色图/小说
static const List<MediaStyle> _tabTypes = [
MediaStyle.Community,
MediaStyle.Pic,
MediaStyle.Novel
];
late TabController tabCtr;
@override
void onInit() {
tabCtr =
TabController(length: tabs.length, vsync: this, initialIndex: index);
tabCtr.addListener(() {
if (tabCtr.indexIsChanging) return;
CommunityBottomProvider().communityType = _tabTypes[tabCtr.index];
});
super.onInit();
}
// 跳转相关模块的亚模块
void jumpSubModule(int index, {int tabIndex = 0}) {
tabCtr.index = index;
}
}
@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import '../../../config/config.dart';
import '../../cartoon/cartoon_sub_module_page.dart';
import '../../home/search_page/search_main_page.dart';
import '../../mine/welfare/sign_daily_page.dart';
import '../community/community_module_page.dart';
import 'community_main_logic.dart';
import 'community_section_tab.dart';
class CommunityMainPage extends StatefulWidget {
final int index;
final int subTabIndex;
CommunityMainPage({super.key, this.index = 0, this.subTabIndex = 0});
@override
State<StatefulWidget> createState() {
return CommunityMainPageState();
}
}
class CommunityMainPageState extends State<CommunityMainPage>
with SingleTickerProviderStateMixin {
@override
void initState() {
super.initState();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
//提前加载图片资源
precacheImage(AssetImage('community_main_sel.png'.communityPath), context);
precacheImage(AssetImage('community_pic_sel.png'.communityPath), context);
precacheImage(
AssetImage('community_chatgroup_sel.png'.communityPath), context);
precacheImage(AssetImage('community_noval_sel.png'.communityPath), context);
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Scaffold(
backgroundColor: Colors.transparent,
body: GetBuilder(
init: CommunityMainLogic(index: widget.index),
builder: (_) => Column(
children: [
screen.paddingTop.sizeBoxH,
Row(
children: [
Expanded(child: _buildTabbar(_)),
if (Config.signIcon != null &&
Config.signIcon!.isNotEmpty) ...[
InkWell(
enableFeedback: false,
onTap: () => Get.to(SignDailyPage()),
child: Image.asset(
"community_sign.webp".communityPath,
width: 20,
),
),
],
12.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => Get.to(SearchMainPage()),
child: Image.asset(
'search_icon.png'.commonImgPath,
width: 24,
color: Color(0xff989898),
),
),
16.sizeBoxW,
],
),
Expanded(
child: TabBarView(
controller: _.tabCtr,
children: [
//社区
CommunityModulePage(
defaultIndex:
widget.index == 0 ? widget.subTabIndex : 0,
).keepAlive,
//图集
CartoonSubModulePage(
type: MediaStyle.Pic,
tabIndex: widget.index == 1 ? widget.subTabIndex : 0,
).keepAlive,
// CartoonSubModulePage(
// type: MediaStyle.Game,
// tabIndex: widget.index == 2 ? widget.subTabIndex : 0,
// ).keepAlive,
//小说
CartoonSubModulePage(
type: MediaStyle.Novel,
tabIndex: widget.index == 3 ? widget.subTabIndex : 0,
).keepAlive,
],
),
)
],
),
),
),
],
);
}
_buildTabbar(CommunityMainLogic _) {
return CommunitySectionTabMenu(
_.tabs,
curIndex: widget.index,
tabCtr: _.tabCtr,
);
}
@override
void dispose() {
// Get.delete<CommunityMainPageLogic>();
super.dispose();
}
}
@@ -0,0 +1,85 @@
//发现模块tabbar
import 'package:flutter/material.dart';
import '../../../hj_utils/widget_util.dart';
import '../../main_page/main_logic.dart';
class CommunitySectionTabMenu extends StatefulWidget {
final int curIndex;
final List<MainTabModel> tabs;
final TabController? tabCtr;
final double? fontSize;
const CommunitySectionTabMenu(
this.tabs, {
super.key,
this.curIndex = 0,
this.tabCtr,
this.fontSize = 16,
});
@override
State<StatefulWidget> createState() {
return _CommunitySectionTabMenuState();
}
}
class _CommunitySectionTabMenuState extends State<CommunitySectionTabMenu> {
late int curIndex = widget.curIndex;
@override
void initState() {
super.initState();
widget.tabCtr?.addListener(_listener);
}
void _listener() {
setState(() => curIndex = widget.tabCtr?.index ?? 0);
}
@override
void dispose() {
widget.tabCtr?.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 36,
child: TabBar(
tabAlignment: TabAlignment.start,
padding: const EdgeInsets.only(left: 6),
controller: widget.tabCtr,
indicatorWeight: 0,
isScrollable: true,
labelPadding: const EdgeInsets.symmetric(horizontal: 8),
labelStyle: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w600),
unselectedLabelStyle: TextStyle(fontSize: widget.fontSize, fontWeight: FontWeight.w400),
indicator: const BoxDecoration(),
tabs: List.generate(widget.tabs.length, _tabItem),
),
);
}
Widget _tabItem(int index) {
final sel = curIndex == index;
// 固定 49 宽槽位,选中(图标)/未选中(文字)就地交叉淡入淡出,宽度恒定不跳动
return AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
child: SizedBox(
key: ValueKey(sel),
width: 49,
child: sel
? Center(child: Image.asset(widget.tabs[index].imageNor, height: 31))
: Align(
alignment: Alignment(0, -0.5),
child: Text(
widget.tabs[index].title,
style: textStyle(14, Colors.white.withValues(alpha: 0.55), FontWeight.w400),
),
),
),
);
}
}
@@ -0,0 +1,42 @@
import 'dart:async';
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/tag_service.dart';
class CommunityTagLogic extends GetxController
with GetTickerProviderStateMixin {
final scrollCtr = ScrollController();
double? offset;
bool isPinned = false;
TagsBean model;
StreamController<bool> showAppCtr = StreamController.broadcast();
late final TabController mainTabCtr =
TabController(length: sorts.length, vsync: this);
RxBool isLoadingFollow = false.obs;
final sorts = [2, 1, 5]; //排序类型 1:最新上架 2:最多收藏 3:本月最热 4:最多观看
CommunityTagLogic(this.model);
@override
onReady() async {
super.onReady();
final newsType = model.newsType;
final res = await TagService.fetchInfo(model.id ?? '');
if (res != null) {
model = res;
model.newsType = newsType;
}
update();
}
@override
void onClose() {
showAppCtr.close();
mainTabCtr.dispose();
super.onClose();
}
}
@@ -0,0 +1,160 @@
import 'package:extended_nested_scroll_view/extended_nested_scroll_view.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_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import 'package:hgdj/tools_base/widget/keep_alive_widget.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:provider/provider.dart';
import '../../../tools_base/widget/follow_button.dart';
import '../../home/widget/collect_status_wrapper.dart';
import '../widget/community_sort_header.dart';
import 'community_tag_logic.dart';
import 'subviews/tag_detail_sub_controller.dart';
import 'subviews/tag_detail_subview.dart';
class UserCenterSortTypeEvent {
int sortIndex;
UserCenterSortTypeEvent(this.sortIndex);
}
class CommunityTagDetailPage extends StatefulWidget {
final TagsBean model;
const CommunityTagDetailPage({super.key, required this.model});
@override
State<CommunityTagDetailPage> createState() => _CommunityTagDetailPageState();
}
class _CommunityTagDetailPageState extends State<CommunityTagDetailPage>
with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<CommunityTagLogic>(
tag: uniqueTag,
init: CommunityTagLogic(widget.model),
builder: (controller) => Scaffold(
extendBodyBehindAppBar: true,
body: Stack(
children: [
ExtendedNestedScrollView(
controller: controller.scrollCtr,
pinnedHeaderSliverHeightBuilder: () =>
AppBar().preferredSize.height + screen.paddingTop,
headerSliverBuilder: (_, istop) {
return [
SliverAppBar(
pinned: true,
floating: false,
expandedHeight: 64,
titleSpacing: 18.w,
automaticallyImplyLeading: false,
title: _buildAppBarTitle(controller),
flexibleSpace: FlexibleSpaceBar(
background: SizedBox(),
),
)
];
},
body: Column(
children: [
12.sizeBoxH,
CommunitySortHeader(
controller: controller.mainTabCtr,
sortOnTap: (index) {},
),
12.sizeBoxH,
Expanded(
child: TabBarView(
controller: controller.mainTabCtr,
children: controller.sorts
.map(
(e) => TagDetailSubView<TagCOVERController>(
tag: controller.model, postSort: e)
.keepAlive,
)
.toList(),
),
)
],
),
)
],
),
),
);
}
Widget _buildAppBarTitle(CommunityTagLogic controller) {
return ChangeNotifierProvider.value(
value: controller.scrollCtr,
child: Consumer<ScrollController>(
builder: (_, ctr, __) {
final isTop = (180 - ctr.offset - kToolbarHeight) == 0;
return Row(
children: [
GestureDetector(
onTap: () => Get.back(),
child: Image.asset(
isTop
? 'common_back.png'.commonImgPath
: 'back_circle.png'.commonImgPath,
width: 24,
height: 24,
),
),
if (isTop) ...[
4.sizeBoxW,
NetworkImageLoader(
imageUrl: controller.model.coverImg ?? '',
width: 30,
height: 30,
borderRadius: 15,
),
6.sizeBoxW,
Text(
controller.model.name ?? '',
style: const TextStyle(
fontSize: 14,
color: Color(0xffffffff),
fontWeight: FontWeight.w500,
),
),
Spacer(),
CollectStatusWrapper(
tagModel: controller.model,
builder: () => FollowButton(
mediaId: controller.model.id,
isFollow: controller.model.hasCollected ?? false,
followType: FollowEnum.tag,
successsAction: (success) =>
controller.model.hasCollected = success,
),
),
] else ...[
Expanded(
child: Center(
child: Text(
controller.model.name ?? '',
style: TextStyle(
fontSize: 18,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
(24 + 18.w).sizeBoxH,
],
],
);
},
),
);
}
}
@@ -0,0 +1,130 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/tag_service.dart';
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
import 'package:hgdj/tools_base/event_bus/events.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../home/widget/collect_status_wrapper.dart';
abstract class TagDetailSubController extends GetxController {
List<VideoModel> dataSource = [];
bool isGridStyle = true;
double childRatio = 1;
bool isLoading = true;
String? loadType;
String? newsType;
String? sort;
int? postSort;
int page = 1;
TagsBean tag;
TagDetailSubController(this.tag, {this.postSort});
RefreshController? controller;
StreamSubscription? _collectStatusSub;
void _onCollectStatusChanged(CollectStatusModel event) {
if (event.isLiked == null && event.likeCountDelta == null) return;
var changed = false;
for (final item in dataSource) {
if (item.id == event.id) {
applyVideoModelCollectStatus(item, event);
changed = true;
}
}
if (changed) update();
}
@override
void onInit() {
super.onInit();
_collectStatusSub =
eventBus.on<CollectStatusModel>(_onCollectStatusChanged);
}
@override
void onClose() {
_collectStatusSub?.cancel();
super.onClose();
}
@override
void onReady() {
super.onReady();
// isLoading = false;
fetchPageData(needLoading: true);
}
fetchPageData({bool isRefresh = true, bool needLoading = false}) async {
if (isRefresh) {
page = 1;
}
isLoading = needLoading;
update();
final res = await TagService.fetchList(
tag.id ?? '',
newsType: newsType,
sortType: postSort,
page: page,
size: 10,
);
if (isRefresh) {
dataSource.clear();
controller?.refreshCompleted();
}
dataSource.addAll(res?.videos ?? []);
page += 1;
isLoading = false;
update();
res?.hasNext ?? false
? controller?.loadComplete()
: controller?.loadNoData();
}
}
class TagVideoController extends TagDetailSubController {
TagVideoController(super.tag);
@override
String get loadType => 'MOVIE';
String get newsType => tag.newsType ?? "MOVIE";
@override
double get childRatio => 168 / 119;
}
class TagSPController extends TagDetailSubController {
TagSPController(super.tag);
@override
String get loadType => 'SP';
String get newsType => tag.newsType ?? "SP";
@override
double get childRatio => 168 / 248;
}
class TagCOVERController extends TagDetailSubController {
TagCOVERController(super.tag, {super.postSort});
@override
String? get loadType => null;
String get newsType => tag.newsType ?? "";
@override
bool get isGridStyle => false;
}
T instanceController<T>(TagsBean tag, {int? postSort}) {
// Type 对象比较,避免 release 混淆下类名字符串匹配失败导致 throw→灰屏
if (T == TagCOVERController)
return TagCOVERController(tag, postSort: postSort) as T;
if (T == TagSPController) return TagSPController(tag) as T;
if (T == TagVideoController) return TagVideoController(tag) as T;
throw '$T 没有对应类型';
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.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 '../../widget/community_post_widget.dart';
import 'tag_detail_sub_controller.dart';
class TagDetailSubView<T extends TagDetailSubController>
extends StatelessWidget {
final TagsBean tag;
final int? postSort;
const TagDetailSubView({super.key, required this.tag, this.postSort});
@override
Widget build(BuildContext context) {
return GetBuilder<T>(
init: instanceController<T>(tag, postSort: postSort),
global: false,
builder: (controller) {
if (controller.isLoading) return LoadingCenterWidget();
return pullYsRefresh(
onLoading: (refreshController) =>
controller.fetchPageData(isRefresh: false),
onRefresh: (refreshController) => controller.fetchPageData(),
child: () {
if (controller.dataSource.isEmpty) return CErrorWidget();
if (controller.isGridStyle)
return GridView.builder(
padding: EdgeInsets.symmetric(horizontal: 16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: controller.childRatio,
crossAxisSpacing: 7,
mainAxisSpacing: 12,
),
itemCount: controller.dataSource.length,
itemBuilder: (BuildContext context, int index) {
final model = controller.dataSource[index];
return GestureDetector(
onTap: () => pushToVideoPage(videoModel: model),
child: VideoSimpleCell(videoModel: model),
);
},
);
return ListView.separated(
padding: EdgeInsets.only(top: 0),
separatorBuilder: (_, __) => 14.sizeBoxH,
itemCount: controller.dataSource.length,
itemBuilder: (BuildContext context, int index) {
final model = controller.dataSource[index];
return CommunityPostWidget(
videoModel: model,
videoModels: controller.dataSource,
showTags: false,
);
},
);
}(),
onInit: (ctr) => controller.controller = ctr,
);
},
);
}
}
@@ -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/pay/pay_manager.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
import 'package:provider/provider.dart';
import '../model/im_group_list_model.dart';
class GroupBuyBottomSheet extends StatefulWidget {
final IMGroupItemModel groupModel;
const GroupBuyBottomSheet({super.key, required this.groupModel});
@override
State<GroupBuyBottomSheet> createState() => _GroupBuyBottomSheetState();
}
class _GroupBuyBottomSheetState extends State<GroupBuyBottomSheet> {
@override
void initState() {
super.initState();
globalStore.updateUserInfo();
globalStore.refreshWallet();
}
@override
Widget build(BuildContext context) {
return Consumer<GlobalStore>(
builder: (_, store, __) {
final enough =
(store.wallet?.amount ?? 0) >= (widget.groupModel.price ?? 0);
return _buildNormal(enough, store);
},
);
}
Widget _buildNormal(bool enough, GlobalStore store) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 18),
decoration: BoxDecoration(
color: AppColors.primaryColor,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetHandleBar(),
18.sizeBoxH,
Text(
'购买',
style: TextStyle(
color: Color(0xE5ffffff),
fontSize: 20,
fontWeight: FontWeight.w500,
),
),
36.sizeBoxH,
Row(
children: [
Text(
'解锁群聊',
style: TextStyle(
color: Color(0xE5ffffff),
fontSize: 14,
),
),
Spacer(),
GestureDetector(
onTap: () => pushToWalletPage(tabPosition: 1),
child: Row(
children: [
Text(
'${widget.groupModel.price ?? 0} 金币',
style:
TextStyle(color: AppColors.actionRed, fontSize: 14),
),
],
),
)
],
),
18.sizeBoxH,
0.5.line,
18.sizeBoxH,
Row(
children: [
Text(
'我的金币',
style: TextStyle(
color: Color(0xE5ffffff),
fontSize: 14,
),
),
Spacer(),
Row(
children: [
Text(
'${GlobalStore().wallet?.amount?.toDouble().fixed(2) ?? 0}',
style: TextStyle(
color: Color(0xE5ffffff),
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
12.sizeBoxW,
InkWell(
enableFeedback: false,
onTap: () => pushToWalletPage(tabPosition: 1),
child: Text(
'充值',
style: TextStyle(
color: Color(0xffA4634D),
fontSize: 14,
fontWeight: FontWeight.w400,
decoration: TextDecoration.underline,
decorationColor: Color(0xffA4634D),
),
),
),
],
),
],
),
18.sizeBoxH,
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => _doPayAction(enough),
child: Container(
width: double.infinity,
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed,
),
alignment: Alignment.center,
child: Text(
enough
? '${widget.groupModel.price ?? 0} 金币/立即支付'
: '余额不足,前往充值',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500),
),
),
),
],
),
);
}
void _doPayAction(bool enough) async {
if (!enough) {
await pushToWalletPage(tabPosition: 1);
return;
}
await PayManager().buy(
widget.groupModel.id,
ProductType.group,
source: 'group_buy',
onSuccess: (data) {
globalStore.refreshWallet();
Get.back(result: true);
},
);
}
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../../hj_utils/api_service/group_service.dart';
import '../../../../tools_base/file_upload/file_upload_tool.dart';
import '../../../../tools_base/file_upload/upload_result_model.dart';
import '../../../comment/comment_input_view.dart';
import '../model/im_group_list_model.dart';
import '../model/im_message_resp.dart';
class GroupChatDetailLogic extends GetxController {
final IMGroupItemModel model;
GroupChatDetailLogic(this.model);
int pageNum = 1;
List<IMMessageModel>? msgList;
RefreshController? refreshController;
TextEditingController textEditController = TextEditingController();
Rx<String?> selectedImagePath = Rx<String?>(null);
RxBool isSendingMsg = false.obs;
@override
void onReady() {
refreshData();
super.onReady();
}
void refreshData() {
_loadData();
}
void loadMoreData() {
_loadData(page: pageNum + 1);
}
void _loadData({int page = 1, int size = 20}) async {
try {
final retResp = await GroupService.getMessages(model.groupId, page, size);
pageNum = page;
msgList ??= [];
if (pageNum == 1) {
msgList?.clear();
}
msgList?.addAll(retResp.list ?? []);
retResp.hasNext == true
? refreshController?.loadComplete()
: refreshController?.loadNoData();
} catch (e) {
debugLog(e);
refreshController?.loadComplete();
}
refreshController?.refreshCompleted();
update();
}
Future readySendMsg({bool? isSend}) async {
if (isSend == true &&
(textEditController.text.isNotEmpty ||
selectedImagePath.value?.isNotEmpty == true)) {
// 点击发送按钮有内容,直接发送内容, 否则弹出输入框
sendMsgEvent();
} else {
final result = await Get.bottomSheet(
CommentInputView(
hint: "请输入消息",
textCtr: textEditController,
imgPath: selectedImagePath.value,
onImgChange: (value) => selectedImagePath.value = value,
),
backgroundColor: Colors.transparent,
barrierColor: Colors.transparent,
enableDrag: false,
settings: RouteSettings(name: CommentInputView.routeName),
useRootNavigator: true,
);
if (result == true) {
update();
}
sendMsgEvent();
}
}
Future sendMsgEvent() async {
if (textEditController.text.isEmpty &&
selectedImagePath.value?.isNotEmpty != true) {
showToast("请输入消息内容");
return;
}
if (isSendingMsg.value) return;
isSendingMsg.value = true;
String? sendImagePath;
if (selectedImagePath.value?.isNotEmpty == true) {
try {
ImageUploadResultModel? imgResult =
await FileUploadTool().uploadImage(selectedImagePath.value!);
sendImagePath = imgResult?.coverImg ?? "";
if (sendImagePath.isEmpty) {
isSendingMsg.value = false;
showToast("图片上传失败!");
return;
}
} catch (e) {
debugLog(e);
isSendingMsg.value = false;
showToast("图片上传失败!");
return;
}
}
try {
var ret = await GroupService.sendMessage(
model.groupId, textEditController.text, sendImagePath);
if (ret.isSuccess) {
textEditController.text = "";
selectedImagePath.value = null;
showToast("消息已发送");
if (ret.data is Map && (ret.data['message'] is Map)) {
IMMessageModel sendModel =
IMMessageModel.fromJson(ret.data['message']);
sendModel.portrait = globalStore.meInfo?.portrait;
sendModel.name = globalStore.meInfo?.name;
msgList?.insert(0, sendModel);
update();
}
}
} catch (e) {
showToast("消息发送失败!");
debugLog(e);
}
isSendingMsg.value = false;
}
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/comment/widget/comment_foot_view.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import '../model/im_group_list_model.dart';
import 'soul_group_detail_logic.dart';
import 'widget/chat_item_cell.dart';
class GroupChatDetailPage extends StatelessWidget {
final IMGroupItemModel model;
const GroupChatDetailPage(this.model, {super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<GroupChatDetailLogic>(
global: false,
init: GroupChatDetailLogic(model),
builder: (logic) {
return Scaffold(
appBar: AppBar(
title: Text(model.name ?? ""),
),
body: Column(
children: [
Expanded(
child: pullYsRefresh(
onInit: (ctr) => logic.refreshController = ctr,
onRefresh: (ctr) => logic.refreshData(),
onLoading: (ctr) => logic.loadMoreData(),
child: ListView.builder(
itemCount: logic.msgList?.length ?? 0,
reverse: true,
padding: EdgeInsets.only(bottom: 12, top: 12),
itemBuilder: (context, index) {
return ChatItemCell(model: logic.msgList![index]);
},
),
),
),
Obx(
() => CommentFootView(
imgPath: logic.selectedImagePath.value,
isSending: logic.isSendingMsg.value,
onImgChange: (value) => logic.selectedImagePath.value = value,
onComment: (isSend) => logic.readySendMsg(isSend: isSend),
textCtr: logic.textEditController,
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,276 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../../../tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
import '../../model/im_message_resp.dart';
class ChatItemCell extends StatefulWidget {
final IMMessageModel? model;
ChatItemCell({super.key, this.model});
@override
State<StatefulWidget> createState() {
return _ChatItemCellState();
}
}
class _ChatItemCellState extends State<ChatItemCell> {
bool get isImg => widget.model?.image?.isNotEmpty == true;
bool get isMe => globalStore.isMe(widget.model?.uid);
double imageWidth = 173;
@override
void initState() {
super.initState();
}
void _showImageScan(String imageUrl) {
if (imageUrl.isNotEmpty) {
ImageBrowserPage.open([widget.model?.image ?? '']);
}
}
@override
Widget build(BuildContext context) {
if (isMe) {
return _buildRightStyle();
} else {
return _buildLeftStyle();
}
}
Widget _buildLeftStyle() {
return Container(
padding: EdgeInsets.fromLTRB(0, 12, 0, 6),
child: Column(
children: [
_buildTime(),
Container(
padding: EdgeInsets.fromLTRB(16, 0, 50, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
pushToPersonCenter(widget.model?.uid);
},
child: SizedBox(
width: 40,
height: 40,
child: NetworkImageLoader(
imageUrl: widget.model?.portrait ?? "",
width: 40,
height: 40,
borderRadius: 20,
),
),
),
SizedBox(width: 12),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.model?.name ?? "",
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 14,
),
),
SizedBox(height: 10),
if (widget.model?.content?.isNotEmpty == true)
Container(
padding: EdgeInsets.symmetric(
horizontal: 20, vertical: 10),
margin: EdgeInsets.only(top: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
topRight: Radius.circular(12),
topLeft: Radius.circular(2)),
),
child: Text(
widget.model?.content ?? "",
style: TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
if (isImg)
InkWell(
enableFeedback: false,
onTap: () {
_showImageScan(widget.model?.image ?? "");
},
child: Container(
margin: EdgeInsets.only(top: 5),
alignment: Alignment.centerLeft,
constraints: BoxConstraints(maxWidth: 173),
child: _buildImageWidget(isLeft: true),
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildRightStyle() {
return Container(
padding: EdgeInsets.fromLTRB(0, 12, 0, 6),
child: Column(
children: [
_buildTime(),
Container(
padding: EdgeInsets.fromLTRB(50, 0, 16, 0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
widget.model?.name ?? "",
style: TextStyle(
color: Colors.white,
fontSize: 14,
),
),
SizedBox(height: 10),
if (widget.model?.content?.isNotEmpty == true)
Container(
padding: EdgeInsets.symmetric(
horizontal: 20, vertical: 10),
margin: EdgeInsets.only(top: 5),
decoration: BoxDecoration(
color: Color(0xff1DC194),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
topLeft: Radius.circular(12),
topRight: Radius.circular(4)),
),
child: Text(
widget.model?.content ?? "",
style: TextStyle(
color: Color(0xff141414),
fontSize: 14,
),
),
),
if (isImg)
InkWell(
enableFeedback: false,
onTap: () {
_showImageScan(widget.model?.image ?? "");
},
child: Container(
margin: EdgeInsets.only(top: 5),
alignment: Alignment.centerRight,
constraints: BoxConstraints(maxWidth: 173),
child: _buildImageWidget(isLeft: false),
),
),
],
),
),
SizedBox(width: 12),
GestureDetector(
onTap: () {},
child: SizedBox(
width: 40,
height: 40,
child: NetworkImageLoader(
imageUrl: widget.model?.portrait,
width: 40,
height: 40,
borderRadius: 20,
),
),
),
],
),
),
],
),
);
}
Widget _buildImageWidget({bool isLeft = false}) {
return Stack(
children: [
NetworkImageLoader(
imageUrl: widget.model?.image ?? "",
width: imageWidth,
imgBorderRadius: BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
topRight: Radius.circular(isLeft ? 12 : 0),
topLeft: Radius.circular(isLeft ? 0 : 12),
),
),
if (isLeft)
Positioned(
bottom: 12,
left: 0,
right: 0,
child: Container(
alignment: Alignment.bottomCenter,
child: Container(
width: 72,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: AppColors.actionRed,
),
child: Text(
'查看原图',
style: TextStyle(fontSize: 12, color: Colors.white),
),
),
),
),
],
);
}
Widget _buildTime() {
// if (widget.model?.isShowTime == true) {
// return Container(
// margin: EdgeInsets.fromLTRB(0, 12, 0, 12),
// padding: EdgeInsets.symmetric(horizontal: 20, vertical: 2),
// // decoration: BoxDecoration(
// // borderRadius: BorderRadius.circular(30),
// // color: Color(0xfff7f7f7).withValues(alpha: 0.42),
// // ),
// child: Text(
// widget.model?.createAtDesc ?? "",
// style: TextStyle(
// color: Color(0xff666666),
// fontSize: 12,
// ),
// ),
// );
// }
return SizedBox();
}
}
@@ -0,0 +1,46 @@
import 'package:get/get.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import '../../../hj_utils/api_service/group_service.dart';
import 'model/im_group_list_model.dart';
class GroupChatLogic extends GetxController {
int pageNum = 1;
List<IMGroupItemModel>? groupList;
RefreshController? refreshController;
@override
void onReady() {
refreshData();
super.onReady();
}
void refreshData() {
_loadData();
}
void loadMoreData() {
_loadData(page: pageNum + 1);
}
void _loadData({int page = 1}) async {
try {
final retResp = await GroupService.getList(page);
pageNum = page;
groupList ??= [];
if (pageNum == 1) {
groupList?.clear();
}
groupList?.addAll(retResp?.list ?? []);
retResp?.hasNext == true
? refreshController?.loadComplete()
: refreshController?.loadNoData();
} catch (e) {
debugLog(e);
refreshController?.loadComplete();
}
refreshController?.refreshCompleted();
update();
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_page/main_page/provider/bottom_bar_provider.dart';
import 'package:hgdj/hj_utils/const.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'group_chat_logic.dart';
import 'widget/chat_group_cell.dart';
class GroupChatPage extends StatelessWidget {
const GroupChatPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<GroupChatLogic>(
global: false,
init: GroupChatLogic(),
builder: (logic) {
return pullYsRefresh(
onInit: (ctr) => logic.refreshController = ctr,
onRefresh: (ctr) => logic.refreshData(),
onLoading: (ctr) => logic.loadMoreData(),
child: CustomScrollView(
controller:
CommunityBottomProvider().scrollCtr(MediaStyle.GroupChat, 0),
slivers: [
SliverToBoxAdapter(
child: AdsGridViewWidget(
6,
accordingAdsType: true,
padding: EdgeInsets.fromLTRB(16, 12, 16, 18),
),
),
if (logic.groupList == null)
SliverToBoxAdapter(child: LoadingCenterWidget(height: 400))
else if (logic.groupList!.isEmpty)
SliverToBoxAdapter(
child: SizedBox(
height: 400,
child: CErrorWidget(
retryOnTap: () => logic.refreshData(),
),
),
)
else
SliverList.builder(
itemBuilder: (ctx, index) =>
ChatGroupCell(logic.groupList![index]),
itemCount: logic.groupList!.length,
),
],
),
);
},
);
}
}
@@ -0,0 +1,87 @@
class IMGroupListModel {
bool? hasNext;
List<IMGroupItemModel>? list;
int? total;
IMGroupListModel({this.hasNext, this.list, this.total});
IMGroupListModel.fromJson(Map<String, dynamic> json) {
hasNext = json['hasNext'];
if (json['list'] != null) {
list = <IMGroupItemModel>[];
json['list'].forEach((v) {
list!.add(new IMGroupItemModel.fromJson(v));
});
}
total = json['total'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['hasNext'] = this.hasNext;
if (this.list != null) {
data['list'] = this.list!.map((v) => v.toJson()).toList();
}
data['total'] = this.total;
return data;
}
}
class IMGroupItemModel {
String? cover;
String? createdAt;
///成员数量(假的)
int? fakeMemberNum;
int? groupId;
String? id;
///成员数量(真实)
int? memberNum;
String? name;
///加入群聊价格 0-免费
int? price;
String? summary;
String? updatedAt;
IMGroupItemModel(
{this.cover,
this.createdAt,
this.fakeMemberNum,
this.groupId,
this.id,
this.memberNum,
this.name,
this.price,
this.summary,
this.updatedAt});
IMGroupItemModel.fromJson(Map<String, dynamic> json) {
cover = json['cover'];
createdAt = json['createdAt'];
fakeMemberNum = json['fakeMemberNum'];
groupId = json['groupId'];
id = json['id'];
memberNum = json['memberNum'];
name = json['name'];
price = json['price'];
summary = json['summary'];
updatedAt = json['updatedAt'];
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = new Map<String, dynamic>();
data['cover'] = this.cover;
data['createdAt'] = this.createdAt;
data['fakeMemberNum'] = this.fakeMemberNum;
data['groupId'] = this.groupId;
data['id'] = this.id;
data['memberNum'] = this.memberNum;
data['name'] = this.name;
data['price'] = this.price;
data['summary'] = this.summary;
data['updatedAt'] = this.updatedAt;
return data;
}
}
@@ -0,0 +1,46 @@
class IMMessageResp {
bool? hasNext;
List<IMMessageModel>? list;
int? total;
IMMessageResp({this.hasNext, this.list, this.total});
IMMessageResp.fromJson(Map<String, dynamic> json) {
hasNext = json['hasNext'];
if (json['list'] is List) {
list = <IMMessageModel>[];
json['list'].forEach((v) {
list!.add(IMMessageModel.fromJson(v));
});
}
total = json['total'];
}
}
class IMMessageModel {
String? id;
int? groupId;
int? uid;
String? name;
String? gender;
String? portrait;
String? content;
String? image;
String? createdAt;
IMMessageModel.fromJson(Map<String, dynamic> json) {
id = json['id'];
groupId = json['groupId'];
uid = json['uid'];
name = json['name'];
// gender = json['gender'];
portrait = json['portrait'];
content = json['content'];
image = json['image'];
createdAt = json['createdAt'];
}
}
@@ -0,0 +1,147 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:hgdj/hj_utils/screen.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 '../../../../alert/mine/vip_level_dialog.dart';
import '../../../../assets_tool/app_colors.dart';
import '../../../../hj_utils/api_service/group_service.dart';
import '../../../../tools_base/widget/header_widget.dart';
import '../alert/group_buy_bottom_sheet.dart';
import '../detail/soul_group_detail_page.dart';
import '../model/im_group_list_model.dart';
import 'package:hgdj/extension/extensions.dart';
class ChatGroupCell extends StatefulWidget {
final IMGroupItemModel model;
ChatGroupCell(this.model, {super.key});
@override
State<StatefulWidget> createState() {
return _ChatGroupCellState();
}
}
class _ChatGroupCellState extends State<ChatGroupCell> {
IMGroupItemModel get model => widget.model;
void _addGroupEvent() async {
if (!globalStore.isVIPTopLevel) {
showVipLevelDialog('本内容需要开通至尊VIP会员\n\n开通会员 即可畅享高级群聊特权');
return;
}
try {
LoadingAlertWidget.show();
final retResp = await GroupService.getHasJoin(model.groupId);
LoadingAlertWidget.cancel();
if (retResp.isSuccess) {
int status = retResp.data['status']; // 0-已被禁言 1-可发言 2-未加入
if (status == 0) {
showToast("您已被禁言");
} else if (status == 1) {
Get.to(GroupChatDetailPage(model));
} else if (status == 2) {
var ret = await Get.bottomSheet(
GroupBuyBottomSheet(groupModel: widget.model));
if (ret == true) {
Get.to(GroupChatDetailPage(model));
}
} else {
showToast("群未知状态");
}
}
} catch (e) {
LoadingAlertWidget.cancel();
showToast("网络数据异常");
debugLog(e);
}
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(left: 16, right: 16, bottom: 12),
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .05),
borderRadius: BorderRadius.circular(6),
),
child: InkWell(
enableFeedback: false,
onTap: _addGroupEvent,
child: Row(
children: [
HeaderWidget(
headPath: model.cover ?? '',
level: 1,
headWidth: 64,
headHeight: 64,
isCircle: false,
radius: 32,
),
12.sizeBoxW,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
model.name ?? '',
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500),
),
2.sizeBoxH,
Text(
model.summary ?? '',
maxLines: 1,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 12,
),
),
6.sizeBoxH,
Row(
children: [
Text(
'ID: ${model.groupId ?? 0} ${model.fakeMemberNum.countStr}人在玩',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.55),
fontSize: 12,
),
),
Spacer(),
GestureDetector(
onTap: _addGroupEvent,
child: Container(
width: 52,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: AppColors.actionRed),
child: Text(
'加入群聊',
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
),
),
],
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,250 @@
import 'dart:ui';
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_model/video_model.dart';
import 'package:hgdj/hj_page/video/video_player_alert.dart';
import 'package:hgdj/hj_utils/pay/pay_manager.dart';
import 'package:hgdj/hj_utils/buy_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/hj_utils/widget_util.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
import '../../../../alert/video/vip_acg_dialog.dart';
import '../../../../assets_tool/app_colors.dart';
import '../../../mine/mine_share/mine_share_page.dart';
/// 帖子详情视频封面:未解锁时盖一层金币 / VIP 蒙层
class PostDetailVideoContent extends StatefulWidget {
final VideoModel model;
const PostDetailVideoContent({super.key, required this.model});
@override
State<PostDetailVideoContent> createState() => _PostDetailVideoContentState();
}
class _PostDetailVideoContentState extends State<PostDetailVideoContent> {
VideoModel get model => widget.model;
@override
Widget build(BuildContext context) {
final isPlayable = model.isVideo() || model.isSeedPost();
if (!isPlayable || model.sourceURL?.isNotEmpty != true)
return const SizedBox.shrink();
final isLocked = needPostCoin(model);
//非免费区的金币帖走金币解锁,其余走 VIP 解锁
final isCoin = !(model.freeArea ?? false) && model.originCoins != 0;
return Column(
children: [
GestureDetector(
onTap: () {
if (needPostCoin(model)) return;
_openPlayer();
},
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 220),
child: AspectRatio(
aspectRatio: 391 / 220,
child: Stack(
alignment: Alignment.center,
children: [
NetworkImageLoader(
imageUrl: model.cover ?? '',
width: double.infinity,
height: double.infinity),
Image.asset('circle_play.webp'.videoPath,
width: 30, height: 30),
//未解锁蒙层
if (isLocked)
Positioned.fill(
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
child: Container(
color: Colors.black.withValues(alpha: .6),
child: isCoin ? _coinMask() : _vipMask(),
),
),
),
),
],
),
),
),
),
if (model.isSeedPost()) 10.sizeBoxH,
],
);
}
//金币解锁蒙层
Widget _coinMask() {
final coins = model.originCoins ?? 0;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
EasyRichText(
'你只需花费 $coins 金币',
defaultStyle: textStyle(16, Colors.white, FontWeight.w400),
patternList: [
EasyRichTextPattern(
targetString: '$coins',
style: textStyle(16, AppColors.actionRed, FontWeight.w400)),
],
),
16.sizeBoxH,
const Text('即可解锁继续查看',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w400)),
16.sizeBoxH,
_btn(
'立即解锁',
width: 84,
colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)],
onTap: () async {
//购买期间可能已被其他入口解锁,点的时候再判一次
if (!needPostCoin(model)) {
_openPlayer();
return;
}
if (!await _pay()) return;
model.vidStatus?.hasPaid = true;
if (mounted) setState(() {});
_openPlayer();
},
),
],
);
}
//VIP 解锁蒙层
Widget _vipMask() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('本视频需开通VIP免费观看',
style: TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w400)),
16.sizeBoxH,
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_btn(
'邀请得3日VIP',
width: 110,
colors: const [Color(0xffFFE8BE), Color(0xffE6B764)],
textColor: const Color(0xff694923),
onTap: () => Get.to(() => MineSharePage()),
),
18.sizeBoxW,
_btn(
'开通VIP免费看',
width: 115,
colors: const [Color(0xffFF6E6E), Color(0xffFF4D4D)],
onTap: () => Get.dialog(VipACGDialog()),
),
],
),
],
);
}
//蒙层上的胶囊按钮
Widget _btn(
String text, {
required double width,
required List<Color> colors,
required VoidCallback onTap,
Color textColor = Colors.white,
}) {
return InkWell(
enableFeedback: false,
onTap: onTap,
child: Container(
width: width,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: LinearGradient(colors: colors),
borderRadius: BorderRadius.circular(15)),
child: Text(text, style: textStyle(12, textColor, FontWeight.w400)),
),
);
}
Future<bool> _pay() async {
bool isOk = false;
await PayManager().buy(
model.id,
ProductType.media,
source: 'post_cover',
onSuccess: (data) {
globalStore.updateUserInfo();
isOk = true;
},
);
return isOk;
}
void _openPlayer() => Get.to(() => VideoPlayerAlert(model.realVideoUrl));
}
/// 帖子详情多图封面:竖向拼接,只有首尾切圆角
class PostDetailCoverContent extends StatelessWidget {
final VideoModel model;
const PostDetailCoverContent({super.key, required this.model});
@override
Widget build(BuildContext context) {
//有视频源时封面已在视频区展示,这里剔除避免重复
if (model.sourceURL?.isNotEmpty == true)
model.seriesCover?.remove(model.cover);
final covers = model.seriesCover ?? [];
return Column(
children: [
if (covers.isNotEmpty)
Wrap(
runSpacing: 2,
children: [
for (final (index, url) in covers.indexed)
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => ImageBrowserPage.open(covers, index: index),
child: ClipRRect(
borderRadius: _radius(index, covers.length),
child: NetworkImageLoader(
imageUrl: url,
width: double.infinity,
fit: BoxFit.fill,
borderRadius: 0),
),
),
],
),
18.sizeBoxH,
],
);
}
BorderRadius _radius(int index, int total) {
const radius = Radius.circular(12);
if (total == 1) return const BorderRadius.all(radius);
if (index == 0)
return const BorderRadius.only(topLeft: radius, topRight: radius);
if (index == total - 1)
return const BorderRadius.only(bottomLeft: radius, bottomRight: radius);
return BorderRadius.zero;
}
}
@@ -0,0 +1,118 @@
import 'dart:math';
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/video_model.dart';
import 'package:hgdj/hj_utils/api_service/common_service.dart';
import 'package:hgdj/hj_utils/screen.dart';
import '../../../../alert/video/share_media_dialog.dart';
import '../../../mine/collec_history_buy/col_his_buy_sub_page.dart';
import 'package:hgdj/extension/extensions.dart';
/// 帖子详情操作栏:浏览量 / 点赞 / 评论 / 分享
class PostDetailOperationView extends StatefulWidget {
final VideoModel model;
const PostDetailOperationView({super.key, required this.model});
@override
State<PostDetailOperationView> createState() =>
_PostDetailOperationViewState();
}
class _PostDetailOperationViewState extends State<PostDetailOperationView> {
VideoModel get model => widget.model;
@override
Widget build(BuildContext context) {
return Container(
height: 24,
margin: EdgeInsets.only(bottom: 18.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 浏览量
_iconLabel(
icon: "eye_grey.webp".commonImgPath,
gap: 2,
text: model.pageViewCount?.countStr ?? "",
fontSize: 12.sp,
),
40.sizeBoxW,
// 点赞
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggleLike,
child: _iconLabel(
icon: model.vidStatus?.hasLiked == true
? "like_red.png".commonImgPath
: "like_empty_gray.png".communityPath,
gap: 5.w,
text: model.likeCount?.countOr("喜欢") ?? '',
fontSize: 12.sp,
),
),
40.sizeBoxW,
// 评论量
_iconLabel(
icon: "count_msg_gray.png".communityPath,
gap: 2,
text: model.commentCount?.countStr ?? "",
fontSize: 12.sp,
color: const Color(0xff999999),
),
40.sizeBoxW,
// 分享
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => Get.dialog(ShareMediaDialog(videoModel: model)),
child: _iconLabel(
icon: 'share_grey.png'.commonImgPath,
iconSize: 16,
gap: 4.w,
text: '分享',
fontSize: 10.sp,
),
),
],
),
);
}
/// 图标 + 文案的横向组合
Widget _iconLabel({
required String icon,
required String text,
required double fontSize,
double iconSize = 20,
double gap = 2,
Color color = const Color(0xff989898),
}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(icon, width: iconSize, height: iconSize),
gap.sizeBoxW,
Text(text, style: TextStyle(color: color, fontSize: fontSize)),
],
);
}
/// 点赞 / 取消点赞
void _toggleLike() async {
final wasLiked = model.vidStatus?.hasLiked ?? false;
final count = model.likeCount ?? 0;
final res = wasLiked
? await CommonService.cancelLike(
model.id ?? '', LoadDataType.post.apiType)
: await CommonService.sendLike(
model.id ?? '', LoadDataType.post.apiType);
if (!res || !mounted) return;
model.vidStatus?.hasLiked = !wasLiked;
model.likeCount = max(0, count + (wasLiked ? -1 : 1));
setState(() {});
}
}
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/pay/pay_manager.dart';
import 'package:hgdj/tools_base/global_store/store.dart';
/// 帖子金币解锁弹窗:解锁成功回传 true,点空白关闭交给 barrier
class PostDetailUnlockDialog extends StatelessWidget {
final VideoModel model;
const PostDetailUnlockDialog({super.key, required this.model});
@override
Widget build(BuildContext context) {
return Center(
child: Material(
color: Colors.transparent,
child: InkWell(
enableFeedback: false,
onTap: () => PayManager().buy(
model.id,
ProductType.media,
source: 'post_unlock',
onSuccess: (data) {
globalStore.updateUserInfo();
Get.back(result: true);
},
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(30)),
gradient: LinearGradient(
colors: [
Color.fromRGBO(255, 238, 220, 1),
Color.fromRGBO(249, 219, 191, 1)
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('post_detail_coin.png'.communityPath,
width: 30, height: 30),
Text(
'${model.videoCoin()}金币解锁继续',
style: const TextStyle(
color: Color(0xff370000),
fontSize: 14,
fontWeight: FontWeight.w400),
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/routers/jump_router.dart';
import 'package:hgdj/tools_base/widget/image_browser_page.dart';
import '../../../../tools_base/richTextParsing/html_parser.dart';
import '../../../../tools_base/widget/shrink_wrap.dart';
import '../../community_tag_page/community_tag_page.dart';
/// 黄游帖的游戏介绍
class GameDetailWordContent extends StatelessWidget {
final VideoModel model;
const GameDetailWordContent({super.key, required this.model});
@override
Widget build(BuildContext context) {
if (model.newsType != 'ADULT_GAME') return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('游戏介绍', style: TextStyle(color: Colors.white, fontSize: 16)),
18.sizeBoxH,
Text(model.content ?? '',
style: const TextStyle(color: Colors.white, fontSize: 16)),
],
);
}
}
/// 帖子正文:标题 + 标签 + 富文本 / 纯文本内容
class PostDetailWordContent extends StatelessWidget {
final VideoModel model;
const PostDetailWordContent({super.key, required this.model});
@override
Widget build(BuildContext context) {
if (model.newsType == 'ADULT_GAME') return const SizedBox.shrink();
final contentStyle =
TextStyle(color: const Color(0xFFDCDCDC), fontSize: 14.sp);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
14.sizeBoxH,
//标题
if (model.title?.isNotEmpty == true)
Padding(
padding: EdgeInsets.only(bottom: 1.h),
child: Text(model.title ?? '', style: contentStyle)),
//标签
_tags(),
12.h.sizeBoxH,
Divider(height: .5, color: Colors.white.withValues(alpha: .05)),
18.h.sizeBoxH,
//正文:富文本优先,没有富文本才走纯文本
if (model.richText?.isNotEmpty == true)
_richText()
else if (model.content?.isNotEmpty == true)
Padding(
padding: EdgeInsets.only(bottom: 18.h),
child: Text(model.content ?? '', style: contentStyle)),
],
);
}
Widget _richText() {
return DefaultTextStyle(
style: const TextStyle(color: Colors.white),
child: Wrap(
alignment: WrapAlignment.start,
children: HtmlParser(
width: double.infinity,
onLinkTap: pushToPageByLink,
imgLinkTap: (url) => ImageBrowserPage.open([url]),
).parse(model.richText ?? ''),
),
);
}
Widget _tags() {
final tags = model.tags ?? [];
if (tags.isEmpty) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 10),
child: ShrinkWrap(
spacing: 0,
runSpacing: 6,
maxLines: 1,
children: tags
.map((e) => InkWell(
enableFeedback: false,
onTap: () => Get.to(() => CommunityTagDetailPage(model: e)),
child: Container(
padding: const EdgeInsets.fromLTRB(8, 3, 8, 3),
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: const Color(0x0DFFFFFF),
borderRadius: BorderRadius.circular(3)),
child: Text('#${e.name}',
style: const TextStyle(
color: Color(0x8CFFFFFF), fontSize: 12)),
),
))
.toList(),
),
);
}
}
@@ -0,0 +1,115 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_utils/date_time_util.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 '../../../../hj_model/video_model.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'post_detail_unlock_dialog.dart';
/// 种子帖 / 黄游帖的资源信息与下载入口
class PostResourceInfoWidget extends StatefulWidget {
final VideoModel videoModel;
const PostResourceInfoWidget({super.key, required this.videoModel});
@override
State<PostResourceInfoWidget> createState() => _PostResourceInfoWidgetState();
}
class _PostResourceInfoWidgetState extends State<PostResourceInfoWidget> {
VideoModel get model => widget.videoModel;
/// 是否还需要花金币解锁
bool get _needLock {
if (model.freeArea == true) return false;
final me = globalStore.meInfo;
//金币视频限免期内:全场免费 或 50 金币以内免费
final isFreeTime = DateTimeUtil.calTime3(me?.goldVideoFreeExpire) > 0;
if (isFreeTime && (me?.allGoldVideoFree == true || (model.coins ?? 0) < 50))
return false;
return model.isCoinVideo() &&
!globalStore.isMe(model.publisher?.uid) &&
!(model.vidStatus?.hasPaid ?? false);
}
String get _buyDesc =>
_needLock ? '${model.realCoins ?? 0}金币解锁 即可下载种子' : '开通VIP 即可下载种子';
@override
Widget build(BuildContext context) {
final isSeed = model.newsType == 'SEED_LINK';
if (!isSeed && model.newsType != 'ADULT_GAME') return const SizedBox();
const descStyle =
TextStyle(color: Color(0xff999999), fontSize: 14, height: 1.5);
//未解锁、或非 VIP 的非免费区内容,都只能看解锁入口
final isLocked =
_needLock || (!globalStore.isVIP && model.freeArea != true);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//种子帖的影片信息与下载说明
if (isSeed) ...[
if ((model.seedSize ?? 0) > 0)
Text('[影片大小]${model.seedSizeDesc}', style: descStyle),
if ((model.seedPlayTime ?? 0) > 0)
Text('[影片时长]${model.seedPlayTimeDesc}', style: descStyle),
const Text(
'[下载说明]:解锁后请复制种子的下载链接到手机自带浏览器(谷歌/UC/夸克/火狐 等)打开!或使用BT软件(迅雷/Vuze/uTorrent/BitTorrent 等)进行下载.',
style: descStyle,
),
const Text('[温馨提示]:下载后请勿传播分享,仅供个人欣赏哟~~', style: descStyle),
18.sizeBoxH,
],
//解锁入口 / 复制链接
Center(
child: isLocked
? InkWell(
enableFeedback: false,
onTap: _showUnlock,
child: Text(
_buyDesc,
style: const TextStyle(
fontSize: 16,
color: AppColors.primaryHighColor,
fontWeight: FontWeight.w500),
),
)
: InkWell(
enableFeedback: false,
onTap: _copyLink,
child: Image.asset('link_copy_icon.webp'.communityPath,
width: 180, height: 32),
),
),
],
);
}
/// 需要金币的弹解锁弹窗,否则去充值页开 VIP
void _showUnlock() async {
if (!_needLock) {
pushToWalletPage(tabPosition: 0);
return;
}
final result = await Get.dialog(
PostDetailUnlockDialog(model: model),
barrierColor: const Color.fromRGBO(14, 20, 30, 0.6),
);
if (result == null || !mounted) return;
model.vidStatus?.hasPaid = true;
setState(() {});
}
void _copyLink() {
Clipboard.setData(ClipboardData(text: model.seedRealLink));
showToast('链接地址复制成功');
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/comment/comment_mixin.dart';
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
import 'package:hgdj/hj_utils/const.dart';
import '../../../hj_utils/history_util.dart';
import 'post_detail_page.dart';
class PostDetailLogic extends GetxController with CommentMixin {
// ===== 状态 =====
bool isLoading = false;
// ===== 数据 =====
final PostDetailPageArgument args;
VideoModel? model;
// ===== Controller =====
final scrollCtr = ScrollController();
PostDetailLogic(this.args);
@override
void onInit() {
super.onInit();
id = args.id;
objType = 'video';
}
@override
void onReady() {
super.onReady();
initData();
}
@override
void onClose() {
// scrollCtr 在本类创建,需自行释放;commentController 由 CommentMixin.onClose 处理
scrollCtr.dispose();
super.onClose();
}
//详情与评论并行拉,两个都回来才收 loading
void initData() {
isLoading = true;
update();
Future.wait([fetchDetail(), fetchComments(args.id ?? '')]).then((_) {
isLoading = false;
update();
});
}
Future fetchDetail() async {
final res = await VidService.getDetail(args.id ?? '');
if (res != null) {
model = res;
HistoryUtil.insert(model!, MediaStyle.Community);
}
update();
}
/// 上下篇切换:原地换 id 重拉,不 push 新页
/// 配套数据是 args.videoModels(各入口都在传),但翻页入口还没接,别当死代码删
void nextOrPrePost(String? newId) {
args.id = newId;
id = newId;
page = 1;
comments.clear();
initData();
}
}
@@ -0,0 +1,217 @@
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_model/video_model.dart';
import 'package:hgdj/hj_page/comment/cell/comment_item.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/banner/ads_grid_view_widget.dart';
import 'package:hgdj/tools_base/loading/loading_center_widget.dart';
import 'package:hgdj/tools_base/refresh/pull_refresh.dart';
import 'package:hgdj/tools_base/unique_tag_mixin.dart';
import '../../comment/widget/comment_foot_view.dart';
import '../../home/widget/quick_search_view.dart';
import '../widget/post_user_info_view.dart';
import 'detail_widget/post_detail_cover_content.dart';
import 'detail_widget/post_detail_operation_view.dart';
import 'detail_widget/post_detail_word_content.dart';
import 'detail_widget/post_resource_info_widget.dart';
import 'post_detail_logic.dart';
/// 过滤掉广告位,只留真实帖子
List<VideoModel>? _filters(List<VideoModel>? videoModel) {
return (videoModel ?? [])
.where((e) => !e.isRandomAd() && !e.isAdsArr())
.toList();
}
class PostDetailPageArgument {
final List<VideoModel>? videoModels;
String? id;
PostDetailPageArgument({List<VideoModel>? models, this.id})
: videoModels = _filters(models);
}
class PostDetailPage extends StatefulWidget {
final PostDetailPageArgument argument;
const PostDetailPage({super.key, required this.argument});
@override
State<PostDetailPage> createState() => _PostDetailPageState();
}
// 详情页能点头像进用户主页,主页作品列表又能开帖子详情(A→B→A),两份会并存,
// 所以 logic 必须按实例隔离;不能省掉 tag 让它们共用一个
class _PostDetailPageState extends State<PostDetailPage> with UniqueTagMixin {
@override
Widget build(BuildContext context) {
return GetBuilder<PostDetailLogic>(
init: PostDetailLogic(widget.argument),
tag: uniqueTag,
builder: (logic) {
final model = logic.model;
//置顶评论下标:getter 内部要遍历评论,别丢进 itemBuilder 里每条算一遍
final topIndex = logic.topPinnedCommentIndex;
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
titleSpacing: 18.w,
automaticallyImplyLeading: false,
title: _titleView(logic)),
body: logic.isLoading || model == null
? SizedBox(height: 300, child: LoadingCenterWidget())
: Column(
children: [
Expanded(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 18.w),
child: pullYsRefresh(
enablePullDown: false,
onInit: (refreshCtr) => logic.refreshCtr = refreshCtr,
onLoading: (_) =>
logic.fetchComments(logic.args.id ?? ''),
child: CustomScrollView(
controller: logic.scrollCtr,
slivers: [
SliverToBoxAdapter(child: 14.h.sizeBoxH),
//发帖人信息
SliverToBoxAdapter(
child: PostUserInfoView(
model: model, isDetailStyle: true)),
//正文
SliverToBoxAdapter(
child: PostDetailWordContent(model: model)),
//富文本帖自带图,不再单独铺封面
if (model.richText?.isNotEmpty != true)
SliverToBoxAdapter(
child:
PostDetailCoverContent(model: model)),
SliverToBoxAdapter(
child: GameDetailWordContent(model: model)),
SliverToBoxAdapter(
child: PostDetailVideoContent(model: model)),
SliverToBoxAdapter(
child: PostResourceInfoWidget(
videoModel: model)),
SliverToBoxAdapter(child: 16.sizeBoxH),
//点赞/收藏/分享
SliverToBoxAdapter(
child: PostDetailOperationView(model: model)),
SliverToBoxAdapter(
child: AdsGridViewWidget(
7,
accordingAdsType: true,
),
),
//评论数 + 快捷搜索词
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${model.commentCount?.countStr}条评论',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Colors.white
.withValues(alpha: 0.9)),
),
if (logic.quickSearchList.isNotEmpty) ...[
18.sizeBoxH,
QuickSearchView(logic.quickSearchList)
]
],
).paddingOnly(bottom: 14),
),
//评论列表
if (logic.comments.isEmpty)
SliverToBoxAdapter(
child: SizedBox(
height: 220, child: CErrorWidget()))
else
SliverList.builder(
itemCount: logic.comments.length,
itemBuilder: (_, index) => CommentItem(
logic.comments[index],
objId: model.id ?? '',
//置顶那条上方插一组 Banner
topBanners: index == topIndex &&
logic.commentTopBanners.isNotEmpty
? logic.commentTopBanners
: null,
replyHandler: (comment, {reply}) =>
logic.readyReplyComment(comment,
replyModel: reply),
),
)
],
),
),
),
),
//底部评论输入条
Obx(
() => CommentFootView(
video: model,
showCollect: true,
onComment: (isSend) =>
logic.readyReplyComment(null, isSend: isSend),
isSending: logic.isSendingMsg.value,
textCtr: logic.commentController,
imgPath: logic.selectedImagePath.value,
onImgChange: (value) =>
logic.selectedImagePath.value = value,
),
)
],
),
);
},
);
}
//标题栏:未滚动 / 游戏帖显示固定标题,滚过 60 后换成发帖人信息
Widget _titleView(PostDetailLogic logic) {
final scrollCtr = logic.scrollCtr;
return ListenableBuilder(
listenable: scrollCtr,
builder: (_, __) {
final isGame = logic.model?.newsType == 'ADULT_GAME';
if (!scrollCtr.hasClients || scrollCtr.offset < 60 || isGame) {
return Stack(
alignment: Alignment.centerLeft,
children: [
_backBtn(),
Center(
child: Text(switch (logic.model?.newsType) {
'SEED_LINK' => '种子详情',
'ADULT_GAME' => '游戏详情',
_ => '帖子详情',
}),
),
],
);
}
return Row(
children: [
_backBtn(),
4.sizeBoxW,
Expanded(
child: PostUserInfoView(
model: logic.model!, isDetailStyle: true, avatarSize: 30)),
],
);
},
);
}
Widget _backBtn() {
return GestureDetector(
onTap: () => Get.back(),
child: const Icon(Icons.arrow_back_ios, size: 18, color: Colors.white),
);
}
}
@@ -0,0 +1,55 @@
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/search_service.dart';
import 'package:hgdj/tools_base/debug_log.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'chose_tag_page.dart';
class ChoseTopicLogic extends GetxController {
ChoseTopicLogic({
required this.entry,
List<TagsBean> initialSelectTags = const [],
}) : selectTags = initialSelectTags.obs;
// ========== 属性声明 ==========
final ChoseTopicEntry entry;
final RxList<TagsBean> selectTags;
final dataSource = <TagsBean>[];
RefreshController? refreshCtr;
bool isLoading = true;
// ========== 生命周期 ==========
@override
void onReady() {
super.onReady();
loadTags();
}
// ========== 公开方法 ==========
/// 标签一次拉全量,接口本身没有分页
Future<void> loadTags() async {
try {
dataSource.addAll(await SearchService.fetchPublishTags());
} catch (e) {
//失败也要收 loading,否则页面一直转圈
debugLog('fetchPublishTags $e');
}
refreshCtr?.loadNoData();
isLoading = false;
update();
}
/// 单选并返回
void pickSingleAndBack(TagsBean model) {
//点的就是已选中那项:不必重复赋值,直接关页面(原来 return 掉会点了没反应)
if (selectTags.any((e) => e.id == model.id)) {
Get.back();
return;
}
selectTags
..clear()
..add(model);
Get.back(result: model);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/community/publish_page/chose_tag_logic.dart';
import 'package:hgdj/hj_page/home/search_page/widget/topic_item.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 ChoseTopicEntry {
home('选择标签'),
squire('选择话题');
final String title;
const ChoseTopicEntry(this.title);
}
class ChoseTopicPage extends StatelessWidget {
final ChoseTopicEntry? entry;
final List<TagsBean>? selectTags;
const ChoseTopicPage({super.key, this.entry, this.selectTags});
@override
Widget build(BuildContext context) {
return GetBuilder<ChoseTopicLogic>(
init: ChoseTopicLogic(
entry: entry ?? ChoseTopicEntry.home,
initialSelectTags: selectTags ?? const [],
),
global: false,
builder: (logic) => Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(logic.entry.title),
),
body: pullYsRefresh(
onInit: (ctr) => logic.refreshCtr = ctr,
//标签一次拉全量,没有下拉刷新;不关掉的话下拉会卡在转圈(onRefresh 为空没人收)
enablePullDown: false,
child: _buildBody(logic),
),
),
);
}
Widget _buildBody(ChoseTopicLogic logic) {
if (logic.isLoading) return LoadingCenterWidget();
if (logic.dataSource.isEmpty)
return CErrorWidget(retryOnTap: logic.loadTags);
return _buildList(logic);
}
/// 单选 list
Widget _buildList(ChoseTopicLogic logic) {
return ListView.separated(
padding: EdgeInsets.only(left: 18.w, right: 18.w, top: 14.h),
separatorBuilder: (_, __) => 12.h.sizeBoxH,
itemCount: logic.dataSource.length,
itemBuilder: (_, index) {
final model = logic.dataSource[index];
final selected =
logic.selectTags.indexWhere((e) => e.id == model.id) != -1;
return TopicItem(
model: model,
isSelect: selected,
onTap: () => logic.pickSingleAndBack(model),
);
},
);
}
}
@@ -0,0 +1,384 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:image_pickers/image_pickers.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_utils/api_service/vid_service.dart';
import 'package:hgdj/hj_utils/file_util.dart';
import 'package:hgdj/hj_utils/media_info.dart';
import 'package:hgdj/tools_base/file_upload/file_upload_tool.dart';
import 'package:hgdj/tools_base/loading/loading_alert_widget.dart';
import 'package:hgdj/tools_base/toast.dart';
import 'package:path_provider/path_provider.dart';
import 'package:video_thumbnail/video_thumbnail.dart';
import 'package:hgdj/tools_base/widget/common_alert.dart';
import 'publish_page.dart';
/// 社区发帖的视频载体:单条视频的本地/远程地址 + 元信息
class PublishVideoModel {
String? localPath;
String? localCover;
String? remotePath;
String? remoteCover;
String? customCover;
double? ratio;
String? name;
String? md5;
int? playTime;
int? size;
String? sourceid;
String? resolution;
PublishVideoModel(
{this.localPath,
this.localCover,
this.remotePath,
this.remoteCover,
this.ratio,
this.name,
this.playTime,
this.size,
this.sourceid,
this.resolution,
this.md5,
this.customCover});
copy() {
return PublishVideoModel(
localCover: localCover,
localPath: localPath,
remotePath: remotePath,
remoteCover: remoteCover,
resolution: resolution,
ratio: ratio,
name: name,
playTime: playTime,
size: size,
sourceid: sourceid,
md5: md5,
customCover: customCover);
}
}
class PublishLogic extends GetxController {
// Rx<TagsBean?> topic = Rx(null);
RxList<TagsBean> topics = <TagsBean>[].obs;
Rx<String?> gold = Rx(null);
RxList<String> imgs = [''].obs;
final describeController = TextEditingController();
final titleController = TextEditingController();
final titleFouceN = FocusNode();
final contentFouceN = FocusNode();
final goldFouceN = FocusNode();
// 链接的视频
Rx<PublishVideoModel> video = Rx(PublishVideoModel());
final PublishType type;
PublishLogic(this.type);
@override
void onClose() {
describeController.dispose();
titleController.dispose();
titleFouceN.dispose();
contentFouceN.dispose();
goldFouceN.dispose();
super.onClose();
}
void unfocus() {
if (titleFouceN.context != null && titleFouceN.hasFocus) {
titleFouceN.unfocus();
}
if (contentFouceN.context != null && contentFouceN.hasFocus) {
contentFouceN.unfocus();
}
if (goldFouceN.context != null && goldFouceN.hasFocus) {
goldFouceN.unfocus();
}
}
/// 视频封面相关
choseCover() async {
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 1,
showCamera: false,
);
if (listMedia.isEmpty) return;
video.value = video.value.copy()..customCover = listMedia.first.path ?? '';
}
deleteCover() {
video.value = video.value.copy()..customCover = null;
}
///视频相关
choseVideo() async {
var pickedFile = await ImagePicker().pickVideo(source: ImageSource.gallery);
if (pickedFile == null) return null;
if (!File(pickedFile.path).existsSync()) {
showToast("用户视频文件损坏或格式错误");
return;
}
var path = pickedFile.path;
final mediaInfo = await _checkVideoRule(path);
if (mediaInfo == null) return;
final coverPath_ = await VideoThumbnail.thumbnailFile(
video: path,
thumbnailPath: (await getTemporaryDirectory()).path,
imageFormat: ImageFormat.PNG,
quality: 100,
);
final videoM_ = PublishVideoModel(
localCover: coverPath_,
localPath: path,
ratio: mediaInfo.ratio ?? 1,
name: "${DateTime.now().toIso8601String()}.mp4",
playTime: mediaInfo.playTime,
size: mediaInfo.size,
resolution: mediaInfo.resolution);
video.value = videoM_..customCover = video.value.customCover;
}
deleteSelectVideo() {
video.value = PublishVideoModel()..customCover = video.value.customCover;
}
/// 图集相关
choseImg() async {
final listMedia = await ImagePickers.pickerPaths(
uiConfig: UIConfig(uiThemeColor: Colors.white),
galleryMode: GalleryMode.image,
selectCount: 9 - (imgs.length - 1),
showCamera: false,
);
if (listMedia.isEmpty) return;
final ret = listMedia.map((e) => e.path ?? '').toList();
imgs.insertAll(0, ret);
}
publish() async {
final res = _checkEnable();
if (!res) return;
FocusManager.instance.primaryFocus?.unfocus();
//上传图片前 先移除第一个占位
List<String> imgs_ = [];
if (type != PublishType.squireVideo) {
imgs_.addAll(imgs);
imgs_.remove('');
}
// 上传图片(封面),成功后再走视频上传 + 提交
FileUploadTool().uploadImagesWithProgress(
imgs_,
onFailure: () => showToast("图片上传失败"),
onSuccess: (seriesCover) => _publishWithCover(seriesCover),
);
}
/// 封面上传完成后:上传视频(可选)→ 提交发布
Future<void> _publishWithCover(List<String> seriesCover) async {
// 无视频:直接提交
if (video.value.localPath == null) {
_submitPost(seriesCover, null);
return;
}
// 有视频:取元信息 → 校验封面 → 上传视频 → 提交
// 取元信息期间也要有遮挡,否则这段空窗期发布按钮可重复点
LoadingAlertWidget.show(title: "正在处理视频...");
final mediaInfo = await getMediaInfo(video.value.localPath ?? "");
LoadingAlertWidget.cancel();
video.value.remoteCover = seriesCover.first;
if (video.value.remoteCover == null) {
showToast("视频封面上传失败");
return;
}
FileUploadTool().uploadVideoWithProgress(
video.value.localPath ?? '',
onFailure: () => showToast("视频上传失败"),
onSuccess: (fileR) {
video.value.remotePath = fileR.videoUri;
video.value.md5 = fileR.md5;
video.value.sourceid = fileR.id;
_submitPost(seriesCover, mediaInfo);
},
);
}
/// 提交发布([mediaInfo] 仅视频帖有)
Future<void> _submitPost(
List<String> seriesCover, MediaInfo? mediaInfo) async {
final result = await VidService.submit(
title: titleController.text,
newsType: _generateNewsType(),
tags: topics.map((element) => element.id ?? '').toList(),
seriesCover: seriesCover,
coins: int.parse(gold.value ?? '0'),
playTime: mediaInfo?.playTime,
mimeType: mediaInfo != null ? "mp4" : null,
content: describeController.text,
sourceID: video.value.sourceid,
md5: video.value.md5,
ratio: video.value.ratio,
size: video.value.size,
resolution: video.value.resolution,
sourceURL: video.value.remotePath,
cover: video.value.remoteCover,
);
if (result) {
await CommonAlert.show(
title: "发布成功",
content: '帖子发布后将在24小时内进行审核,您可在【我的】-【我的帖子】中进行查看',
showCancel: false,
);
Get.back();
} else {
showToast('发布失败');
}
}
/// 检查本地视频
Future<MediaInfo?> _checkVideoRule(String path) async {
if (path.isEmpty) {
showToast("视频地址错误");
return null;
}
MediaInfo? videoInfo = await getMediaInfo(path);
if (videoInfo == null) {
showToast('用戶視頻文件損壞或格式錯誤');
return null;
}
//时间
if ((videoInfo.playTime ?? 0) < 30 && (videoInfo.playTime ?? 0) > 0) {
showToast('請選擇30秒以上視頻');
return null;
}
//大小
int sizeM = (videoInfo.size ?? 0) ~/ (MB_SIZE);
if (sizeM > 300 || sizeM == 0) {
showToast('請選擇250M內視頻');
return null;
}
//分辨率
String resolution = videoInfo.resolution ?? '';
if (resolution.isEmpty || !resolution.contains("*")) {
showToast('用戶視頻文件損壞或格式錯誤');
return null;
}
List<String> list = resolution.split("*");
var curResolution = double.parse(list[0]) * double.parse(list[1]);
if (curResolution < 360 * 360) {
showToast('請選擇分辨率大於360*360以上視頻');
return null;
}
return videoInfo;
}
bool _checkEnable() {
if (type == PublishType.homeImgText || type == PublishType.homeImg) {
if (titleController.text.isEmpty) {
showToast('请输入标题!');
return false;
}
if (describeController.text.isEmpty && type == PublishType.homeImgText) {
showToast('请输入描述!');
return false;
}
if (imgs.length <= 1) {
showToast('请选择图片!');
return false;
}
if (topics.isEmpty) {
showToast('请选择话题哦~');
return false;
}
return true;
}
if (type == PublishType.homeVideo) {
if (titleController.text.isEmpty) {
showToast('请输入标题!');
return false;
}
if (describeController.text.isEmpty) {
showToast('请输入描述!');
return false;
}
if (video.value.localPath == null) {
showToast('请选择需要上传的视频!');
return false;
}
if (imgs.length <= 1) {
showToast('请选择图片!');
return false;
}
if (topics.isEmpty) {
showToast('请选择话题哦~');
return false;
}
return true;
}
if (type == PublishType.squireImgText) {
if (topics.isEmpty) {
showToast('至少选择一个话题哦~');
return false;
}
if (describeController.text.isEmpty) {
showToast('请输入描述!');
return false;
}
if (imgs.length == 1) {
showToast('请选择图片!');
return false;
}
return true;
}
if (topics.isEmpty) {
showToast('至少选择一个话题哦~');
return false;
}
if (describeController.text.isEmpty) {
showToast('请输入描述!');
return false;
}
if (video.value.localPath == null) {
showToast('请选择需要上传的视频!');
return false;
}
return true;
}
String _generateNewsType() {
if (type == PublishType.homeImg ||
type == PublishType.homeImgText ||
type == PublishType.squireImgText) return 'COVER';
final duration = video.value.playTime ?? 0;
if (duration <= 300) return 'SP';
return 'SP';
}
}
@@ -0,0 +1,143 @@
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/hj_page/community/publish_page/chose_tag_page.dart';
import 'package:hgdj/hj_page/community/publish_page/publish_logic.dart';
import 'package:hgdj/hj_page/community/publish_page/publish_rule_page.dart';
import 'package:hgdj/hj_page/community/publish_page/widget/publish_widget.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/common_alert.dart';
enum PublishType {
homeImg(RuleType.home, ChoseTopicEntry.home, '发布图片'),
homeImgText(RuleType.home, ChoseTopicEntry.home, '发布图文'),
homeVideo(RuleType.home, ChoseTopicEntry.home, '发布视频'),
squireImgText(RuleType.squire, ChoseTopicEntry.squire, '发布帖子'),
squireVideo(RuleType.squire, ChoseTopicEntry.squire, '发布帖子');
final RuleType rule;
final ChoseTopicEntry tag;
final String title;
const PublishType(this.rule, this.tag, this.title);
}
extension PublishMore on PublishType {
/// 各类型的表单项,顺序即页面从上到下的排列
List<Widget> get children => switch (this) {
PublishType.homeImg => [
TopicWidget(),
InputTitleWidget(),
ChoseImageWidget(highlightText: '添加图集'),
],
PublishType.homeImgText => [
TopicWidget(),
InputTitleWidget(),
InputDescribeWidget(),
ChoseImageWidget(highlightText: '添加图集'),
],
PublishType.homeVideo => [
TopicWidget(),
InputTitleWidget(),
InputDescribeWidget(),
ChoseVideoWidget(),
ChoseImageWidget(
hint: '「默认第一张为封面」图片数量最多9张哦~',
highlightText: '「默认第一张为封面」',
highlightTextSize: 12.sp,
highlightTextColor: const Color(0xffB40400),
),
SettingGoldWidget(),
],
PublishType.squireImgText => [
TopicWidget(),
InputDescribeWidget(),
ChoseImageWidget(hint: '第一张图集默认为封面'),
],
PublishType.squireVideo => [
TopicWidget(),
InputDescribeWidget(),
ChoseVideoWidget(),
],
};
}
class PublishPage extends StatelessWidget {
final PublishType type;
const PublishPage({super.key, this.type = PublishType.homeImgText});
@override
Widget build(BuildContext context) {
return GetBuilder<PublishLogic>(
init: PublishLogic(type),
builder: (logic) => Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(logic.type.title),
actions: [
TextButton(
onPressed: () => CommonAlert.show(
title: '发布规则',
content: '· 禁止发布广告图文 (含Line、WeChat、QQ、网址、二维码等外链)\n'
'· 禁止发布诱导图文 (求网址、求软体等)\n'
'· 禁止发布儿童色情、兽*内容\n'
'· 禁止发布交易相关、政治等内容',
subContent: '请遵守社区规则,否则会被官方机器人拉黑',
showCancel: false,
confirmText: '我知道了',
),
child:
const Text('规则', style: TextStyle(color: Color(0x8CFFFFFF))),
),
18.w.sizeBoxW,
],
),
//点空白处收起键盘
body: InkWell(
enableFeedback: false,
onTap: logic.unfocus,
child: Container(
margin: EdgeInsets.symmetric(horizontal: 16.w),
width: double.infinity,
child: SafeArea(
child: Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: logic.type.children,
),
),
),
//确定发布
GestureDetector(
onTap: logic.publish,
child: Container(
margin: EdgeInsets.symmetric(vertical: 12.h),
padding: EdgeInsets.symmetric(vertical: 10.h),
alignment: Alignment.center,
width: double.infinity,
decoration: BoxDecoration(
color: AppColors.actionRed,
borderRadius: BorderRadius.circular(3),
),
child: const Text(
'确定发布',
style: TextStyle(
color: Colors.white, fontSize: 16, height: 27 / 14),
),
),
),
],
),
),
),
),
),
);
}
}
@@ -0,0 +1,93 @@
import 'package:easy_rich_text/easy_rich_text.dart';
import 'package:flutter/material.dart';
const homeRule = '''欢迎优秀的您加入黑料社,我们珍视每一位UP主,始终致力于为大家带来最优质的产品与服务。您的作品在黑料社的售卖无时间、次数限制,销售的次数越多,获得的收入就会越多。无论您是在睡觉还是在工作,它们都将持续为您带来源源不断的收入。
上传规则
1.UP主及普通用户上传收费视频比例为2:1,即上传2个免费视频才可上传1个收费视频。
2.原创举牌up主上传收费视频比例为1:1,即上传1个免费视频才可上传1个收费视频。
3.视频清晰度需在360P以上,且时长不小于30秒。
4.套图大于6张才可以设置价格。
5.审核时间为48小时内,请在[创作中心]查收反馈。
6.视频中的当事人须满18岁以上,且当事人同意视频被上传分享。
审核规则
1.原创拍摄、原创剪辑作品,会更容易通过并获得官方推荐。
2.禁止直接搬运网络视频,重复率高且不容易通过,多次违规将降低账号推荐权重。
3.禁止在视频/图片中添加个人联系方式或插入广告网址将不会通过审核。
4.禁止上传幼女、人兽、真实强奸等侵害他人的视频。
5.加强用户隐私性,允许原创视频为人物面部等重要部分添加遮挡或马赛克。
6.上传的视频内容不符合上传要求将不会通过审核,如若退回视频未作修改再次发起审核将禁止上传。
定价规则
1.发布内容默认为免费,用户可根据作品内容质量调整为金币视频。
2.认证UP主发布原创举牌长视频,建议定价50-200金币
3.原创举牌作品,建议定价30-50金币
4.原创剪辑作品,建议定价10-20金币
5.非原创短片,建议定价1-10金币''';
const squireRule = '''● 添加合适的话题:通过添加#话题 给内容打上标签,让你的内容被推荐给更多有共同语言的Tapper~
● 用心起标题:用10-20个字总结:”想要表达的观点“或”想解决的问题“
● 第一张图很重要:图文的第一张图会作为封面。图片需突出重点,放大关键信息。视频同理。
● 正文简单明了:段落清晰,一句一行!用标点符号、emoji表情进行分段分区!
''';
const treeCaveRule = '''抽取纸条规则:
1.同一张纸条可以被多人抽取到
2.每个用户每天有5次免费抽取纸条机会,超出后每次抽取需要支付10积分;
3.纸条抽取次数不可累计,如当天未使用凌晨00:00分自动清零重新增加5次
投递纸条规则:
1.用户放入纸条每次需要支付5积分
2.不能发布广告,下载链接等引流信息
3.不能如111,666这类的无意义信息
''';
enum RuleType {
home(homeRule),
squire(squireRule),
treeCave(treeCaveRule);
final String content;
const RuleType(this.content);
}
class PublishRulePage extends StatelessWidget {
final RuleType type;
const PublishRulePage({super.key, this.type = RuleType.home});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('发布规则'),
),
body: SingleChildScrollView(
// controller: controller,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: EasyRichText(
type.content,
defaultStyle: TextStyle(color: Colors.white.withValues(alpha: .6), fontSize: 14),
patternList: [
EasyRichTextPattern(targetString: '认证UP主发布原创举牌长视频', style: TextStyle(color: Color(0xffF68216), fontSize: 14)),
EasyRichTextPattern(targetString: '抽取纸条规则:', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600)),
EasyRichTextPattern(targetString: '投递纸条规则:', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600))
],
),
),
);
}
}
@@ -0,0 +1,428 @@
import 'dart:io';
import 'package:easy_rich_text/easy_rich_text.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_model/video_model.dart';
import 'package:hgdj/tools_base/widget/add_media_source_button.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../home/widget/support_up_alert.dart';
import '../chose_tag_page.dart';
import '../publish_logic.dart';
import 'video_cover_widget.dart';
/// 发布页各输入区统一的卡片底色
const publishDecoration = BoxDecoration(
color: Color(0x0DFFFFFF),
borderRadius: BorderRadius.all(Radius.circular(10)),
);
enum LinkType { topic, video, link }
/// 添加视频
class ChoseVideoWidget extends StatelessWidget {
ChoseVideoWidget({super.key});
final controller = Get.find<PublishLogic>();
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('添加视频',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500)),
10.sizeBoxW,
Text('最大300M以内',
style: TextStyle(color: Color(0xff666666), fontSize: 12.sp)),
],
),
14.h.sizeBoxH,
Obx(() {
final cellW = (Get.width - 52) / 3;
return Row(children: [
6.h.sizeBoxW,
if (controller.video.value.localPath == null)
AddMediaSourceButton(
width: cellW,
height: cellW,
onTap: controller.choseVideo,
isVideo: true)
else
_cover(cellW),
]);
})
],
),
);
}
/// 已选视频:封面 + 右上角删除
Widget _cover(double size) {
return Container(
constraints: const BoxConstraints(maxWidth: 230, maxHeight: 219),
child: Stack(
children: [
VideoCoverWidget(controller.video.value.localCover ?? '',
width: size, height: size, isLocal: true),
Positioned(
right: 6,
top: 6,
child: GestureDetector(
onTap: () => controller.deleteSelectVideo(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .8),
borderRadius: BorderRadius.circular(10)),
child:
const Icon(Icons.close, size: 12, color: Color(0xff333333)),
),
),
)
],
),
);
}
}
/// 选择标签
class TopicWidget extends StatelessWidget {
TopicWidget({super.key});
final controller = Get.find<PublishLogic>();
@override
Widget build(BuildContext context) {
return Obx(
() => GestureDetector(
onTap: () async {
final model = await Get.to(ChoseTopicPage(
entry: controller.type.tag, selectTags: controller.topics));
if (model is TagsBean) {
controller.topics
..clear()
..add(model);
}
},
child: Container(
margin: EdgeInsets.only(top: 12.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
alignment: Alignment.centerLeft,
height: 60.h,
decoration: publishDecoration,
child: controller.topics.isEmpty ? _emptyTip() : _topicInfo(),
),
),
);
}
/// 未选标签时的占位提示
Widget _emptyTip() {
return Row(
children: [
Text("#",
style: TextStyle(
color: Color(0xff989898),
fontSize: 24.sp,
fontWeight: FontWeight.bold)),
10.sizeBoxW,
Text('选择标签',
style: TextStyle(
color: Color(0xff989898),
fontSize: 12.sp,
fontWeight: FontWeight.w500)),
const Spacer(),
Icon(Icons.arrow_forward_ios, size: 16.sp, color: Color(0xffDCDCDC)),
],
);
}
/// 已选标签:封面 + 名称(点击交给外层跳转,别再套手势,否则会截走外层的 onTap)
Widget _topicInfo() {
return Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: NetworkImageLoader(
imageUrl: controller.topics.first.coverImg ?? '',
width: 38,
height: 38),
),
5.sizeBoxW,
Text(
controller.topics.firstOrNull?.name ?? '',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500),
),
Spacer(),
Icon(Icons.arrow_forward_ios, size: 16.sp, color: Color(0xffDCDCDC)),
],
);
}
}
/// 输入标题
class InputTitleWidget extends StatelessWidget {
InputTitleWidget({super.key});
final controller = Get.find<PublishLogic>();
@override
Widget build(BuildContext context) {
return Container(
height: 60.h,
margin: EdgeInsets.only(top: 14.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
alignment: Alignment.centerLeft,
decoration: publishDecoration,
child: TextField(
maxLines: 1,
textAlign: TextAlign.left,
textAlignVertical: TextAlignVertical.center,
style: TextStyle(color: Colors.white, fontSize: 16.sp, height: 22 / 15),
focusNode: controller.titleFouceN,
controller: controller.titleController,
decoration: InputDecoration(
border: InputBorder.none,
hintText: '请填写标题',
hintStyle: TextStyle(
color: Color(0xff525252), fontSize: 12.sp, height: 22 / 15),
contentPadding: EdgeInsets.zero,
isDense: true,
),
),
);
}
}
/// 输入正文(右下角实时字数)
class InputDescribeWidget extends StatelessWidget {
InputDescribeWidget({super.key});
final controller = Get.find<PublishLogic>();
@override
Widget build(BuildContext context) {
return InkWell(
enableFeedback: false,
onTap: () => controller.contentFouceN.requestFocus(),
child: Container(
constraints: BoxConstraints(minHeight: 171.h),
margin: const EdgeInsets.only(top: 14),
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 11.h),
decoration: publishDecoration,
child: Stack(
children: [
TextField(
style: TextStyle(color: Colors.white, fontSize: 16.sp),
maxLength: 300,
maxLines: null,
focusNode: controller.contentFouceN,
controller: controller.describeController,
decoration: InputDecoration(
border: InputBorder.none,
hintStyle: TextStyle(color: Color(0xff525252), fontSize: 12.sp),
hintMaxLines: 10,
hintText: '有趣的介绍能让你的逼格提高N个档次!...',
counter: SizedBox(),
contentPadding: EdgeInsets.zero,
isDense: true,
),
),
Positioned(
bottom: 0,
right: 0,
// 字数计数:只监听不接管所有权(释放归 PublishLogic.onClose)
child: ValueListenableBuilder(
valueListenable: controller.describeController,
builder: (_, value, __) => Text(
'${value.text.length}/300',
style: TextStyle(color: Color(0xff757575), fontSize: 12),
),
),
)
],
),
),
);
}
}
/// 添加图片(最多 9 张,列表末位固定是「添加」占位)
class ChoseImageWidget extends StatelessWidget {
final String? hint;
final String? highlightText;
final Color? highlightTextColor;
final double? highlightTextSize;
ChoseImageWidget(
{super.key,
this.hint,
this.highlightText,
this.highlightTextColor,
this.highlightTextSize});
final controller = Get.find<PublishLogic>();
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(top: 14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(
children: [
Text('添加图片',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500)),
EasyRichText(
hint ?? '添加图集 图片数量最多上传9张哦~',
defaultStyle:
TextStyle(color: Color(0xff666666), fontSize: 12.sp),
patternList: [
EasyRichTextPattern(
targetString: highlightText ?? '',
style: TextStyle(
color: highlightTextColor ?? Colors.white,
fontSize: highlightTextSize ?? 16.sp,
fontWeight: FontWeight.w500),
)
],
),
],
),
14.h.sizeBoxH,
LayoutBuilder(builder: (__, constrains) {
final cellW = (constrains.maxWidth - 16) / 3;
final images = controller.imgs;
return Obx(
() => Wrap(
runSpacing: 8,
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.start,
children: List.generate(images.length, (i) {
// 末位是占位项:满 10 项(9 张图)就不再给「添加」按钮
if (i < images.length - 1) return _imgCell(images[i], cellW);
if (i == 9) return const SizedBox.shrink();
return AddMediaSourceButton(
isVideo: false,
onTap: controller.choseImg,
height: cellW,
width: cellW,
title: "添加图片",
);
}),
),
);
}),
]),
);
}
/// 单张已选图片 + 右上角删除
Widget _imgCell(String path, double size) {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: size,
height: size,
child: Stack(
children: [
Image.file(File(path),
width: size, height: size, fit: BoxFit.cover),
Positioned(
right: 6,
top: 6,
child: GestureDetector(
onTap: () => controller.imgs.remove(path),
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),
),
),
)
],
),
),
);
}
}
/// 设置价格(金币)
class SettingGoldWidget extends StatelessWidget {
SettingGoldWidget({super.key});
final controller = Get.find<PublishLogic>();
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () async {
final res = await Get.bottomSheet(
SupportUPAlert(
isPublish: true,
selectCoin: int.tryParse(controller.gold.value ?? '')),
);
controller.gold.value = res?.toString();
},
child: Container(
margin: const EdgeInsets.only(top: 18),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
decoration: publishDecoration,
child: Row(
children: [
Image.asset('community_coin.webp'.communityPath,
width: 20, height: 20),
10.sizeBoxW,
const Text('设置价格',
style: TextStyle(
color: Color(0xFF989898),
fontSize: 12,
fontWeight: FontWeight.w600)),
const Spacer(),
Obx(
() => Text(
controller.gold.value == null
? '免费'
: '${controller.gold.value} 金币',
style: TextStyle(
color: controller.gold.value == null
? Color(0xFF989898)
: AppColors.actionRed,
// color: Color(0xffF68216),
fontSize: 12,
fontWeight: FontWeight.w400),
),
),
5.sizeBoxW,
const Icon(Icons.arrow_forward_ios,
color: Color(0xffDCDCDC), size: 12)
],
),
),
);
}
}
@@ -0,0 +1,57 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
/// 视频封面:本地/网络图 + 可选暗色遮罩 + 居中播放按钮
/// [ratio] 传了就按比例撑开,不传则由外部约束决定尺寸
class VideoCoverWidget extends StatelessWidget {
final String cover;
final double? ratio;
final double? width;
final double? height;
final bool isLocal;
final bool showMask;
final double borderRadius;
const VideoCoverWidget(
this.cover, {
super.key,
this.ratio,
this.width,
this.height,
this.isLocal = false,
this.showMask = false,
this.borderRadius = 8,
});
@override
Widget build(BuildContext context) {
final maskW = width ?? double.infinity;
final maskH = height ?? double.infinity;
final child = Stack(
alignment: Alignment.center,
children: [
if (isLocal)
Image.file(File(cover),
width: width, height: height, fit: BoxFit.cover)
else
NetworkImageLoader(
imageUrl: cover, width: maskW, height: maskH, borderRadius: 0),
if (showMask)
Container(
width: maskW,
height: maskH,
color: Colors.black.withValues(alpha: 0.4)),
Image.asset('circle_play.webp'.videoPath, width: 30, height: 30),
],
);
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: ratio == null
? child
: AspectRatio(aspectRatio: ratio! > 0 ? ratio! : 1, child: child),
);
}
}
@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
import '../community/community_tab_logic.dart';
/// 社区首页的排序 tab 条,标题取自 CommunityTabLogic.sortTabs
class CommunityMiddleHeader extends StatefulWidget {
final Function(int sort) sortOnTap;
const CommunityMiddleHeader({super.key, required this.sortOnTap});
@override
State<CommunityMiddleHeader> createState() => _CommunityMiddleHeaderState();
}
class _CommunityMiddleHeaderState extends State<CommunityMiddleHeader>
with SingleTickerProviderStateMixin {
//别在这里另写一份标题
final sorts = CommunityTabLogic.sortTabs.map((e) => e.name).toList();
late final TabController _controller;
@override
void initState() {
super.initState();
//别用 late 惰性求值:万一没走 build 就 dispose,会在 dispose 里现建一个 Ticker
_controller = TabController(length: sorts.length, vsync: this);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: AppColors.primaryColor,
child: TabBar(
isScrollable: true,
labelPadding: EdgeInsets.symmetric(horizontal: 20.w),
padding: EdgeInsets.zero,
onTap: widget.sortOnTap,
tabAlignment: TabAlignment.center,
tabs: sorts
.map((e) => Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w),
child: Text(e),
))
.toList(),
indicatorWeight: 1,
unselectedLabelStyle: TextStyle(
color: Colors.white.withValues(alpha: .55),
fontSize: 16.sp,
height: 1),
labelStyle: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 16.sp,
height: 1,
fontWeight: FontWeight.w600),
indicator: const BoxDecoration(),
controller: _controller,
),
);
}
}
@@ -0,0 +1,343 @@
import 'dart:math';
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_utils/date_time_util.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../hj_model/video_model.dart';
import '../../find/rank_module/rank_badge.dart';
import '../../home/widget/collect_status_wrapper.dart';
import '../community_tag_page/community_tag_page.dart';
import '../post_detail_page/post_detail_page.dart';
import 'post_user_info_view.dart';
import 'post_word_rich_text.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/tools_base/widget/common_alert.dart';
typedef OnItemCheckCallback = void Function(
VideoModel videoModel, bool isChecked);
/// 社区帖子卡片,社区/搜索/个人主页/我的发布/收藏历史多处复用
class CommunityPostWidget extends StatelessWidget {
final VideoModel videoModel;
/// 详情页要拿整个列表做左右滑,由列表页传进来
final List<VideoModel>? videoModels;
final GestureTapCallback? clickCallback;
/// 我的发布模式:顶部换成「发布/上传于…」+ 审核状态
final bool isMyPublish;
final bool showTags; //是否展示标签
final bool showLine; //展示分割线
final bool showCheck; //展示勾选框
final bool isChecked; //是否选中
final OnItemCheckCallback? onItemCheck;
final int? rankIndex; //排行
final EdgeInsetsGeometry? padding;
const CommunityPostWidget({
super.key,
required this.videoModel,
this.clickCallback,
this.isMyPublish = false,
this.videoModels,
this.showTags = true,
this.padding = const EdgeInsets.fromLTRB(16, 10, 16, 10),
this.showLine = true,
this.showCheck = false,
this.onItemCheck,
this.isChecked = false,
this.rankIndex,
});
//status: 0待审核 1已成功 2未通过
String get _statusPrefix => videoModel.status == 1 ? '发布' : '上传';
String get _statusDesc => switch (videoModel.status) {
0 => '待审核',
1 => '已成功',
2 => '未通过原因',
_ => '未知状态', //含 5
};
Color get _statusColor => videoModel.status == 2
? const Color(0xffFD0563)
: const Color(0xff3476FF);
bool get _showStatus => videoModel.status != 1 && !showCheck;
@override
Widget build(BuildContext context) {
//视频帖的封面也在图集里,去重后图集只留纯图
if (videoModel.sourceURL?.isNotEmpty == true) {
videoModel.seriesCover?.remove(videoModel.cover ?? "");
}
return InkWell(
enableFeedback: false,
onTap: clickCallback ??
() {
//在点击时算好:PostDetailPageArgument 内部要过滤整个列表,别丢进 page builder 里重复跑
final argument =
PostDetailPageArgument(id: videoModel.id, models: videoModels);
Get.to(() => PostDetailPage(argument: argument));
},
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//顶部:我的发布出「发布于…」+ 审核态,其余出用户信息行
if (!isMyPublish)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(child: PostUserInfoView(model: videoModel)),
if (rankIndex != null) rankBadge(rankIndex!),
],
),
)
else ...[
Row(
children: [
Text(
'$_statusPrefix于${formatTimeTwo(videoModel.realCreateAt)}',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Color(0x8CFFFFFF), fontSize: 14),
),
const Spacer(),
if (showCheck)
GestureDetector(
onTap: () => onItemCheck?.call(videoModel, !isChecked),
child: Stack(
alignment: Alignment.center,
children: [
//白底垫在勾选图标下面,选中时圆圈内部才不透出背景
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color:
isChecked ? Colors.white : Colors.transparent,
shape: BoxShape.circle,
),
),
Icon(
isChecked
? CupertinoIcons.checkmark_circle_fill
: CupertinoIcons.circle,
color: isChecked
? const Color(0xffF68804)
: const Color(0xff999999),
),
],
),
),
if (_showStatus)
InkWell(
enableFeedback: false,
onTap: () async {
if (videoModel.status == 2) {
await CommonAlert.show(
title: "未过审原因",
content: '${videoModel.reason}',
showCancel: false,
confirmText: '我知道了',
);
}
},
child: Text(_statusDesc,
style:
TextStyle(color: _statusColor, fontSize: 14.sp)),
),
],
),
14.sizeBoxH,
],
//标题(含置顶、精华标签)
if (videoModel.title?.trim().isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PostWordRichText(videoModel: videoModel),
),
//封面:视频单图 / 单图 / 三宫格
_coverView(),
12.sizeBoxH,
//底部:浏览、评论、点赞 + 右侧话题
Row(
children: [
_countItem(videoModel.pageViewCount?.countStr,
"eye_grey.webp".commonImgPath),
12.sizeBoxW,
_countItem(videoModel.commentCount?.countStr,
"count_msg_gray.png".communityPath),
12.sizeBoxW,
CollectStatusWrapper(
videoModel: videoModel,
builder: () => _countItem(
videoModel.likeCount?.countStr,
videoModel.vidStatus?.hasLiked == true
? "like_red.png".commonImgPath
: "like_empty_gray.png".communityPath,
),
),
const Spacer(),
if (showTags) _tagView(),
],
),
if (showLine)
Container(
height: 1,
margin: const EdgeInsets.only(top: 18),
color: Colors.white.withValues(alpha: .1),
),
],
),
),
);
}
/// 封面区:无图集的视频帖出单封面 + 播放按钮,单图直接铺,多图走三宫格
Widget _coverView() {
final covers = videoModel.seriesCover ?? [];
final isVideo = videoModel.sourceURL?.isNotEmpty == true;
if (covers.isEmpty && isVideo) {
return SizedBox(
height: 204,
child: Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: videoModel.cover ?? '', borderRadius: 6),
Center(
child: Image.asset("play_icon.png".communityPath,
width: 32, height: 32)),
Positioned(right: 0, top: 0, child: _coinBadge()),
],
),
);
}
if (covers.length == 1) {
return SizedBox(
height: 204,
width: double.infinity,
child: NetworkImageLoader(imageUrl: covers[0], borderRadius: 6),
);
}
//视频帖的封面被上面 remove 掉了,格子数要把它加回来
final coverCount = covers.length + (isVideo ? 1 : 0);
return GridView.builder(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: min(3, coverCount),
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 5,
crossAxisSpacing: 5,
childAspectRatio: 1,
),
itemBuilder: (context, index) {
final isLast = index == 2 || coverCount == index + 1;
//视频帖把视频封面排在最后一格,其余格按图集顺序取
final imgPath = (isLast && isVideo) || index >= covers.length
? (videoModel.cover ?? "")
: covers[index];
//只有首尾两格带外圆角,中间格是直角
final radius = isLast
? const BorderRadius.only(
topRight: Radius.circular(6), bottomRight: Radius.circular(6))
: index == 0
? const BorderRadius.only(
topLeft: Radius.circular(6), bottomLeft: Radius.circular(6))
: BorderRadius.zero;
return Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(imageUrl: imgPath, imgBorderRadius: radius),
if (isLast) Positioned(right: 0, top: 0, child: _coinBadge()),
if (isLast && isVideo)
Center(
child: Image.asset("play_icon.png".communityPath, width: 30))
else if (isLast && coverCount > 3)
Positioned(
bottom: 6,
right: 6,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(30),
),
child: Text(
"+${coverCount - 3}",
style: const TextStyle(
fontSize: 10,
color: Color(0xff141414),
fontWeight: FontWeight.w400,
height: 1.6),
),
),
),
],
);
},
);
}
//金币帖右上角的角标
Widget _coinBadge() {
if (videoModel.permission != 1) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: const BoxDecoration(
borderRadius: BorderRadius.only(
topRight: Radius.circular(6), bottomLeft: Radius.circular(6)),
color: Color(0xffDB361F),
),
child: Image.asset("coin.webp".commonImgPath, width: 14, height: 14),
);
}
Widget _countItem(String? title, String icon) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(icon, width: 20, height: 20),
const SizedBox(width: 4),
Text(title ?? "",
style: const TextStyle(color: Color(0xff999999), fontSize: 12)),
],
);
}
/// 帖子标签
Widget _tagView() {
if (videoModel.tags?.isNotEmpty != true) return const SizedBox();
final tagsBean = videoModel.tags!.first;
tagsBean.newsType = videoModel.newsType;
return GestureDetector(
onTap: () => Get.to(() => CommunityTagDetailPage(model: tagsBean)),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(3),
color: const Color(0x0DFFFFFF)),
child: Text(
'#${tagsBean.name ?? ''}',
style: const TextStyle(color: Color(0x8CFFFFFF), fontSize: 12),
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
class CommunitySortHeader extends StatefulWidget {
final Function(int sort) sortOnTap;
final TabController? controller;
const CommunitySortHeader({
super.key,
required this.sortOnTap,
this.controller,
});
@override
State<CommunitySortHeader> createState() => _CommunitySortHeaderState();
}
class _CommunitySortHeaderState extends State<CommunitySortHeader>
with SingleTickerProviderStateMixin {
final sorts = ['最多收藏', '最新上架', '最新热评'];
late TabController _controller;
/// 外部传进来的归外部释放,只有自己 new 的才由自己 dispose
late final bool _isMine;
@override
void initState() {
super.initState();
//在 initState 定死归属,别用 late 惰性求值——那时读到的可能是 rebuild 后的新 widget
_isMine = widget.controller == null;
_controller =
widget.controller ?? TabController(length: sorts.length, vsync: this);
}
@override
void dispose() {
if (_isMine) _controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.primaryColor,
),
child: TabBar(
isScrollable: true,
labelPadding: EdgeInsets.symmetric(horizontal: 12.w),
padding: EdgeInsets.zero,
onTap: widget.sortOnTap,
tabAlignment: TabAlignment.center,
tabs: sorts
.map((e) => Padding(
padding:
EdgeInsets.symmetric(horizontal: 12.w, vertical: 4.h),
child: Text(e),
))
.toList(),
indicatorWeight: 1,
unselectedLabelStyle: TextStyle(
color: Colors.white.withValues(alpha: .6),
fontSize: 16.sp,
height: 1),
labelStyle: TextStyle(
color: Colors.white,
fontSize: 16.sp,
height: 1,
fontWeight: FontWeight.w600),
// 无指示器:原 ContainerTabIndicator 为全透明,选中态仅靠文字加粗变白
indicator: const BoxDecoration(),
controller: _controller,
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/app_colors.dart';
/// 关注 / 收藏按钮,加载中转菊花
class FollowLoadingButton extends StatelessWidget {
final bool? isFollow;
final bool? isLoading;
final bool? isCollectStyle;
final double radius;
const FollowLoadingButton({
super.key,
this.isFollow,
this.isLoading,
this.isCollectStyle,
this.radius = 6,
});
@override
Widget build(BuildContext context) {
final followed = isFollow == true;
//已关注是灰底灰字 10 号,未关注是红底白字 12 号
final fg = followed ? const Color(0xff989898) : Colors.white;
return Container(
width: 70,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
color: followed ? const Color(0x0DFFFFFF) : AppColors.actionRed,
),
child: isLoading == true
? const CupertinoActivityIndicator(color: Colors.white, radius: 8)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(followed ? Icons.check : Icons.add, size: 14, color: fg),
const SizedBox(width: 4),
Text(
isCollectStyle == true ? "收藏" : (followed ? "已关注" : "关注"),
style: TextStyle(color: fg, fontSize: followed ? 10 : 12),
),
],
),
);
}
}
@@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/extension/extensions.dart';
import 'package:hgdj/hj_utils/screen.dart';
import 'package:hgdj/tools_base/widget/net_image_widget.dart';
import '../../../hj_model/video_model.dart';
import '../../find/pic_collect/pics_detail_page.dart';
/// 图集卡片:封面 + 底部渐变条(浏览数 / 图片数) + 标题
class PicSimpleCell extends StatelessWidget {
final VideoModel? videoModel;
final int textLines;
const PicSimpleCell({super.key, this.videoModel, this.textLines = 2});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () =>
Get.to(PicsDetailPage(id: videoModel?.id), preventDuplicates: false),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
NetworkImageLoader(
imageUrl: videoModel?.cover ?? '',
borderRadius: 8,
alignment: Alignment.topCenter,
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: EdgeInsets.fromLTRB(6, 8, 6, 6),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0),
Colors.black.withValues(alpha: 0.6),
],
),
),
child: Row(
children: [
Image.asset("eye_white.webp".commonImgPath, width: 16),
1.sizeBoxW,
Text(
"${videoModel?.playCount?.countStr}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.white, fontSize: 10),
),
Spacer(),
Container(
padding:
EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Row(
children: [
Image.asset("icon_pic.png".communityPath,
width: 16),
2.sizeBoxW,
Text(
videoModel?.seriesCover?.length.toString() ??
"",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 10,
),
),
],
),
),
],
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
"${videoModel?.title ?? ""}\n",
maxLines: textLines,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
),
],
),
);
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import 'package:hgdj/assets_tool/images.dart';
import 'package:hgdj/hj_model/video_model.dart';
import 'package:hgdj/hj_page/community/widget/follow_loading_button.dart';
import 'package:hgdj/hj_page/home/widget/collect_status_wrapper.dart';
import 'package:hgdj/hj_page/home/widget/user_name_view.dart';
import 'package:hgdj/hj_utils/api_service/mine_service.dart';
import 'package:hgdj/hj_utils/date_time_util.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';
//帖子发布者信息:头像 + 昵称 + 发布时间,详情模式额外带关注按钮
class PostUserInfoView extends StatefulWidget {
final VideoModel model;
final bool isDetailStyle; // 帖子详情模式
final double? avatarSize; // 头像边长,默认 40
const PostUserInfoView({
super.key,
required this.model,
this.avatarSize,
this.isDetailStyle = false,
});
@override
State<PostUserInfoView> createState() => _PostUserInfoViewState();
}
class _PostUserInfoViewState extends State<PostUserInfoView> {
VideoModel get model => widget.model;
Publisher? get publisher => model.publisher;
bool _isLoading = false;
@override
Widget build(BuildContext context) {
//无发布者、黄游帖不展示
if (publisher == null || model.newsType == 'ADULT_GAME') {
return const SizedBox.shrink();
}
final size = widget.avatarSize ?? 40;
return Row(
children: [
//头像
InkWell(
enableFeedback: false,
onTap: () => pushToPersonCenter(publisher?.uid),
child: NetworkImageLoader(
imageUrl: publisher?.portrait ?? "",
width: size,
height: size,
borderRadius: size / 2,
),
),
10.sizeBoxW,
//昵称 + 发布时间
Expanded(child: widget.isDetailStyle ? _nameDetail() : _nameNormal()),
//关注按钮:仅详情模式,套 CollectStatusWrapper 同步全局关注态
if (widget.isDetailStyle)
CollectStatusWrapper(
videoModel: model,
builder: () => InkWell(
enableFeedback: false,
onTap: _toggleFollow,
child: FollowLoadingButton(
isFollow: publisher?.hasFollowed ?? false,
isLoading: _isLoading),
),
),
],
);
}
//关注/取关:先锁定目标值,避免 await 期间 eventBus 改写 hasFollowed 导致取反基准漂移
void _toggleFollow() async {
if (_isLoading) return;
final willFollow = !(publisher?.hasFollowed ?? false);
setState(() => _isLoading = true);
final res = await MineService.getFollow(publisher?.uid, willFollow);
if (res) publisher?.hasFollowed = willFollow;
_isLoading = false;
if (mounted) setState(() {});
}
//列表样式
Widget _nameNormal() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
UserNameView(
nameColor: Colors.white,
name: publisher?.name,
isOfficial: publisher?.officialCert,
),
2.sizeBoxH,
Text(
formatTimeTwo(model.reviewAt),
style: const TextStyle(color: Color(0xff999999), fontSize: 12),
),
],
);
}
//详情样式:昵称后跟官方认证标
Widget _nameDetail() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: UserNameView(
nameColor: Colors.white,
name: publisher?.name,
isOfficial: publisher?.officialCert,
),
),
if (publisher?.officialCert == true) ...[
4.sizeBoxW,
Image.asset("official_icon.png".commonImgPath,
width: 22, height: 22),
],
],
),
2.sizeBoxH,
Text(
formatTimeTwo(model.reviewAt),
style: const TextStyle(color: Color(0x8C999999), fontSize: 12),
),
],
);
}
}
@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:hgdj/hj_utils/widget_util.dart';
import '../../../hj_model/video_model.dart';
/// 帖子标题:置顶 / 精华标签跟标题混排,用 WidgetSpan 才能跟着文字换行
class PostWordRichText extends StatelessWidget {
final VideoModel? videoModel;
const PostWordRichText({super.key, this.videoModel});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
if (videoModel?.isTopping ?? false) _badge('置顶'),
if (videoModel?.chosen ?? false) _badge('精华'),
TextSpan(
text: videoModel?.title ?? '',
style: TextStyle(
color: Colors.white.withValues(alpha: .9),
fontSize: 14,
fontWeight: FontWeight.w500),
),
],
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
);
}
WidgetSpan _badge(String text) => WidgetSpan(
child: Container(
width: 32,
height: 18,
alignment: Alignment.topCenter,
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
color: const Color(0xffF52C56),
borderRadius: BorderRadius.circular(2)),
child:
Text(text, style: textStyle(12, Colors.white, FontWeight.w500)),
),
);
}