初始化

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,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)),
),
);
}