flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/class_update_view.dart';
import 'package:new_learn/features/class/point_rank_view.dart';
/// ============================================================
/// 【ClassPage】「班级」tab 页 —— 对标 Vue pages/tabbar/class/class.vue
///
/// 一、Vue 源结构(壳 + 两个子页):
/// NavBar(透明、无返回)的 title 插槽 = 顶部 tab「班级动态 / 积分排行」
/// content-body = ClassUpdate(tab0) + PointRank(tab1),v-show 切换
///
/// 二、换算规则(本工程统一):Vue 的 `N rpx` → Flutter 的 `(N/2).w/.h/.sp/.r`
/// 例:间距 20rpx → 10.w;卡片圆角 30rpx → 15.r;字号 28rpx → 14.sp
///
/// 三、对应关系:
/// v-show 切换保留状态 → IndexedStack(保持两子页状态)
/// v-if 懒加载 → _loadedTabs 首次切到才 build
/// switchTab 后 handleShow → TODO(业务逻辑): 切回时刷新子页数据
///
/// 四、底部 Tabbar 由 MainTabPage 承载,此处不重复实现。
/// ============================================================
class ClassPage extends StatefulWidget {
const ClassPage({super.key});
@override
State<ClassPage> createState() => _ClassPageState();
}
class _ClassPageState extends State<ClassPage> {
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _subtitleColor = Color(0xFF495A7A);
static const Color _primary = Color(0xFF3D7DF5);
static const List<String> _tabs = ['班级动态', '积分排行'];
int _currentTab = 0;
/// 懒加载标记(对标 Vue 的 loadedTabs):首次切到才创建对应子页
final List<bool> _loadedTabs = [true, false];
void _switchTab(int index) {
if (_currentTab == index) return;
setState(() {
_loadedTabs[index] = true;
_currentTab = index;
});
// TODO(业务逻辑): 对应 Vue switchTab 的 nextTick 后调用子组件 handleShow 刷新。
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// 页面底色(class-container 的 background-color: #F5F8FA)
const Positioned.fill(child: ColoredBox(color: Color(0xFFF5F8FA))),
// 顶部背景图:background-size 100% 512rpx → 高 256.h
Positioned(
top: 0,
left: 0,
right: 0,
height: 256.h,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-04-03/class-header-background-small.png',
),
fit: BoxFit.fill,
width: double.infinity,
),
),
// 积分排行左上角装饰图(仅 tab1 显示;280rpx → 140.w)
if (_currentTab == 1)
Positioned(
top: 0,
left: 0,
width: 140.w,
height: 140.h,
child: IgnorePointer(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-07-31/img_points1785468064641.png',
),
fit: BoxFit.cover,
),
),
),
SafeArea(
child: Column(
children: [
_buildTabBar(),
Expanded(
child: IndexedStack(
index: _currentTab,
children: [
if (_loadedTabs[0])
const ClassUpdateView()
else
const SizedBox.shrink(),
if (_loadedTabs[1])
const PointRankView()
else
const SizedBox.shrink(),
],
),
),
],
),
),
],
),
);
}
/// 顶部 tab 栏(对标 NavBar title 插槽)
Widget _buildTabBar() {
return SizedBox(
height: 44.h,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (int i = 0; i < _tabs.length; i++) ...[
if (i > 0) SizedBox(width: 20.w), // 两个 tab 各 margin 20rpx
_buildTabItem(i),
],
],
),
);
}
Widget _buildTabItem(int index) {
final active = _currentTab == index;
return GestureDetector(
onTap: () => _switchTab(index),
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: 44.h,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_tabs[index],
style: TextStyle(
fontSize: active ? 16.sp : 14.sp,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
color: active ? _titleColor : _subtitleColor,
),
),
SizedBox(height: 6.h),
// 下划线指示(36rpx × 6rpx)
Container(
width: 18.w,
height: 3.h,
decoration: BoxDecoration(
color: active ? _primary : Colors.transparent,
borderRadius: BorderRadius.circular(2.r),
),
),
],
),
),
);
}
}
+403
View File
@@ -0,0 +1,403 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/models/user_model.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/core/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/features/class/widgets/class_update_widgets.dart';
/// ============================================================
/// 【ClassUpdateView】「班级动态」子页 —— 对标 Vue classUpdate.vue
///
/// 一、Vue 源结构:
/// 班名标题 → OnlineStatus 在线同学条 → 动态列表
/// 列表项 SubjectCard 内按 type 分发:1=攻克任务 / 2、3=文字笔记,
/// 再叠九宫格相册 + LikeAndComment 点赞评论栏。
///
/// 二、数据对接(已接通):
/// - 班名 → currentUser(gradeLevel + graduationYear + className)
/// - 在线同学 → getClassUsers
/// - 动态列表 → getUserClassPost(游标分页)
/// - 点赞/评论 → addUserLike / addUserComment(乐观更新,失败回滚)
///
/// 三、为什么列表不用 CommonPagedList:
/// 点赞/评论需要对单条动态做原位更新(不重建整个列表、不丢滚动位置),
/// CommonPagedList 把 PagingController/列表藏死在内部拿不到,无法改单条;
/// 故这里用 ListView + 手动游标分页,list 由本 State 直接持有,改单条即 setState。
///
/// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r
/// ============================================================
class ClassUpdateView extends ConsumerStatefulWidget {
const ClassUpdateView({super.key});
@override
ConsumerState<ClassUpdateView> createState() => _ClassUpdateViewState();
}
class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
static const Color _titleColor = Color(0xFF1D2B47);
static const Color _primary = Color(0xFF3D7DF5);
static const int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final List<UserPostDto> _posts = [];
int _pageIndex = 1;
int _prevPageId = 0;
bool _loading = true; // 首屏加载中
bool _loadingMore = false;
bool _hasMore = true;
bool _failed = false;
int _onlineCount = 0;
List<String> _avatarUrls = [];
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_loadFirstPage();
_fetchClassUsers();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 80) {
_loadMore();
}
}
/// 班名:gradeLevel + graduationYear + "届" + 班级(1班 → (1)班)
String _buildClassName(CurrentUserExtension? user) {
if (user == null) return '';
final className = user.className.trim().replaceAllMapped(
RegExp(r'\d+'),
(m) => '(${m.group(0)})',
);
return '${user.gradeLevel}${user.graduationYear}届$className';
}
Future<void> _fetchClassUsers() async {
try {
final users = await ClassApi.instance.getClassUsers();
final online = users.where((u) => u.isOnline).toList();
final offline = users.where((u) => !u.isOnline).toList();
final ordered = [...online, ...offline];
if (!mounted) return;
setState(() {
_onlineCount = online.length;
_avatarUrls = ordered.map((u) => _avatarUrl(u.avatar)).toList();
});
} catch (_) {
// 在线状态拉取失败静默处理,不影响动态列表
}
}
Future<void> _loadFirstPage() async {
setState(() {
_loading = true;
_failed = false;
});
try {
final res = await ClassApi.instance.getUserClassPost(
pageIndex: 1,
pageSize: _pageSize,
prevPageId: 0,
);
if (!mounted) return;
setState(() {
_posts
..clear()
..addAll(res.data);
_pageIndex = 1;
_prevPageId = res.data.isNotEmpty ? res.data.last.id : 0;
_hasMore = res.data.length >= _pageSize;
_loading = false;
});
} catch (_) {
if (!mounted) return;
setState(() {
_loading = false;
_failed = true;
});
}
}
Future<void> _loadMore() async {
if (_loadingMore || !_hasMore || _loading || _failed) return;
setState(() => _loadingMore = true);
try {
final res = await ClassApi.instance.getUserClassPost(
pageIndex: _pageIndex + 1,
pageSize: _pageSize,
prevPageId: _prevPageId,
);
if (!mounted) return;
setState(() {
_posts.addAll(res.data);
_pageIndex += 1;
if (res.data.isNotEmpty) _prevPageId = res.data.last.id;
_hasMore = res.data.length >= _pageSize;
_loadingMore = false;
});
} catch (_) {
if (!mounted) return;
setState(() => _loadingMore = false);
}
}
Future<void> _toggleLike(int index) async {
final post = _posts[index];
final liked = !post.userLikeStatus;
final newCount = post.likeCount + (liked ? 1 : -1);
_replaceAt(
index,
post.copyWith(
userLikeStatus: liked,
likeCount: newCount < 0 ? 0 : newCount,
),
);
try {
await ClassApi.instance.addUserLike(
userPostId: post.id,
likeStatus: liked,
);
} catch (_) {
if (!mounted) return;
_replaceAt(index, post);
}
}
Future<void> _submitComment(int index, String content) async {
final text = content.trim();
if (text.isEmpty) return;
final post = _posts[index];
final me = ref.read(currentUserProvider).value;
final common = UserPostCommonDto(
userId: me?.userId ?? 0,
userName: me?.userName ?? '我',
content: text,
);
_replaceAt(
index,
post.copyWith(
replyCount: post.replyCount + 1,
commons: [common, ...post.commons],
),
);
try {
await ClassApi.instance.addUserComment(
userPostId: post.id,
content: text,
);
} catch (_) {
if (!mounted) return;
_replaceAt(index, post);
}
}
void _replaceAt(int index, UserPostDto post) {
setState(() => _posts[index] = post);
}
@override
Widget build(BuildContext context) {
final user = ref.watch(currentUserProvider).value;
final className = _buildClassName(user);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(className),
_buildOnlineStatus(),
Expanded(child: _buildBody()),
],
);
}
/// 列表区:首屏加载 / 失败 / 空态 / 内容(含下拉刷新 + 上拉加载)
Widget _buildBody() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_failed) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'加载失败,请检查网络',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
SizedBox(height: 12.h),
FilledButton(onPressed: _loadFirstPage, child: const Text('重新加载')),
],
),
);
}
if (_posts.isEmpty) {
return Center(
child: Text(
'暂无班级动态',
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
),
);
}
return RefreshIndicator(
onRefresh: _loadFirstPage,
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: 14.w),
itemCount: _posts.length + (_hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index >= _posts.length) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.h),
child: const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
),
);
}
return PostCard(
post: _posts[index],
onLike: () => _toggleLike(index),
onComment: (content) => _submitComment(index, content),
);
},
),
);
}
/// 班名标题(class-title-section)
Widget _buildTitle(String className) {
return Padding(
padding: EdgeInsets.fromLTRB(14.w, 16.h, 14.w, 0),
child: Text(
className,
style: TextStyle(
fontSize: 18.sp,
fontWeight: FontWeight.w600,
color: _titleColor,
height: 50 / 36,
),
),
);
}
/// 在线同学条(OnlineStatus)
Widget _buildOnlineStatus() {
return Container(
height: 58.h,
margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h),
padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h),
decoration: BoxDecoration(
image: DecorationImage(
image: CachedNetworkImageProvider(
ImgUrl.build('/StaticResource/2026-04-06/onlineStatus-bg.png'),
),
fit: BoxFit.fill,
),
),
child: Row(
children: [
_buildOnlineLeft(),
const Spacer(),
_buildAvatarStack(),
_buildAllButton(),
],
),
);
}
Widget _buildOnlineLeft() {
return Row(
children: [
SizedBox(width: 15.w),
const Text('在线同学', style: TextStyle(fontSize: 14, color: _titleColor)),
SizedBox(width: 5.w),
Text(
'$_onlineCount',
style: const TextStyle(
fontSize: 23,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 4.w),
const Text('人', style: TextStyle(fontSize: 14, color: _titleColor)),
],
);
}
/// 头像组:在线在前、离线在后,最多展示 6 个
Widget _buildAvatarStack() {
final urls = _avatarUrls.take(6).toList();
if (urls.isEmpty) return const SizedBox.shrink();
final size = 25.w;
final overlap = size * 0.94;
return SizedBox(
width: size + (urls.length - 1) * overlap,
height: size,
child: Stack(
children: [
for (int i = 0; i < urls.length; i++)
Positioned(
left: i * overlap,
child: UserCircleAvatar(url: urls[i], radius: size / 2),
),
],
),
);
}
/// 右侧「全部」按钮
Widget _buildAllButton() {
return Container(
width: 62.w,
height: 34.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
),
borderRadius: BorderRadius.only(
topRight: Radius.circular(15.r),
bottomLeft: Radius.circular(15.r),
bottomRight: Radius.circular(20.r),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('全部', style: TextStyle(fontSize: 14, color: Colors.white)),
SizedBox(width: 2.w),
Icon(Icons.chevron_right, size: 14, color: Colors.white),
],
),
);
}
/// 头像兜底:空 avatar 用伪造头像
static String _avatarUrl(String? raw) {
final url = ImgUrl.build(raw);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
}
+432
View File
@@ -0,0 +1,432 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/api.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/common_paged_list.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/providers/user_provider.dart';
import 'package:new_learn/core/utils/img_url.dart';
import 'package:new_learn/features/class/widgets/point_rank_widgets.dart';
/// ============================================================
/// 【PointRankView】「积分排行」子页 —— 对标 Vue pointRank.vue
///
/// 一、Vue 源结构:
/// 海报图 → 分类(本班级/同年级/同云校) → 子 tab(总榜/本周/上周/今日/昨天)
/// → 规则排序栏 → 排行列表(RankItem) → 底部「我的排名」卡 → 积分规则弹窗。
///
/// 二、数据对接(已接通):
/// - 人数统计 → getUserCount
/// - 榜单列表 → getUserRankPagedList(游标分页,CommonPagedList)
/// - 我的排名 → 同一接口返回的 myRank
/// - 分类/子tab/排序切换 → 通过 ValueKey 重建列表,自动重新拉第一页
///
/// 三、组件已抽离到 widgets/point_rank_widgets.dart(RankRow/EnergyRuleModal/
/// RuleSection/TriangleClipper),本文件只保留页面状态与数据逻辑。
///
/// 四、换算规则:rpx → /2 后接 .w/.h/.sp/.r
/// ============================================================
class PointRankView extends ConsumerStatefulWidget {
const PointRankView({super.key});
@override
ConsumerState<PointRankView> createState() => _PointRankViewState();
}
class _PointRankViewState extends ConsumerState<PointRankView> {
static const Color _primary = Color(0xFF3D7DF5);
static const int _pageSize = 20;
int _activeCategory = 0; // 0 本班级 / 1 同年级 / 2 同云校
int _activeSubTab = 0; // 总榜/本周/上周/今日/昨天
bool _sortDesc = true; // true 积分倒序 / false 正序
bool _showRule = false;
int? _classCount;
int? _gradeCount;
int? _cloudCount;
UserRank? _myRank;
int _prevRankPageId = 0;
static const List<String> _categories = ['本班级', '同年级', '同云校'];
static const List<String> _subTabs = ['总榜', '本周', '上周', '今日', '昨天'];
@override
void initState() {
super.initState();
_fetchUserCount();
}
Future<void> _fetchUserCount() async {
try {
final res = await UserRankApi.instance.getUserCount();
if (!mounted) return;
setState(() {
_classCount = res.classUserCount;
_gradeCount = res.gradeUserCount;
_cloudCount = res.cloudSchoolUserCount;
});
} catch (_) {
// 人数统计失败静默处理
}
}
Future<PagingResult<UserRank>> _loadRank(int page) async {
final prev = page == 1 ? 0 : _prevRankPageId;
final res = await UserRankApi.instance.getRankPage(
pageIndex: page,
pageSize: _pageSize,
prevPageId: prev,
userRankType: _activeSubTab,
userClass: _activeCategory,
desc: _sortDesc,
);
if (res.userRanks.isNotEmpty) {
_prevRankPageId = res.userRanks.last.userId;
}
if (page == 1 && mounted) {
setState(() => _myRank = res.myRank);
}
return PagingResult<UserRank>(
data: res.userRanks,
hasNext: res.userRanks.length >= _pageSize,
);
}
String _countText(int? count) => count == null ? '--' : '$count';
String _formatPoints(double p) {
if (p == p.roundToDouble()) return p.toInt().toString();
return p.toString();
}
@override
Widget build(BuildContext context) {
final currentUserId = ref.watch(currentUserProvider).value?.userId ?? 0;
return Stack(
children: [
Column(
children: [
_buildPoster(),
_buildCategoryBar(),
Expanded(child: _buildRankListContainer(currentUserId)),
],
),
if (_myRank != null)
Positioned(left: 0, right: 0, bottom: 0, child: _buildMyRankCard()),
if (_showRule)
EnergyRuleModal(onClose: () => setState(() => _showRule = false)),
],
);
}
Widget _buildPoster() {
return Container(
margin: EdgeInsets.only(top: 22.h),
alignment: Alignment.center,
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-07-29/pointRankPoster1785291500821.png',
),
width: 170.w,
height: 30.h,
fit: BoxFit.fill,
errorWidget: (_, _, _) => const SizedBox(width: 170, height: 30),
),
);
}
Widget _buildCategoryBar() {
return Container(
margin: EdgeInsets.only(top: 20.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < _categories.length; i++) _buildCategoryItem(i),
],
),
);
}
Widget _buildCategoryItem(int index) {
final active = _activeCategory == index;
final countText = switch (index) {
0 => _countText(_classCount),
1 => _countText(_gradeCount),
_ => _countText(_cloudCount),
};
return GestureDetector(
onTap: () {
if (_activeCategory == index) return;
setState(() => _activeCategory = index);
},
child: Column(
children: [
Container(
width: 105.w,
height: 45.h,
decoration: BoxDecoration(
color: active
? Colors.white
: Colors.white.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8.r),
border: active ? null : Border.all(color: Colors.white, width: 1),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_categories[index],
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: active ? _primary : const Color(0xFF687FAD),
),
),
SizedBox(height: 4.h),
Text(
'$countText人',
style: TextStyle(
fontSize: 11,
color: active ? _primary : const Color(0xFF687FAD),
),
),
],
),
),
if (active) _buildTriangle(),
],
),
);
}
Widget _buildTriangle() {
return ClipPath(
clipper: TriangleClipper(),
child: Container(width: 14.w, height: 8.h, color: Colors.white),
);
}
Widget _buildRankListContainer(int currentUserId) {
return Container(
margin: EdgeInsets.only(top: 8.h),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
_buildSubTabList(),
_buildRuleSortBar(),
Expanded(
child: CommonPagedList<UserRank>(
// 分类/子tab/排序变化 → 重建列表,自动重拉第一页
key: ValueKey('rank-$_activeCategory-$_activeSubTab-$_sortDesc'),
load: _loadRank,
padding: EdgeInsets.fromLTRB(14.w, 13.h, 14.w, 121.h),
emptyWidget: Center(
child: Text(
'暂无积分排行',
style: TextStyle(
fontSize: 14.sp,
color: const Color(0xFF999999),
),
),
),
buildItem: (context, item, index) {
final isSelf = item.userId != 0 && item.userId == currentUserId;
return RankRow(
item: item,
category: _activeCategory,
isSelf: isSelf,
);
},
),
),
],
),
);
}
Widget _buildSubTabList() {
return Container(
margin: EdgeInsets.only(top: 12.h),
padding: EdgeInsets.symmetric(horizontal: 25.w),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
for (int i = 0; i < _subTabs.length; i++) _buildSubTabItem(i),
],
),
);
}
Widget _buildSubTabItem(int index) {
final active = _activeSubTab == index;
return GestureDetector(
onTap: () {
if (_activeSubTab == index) return;
setState(() => _activeSubTab = index);
},
child: Container(
width: 52.w,
height: 32.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? _primary : const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
_subTabs[index],
style: TextStyle(
fontSize: 14,
fontWeight: active ? FontWeight.w500 : FontWeight.w400,
color: active ? Colors.white : const Color(0xFF687FAD),
),
),
),
);
}
Widget _buildRuleSortBar() {
return Container(
margin: EdgeInsets.only(top: 21.h),
padding: EdgeInsets.symmetric(horizontal: 14.w),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _showRule = true),
child: Row(
children: [
const Flexible(
child: Text(
'积分数值越高排名越靠前,同分并列同名次',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)),
),
),
SizedBox(width: 4.w),
const Icon(
Icons.info_outline,
size: 13,
color: Color(0xFF687FAD),
),
],
),
),
),
GestureDetector(
onTap: () => setState(() => _sortDesc = !_sortDesc),
child: Row(
children: [
Text(
_sortDesc ? '积分倒序' : '积分正序',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 5.w),
Icon(
_sortDesc ? Icons.arrow_downward : Icons.arrow_upward,
size: 14,
color: _primary,
),
],
),
),
],
),
);
}
Widget _buildMyRankCard() {
final rank = _myRank!;
return Container(
height: 70.h,
padding: EdgeInsets.fromLTRB(10.w, 0, 14.w, 0),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFD6EEFF), Color(0xFFFFFFFF)],
),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
children: [
SizedBox(
width: 50.w,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${rank.rank}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
const Text(
'我的排名',
style: TextStyle(fontSize: 9, color: Color(0xFF687FAD)),
),
],
),
),
UserCircleAvatar(url: _avatarUrl(rank.userAvatar), radius: 22.w),
SizedBox(width: 10.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
rank.userName.isEmpty ? '我' : rank.userName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
if (_activeCategory != 0 && rank.userClassName.isNotEmpty) ...[
SizedBox(height: 3.h),
Text(
rank.userClassName,
style: TextStyle(fontSize: 12, color: Color(0xFF687FAD)),
),
],
],
),
),
Text(
_formatPoints(rank.points),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: _primary,
),
),
SizedBox(width: 25.w),
],
),
);
}
static String _avatarUrl(String? raw) {
final url = ImgUrl.build(raw);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png')
: url;
}
}
@@ -0,0 +1,578 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【班级动态组件】由 class_update_view.dart 抽离的展示组件
///
/// PostCard(动态卡片,按 type 分发内容 + 相册 + 点赞评论栏)
/// CardHeader / SubjectTag / ResolveProblemBox / AlbumDesc / AlbumGrid
/// LikeCommentBar / MetaButton / CommentPreview / CommentInput
///
/// 原为 class_update_view.dart 内的私有组件,重命名去下划线后放到本文件。
/// 头像复用 core/components/user_circle_avatar.dart。
/// ============================================================
/// 动态卡片(对标 SubjectCard + ResolveProblem/文字 + Album + LikeAndComment)
class PostCard extends StatelessWidget {
const PostCard({
super.key,
required this.post,
required this.onLike,
required this.onComment,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
String get _avatar {
final url = ImgUrl.build(post.avatar);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-03/fake-avatar.png')
: url;
}
String get _name => post.userName.isEmpty ? '默认姓名' : post.userName;
String get _subject => post.subjectName.isEmpty ? '数学' : post.subjectName;
String get _time {
final t = post.createTime;
if (t.isEmpty) return '';
final s = t.replaceFirst('T', ' ').replaceFirst('Z', '');
return s.length > 16 ? s.substring(0, 16) : s;
}
@override
Widget build(BuildContext context) {
return Container(
margin: EdgeInsets.only(bottom: 10.h),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15.r),
),
child: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
height: 85.h,
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topRight,
end: Alignment.bottomLeft,
colors: [
const Color(0xFFCEE9FF).withValues(alpha: 0.9),
const Color(0xFFF6FBFF).withValues(alpha: 0.0),
],
),
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 21.h),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CardHeader(
avatar: _avatar,
name: _name,
subject: _subject,
time: _time,
),
if (post.type == 1) ...[
SizedBox(height: 5.h),
ResolveProblemBox(desc: post.content),
],
if (post.type == 2 || post.type == 3) ...[
SizedBox(height: 15.h),
AlbumDesc(text: post.content),
],
if (post.urls.isNotEmpty) ...[
SizedBox(height: 15.h),
AlbumGrid(urls: post.urls.map(ImgUrl.build).toList()),
],
SizedBox(height: 10.h),
LikeCommentBar(
post: post,
onLike: onLike,
onComment: onComment,
),
],
),
),
],
),
);
}
}
/// 卡片标题:头像 + 姓名 + 学科标签 + 时间
class CardHeader extends StatelessWidget {
const CardHeader({
super.key,
required this.avatar,
required this.name,
required this.subject,
required this.time,
});
final String avatar;
final String name;
final String subject;
final String time;
@override
Widget build(BuildContext context) {
return Row(
children: [
UserCircleAvatar(url: avatar, radius: 20.w),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
),
),
),
SizedBox(width: 8.w),
SubjectTag(subject: subject),
],
),
SizedBox(height: 2.h),
Text(
time,
style: const TextStyle(fontSize: 12, color: Color(0xFF495A7A)),
),
],
),
),
],
);
}
}
/// 学科标签(对标 SubjectTag)
class SubjectTag extends StatelessWidget {
const SubjectTag({super.key, required this.subject});
final String subject;
static const Map<String, _SubjectColor> _map = {
'数学': _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF),
'物理': _SubjectColor(0xFFA45CFF, 0xFFF5ECFF),
'英语': _SubjectColor(0xFFE5A301, 0xFFFFF6E3),
'化学': _SubjectColor(0xFF2DB985, 0xFFE5FAF3),
};
@override
Widget build(BuildContext context) {
final t = subject.trim();
if (t.isEmpty) return const SizedBox.shrink();
final c = _map[t] ?? const _SubjectColor(0xFF3D7DF5, 0xFFE5F0FF);
return Container(
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 2.h),
decoration: BoxDecoration(
color: Color(c.bg),
borderRadius: BorderRadius.circular(6.r),
),
child: Text(t, style: TextStyle(fontSize: 11, color: Color(c.fg))),
);
}
}
/// 攻克任务卡(对标 ResolveProblem)
class ResolveProblemBox extends StatelessWidget {
const ResolveProblemBox({super.key, required this.desc});
final String desc;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(19.w, 25.h, 19.w, 11.h),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFEAF4FF), Color(0xFFFFFFFF)],
),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'刚刚攻克了',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 3.h),
Text.rich(
TextSpan(
children: [
TextSpan(
text: '「$desc」',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF1D2B47),
),
),
const TextSpan(
text: ' 的所有高难考点!',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Color(0xFF45597E),
),
),
],
),
),
],
),
);
}
}
/// 纯文字笔记(album-desc,最多 2 行)
class AlbumDesc extends StatelessWidget {
const AlbumDesc({super.key, required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w400,
color: Color(0xFF495A7A),
height: 42 / 30,
),
);
}
}
/// 九宫格相册(最多展示 9 张,第 9 张叠加 +N)
class AlbumGrid extends StatelessWidget {
const AlbumGrid({super.key, required this.urls});
final List<String> urls;
@override
Widget build(BuildContext context) {
final displayed = urls.take(9).toList();
return Wrap(
spacing: 9.w,
runSpacing: 9.h,
children: [
for (int i = 0; i < displayed.length; i++)
_gridItem(displayed[i], i, urls.length),
],
);
}
Widget _gridItem(String url, int index, int total) {
final more = total > 9 && index == 8 ? total - 9 : 0;
return ClipRRect(
borderRadius: BorderRadius.circular(8.r),
child: SizedBox(
width: 99.w,
height: 99.h,
child: Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: url,
fit: BoxFit.cover,
errorWidget: (_, _, _) =>
const ColoredBox(color: Color(0xFFF0F2F5)),
),
if (more > 0)
ColoredBox(
color: const Color(0x66000000),
child: Center(
child: Text(
'+$more',
style: const TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
),
),
],
),
),
);
}
}
/// 点赞 / 评论栏(对标 LikeAndComment)
class LikeCommentBar extends StatelessWidget {
const LikeCommentBar({
super.key,
required this.post,
required this.onLike,
required this.onComment,
});
final UserPostDto post;
final VoidCallback onLike;
final ValueChanged<String> onComment;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MetaButton(
icon: post.userLikeStatus
? Icons.favorite
: Icons.favorite_border,
count: post.likeCount,
active: post.userLikeStatus,
onTap: onLike,
),
SizedBox(width: 8.w),
MetaButton(icon: Icons.chat_bubble_outline, count: post.replyCount),
],
),
if (post.commons.isNotEmpty) ...[
SizedBox(height: 12.h),
CommentPreview(post: post),
],
SizedBox(height: 12.h),
CommentInput(onSubmit: onComment),
],
);
}
}
class MetaButton extends StatelessWidget {
const MetaButton({
super.key,
required this.icon,
required this.count,
this.active = false,
this.onTap,
});
final IconData icon;
final int count;
final bool active;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final color = active ? const Color(0xFF3D7DF5) : const Color(0xFFB4C2E0);
return GestureDetector(
onTap: onTap,
child: Container(
width: 51.w,
height: 22.h,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.r),
border: Border.all(
color: const Color(0xFFB3C2E0).withValues(alpha: 0.3),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 13.w, color: color),
SizedBox(width: 4.w),
Text('$count', style: TextStyle(fontSize: 13, color: color)),
],
),
),
);
}
}
/// 评论预览框(前 3 条 + 共 N 条)
class CommentPreview extends StatelessWidget {
const CommentPreview({super.key, required this.post});
final UserPostDto post;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: EdgeInsets.symmetric(horizontal: 19.w, vertical: 12.h),
decoration: BoxDecoration(
color: const Color(0xFFF5F8FA),
borderRadius: BorderRadius.circular(10.r),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final c in post.commons.take(3))
Padding(
padding: EdgeInsets.only(bottom: 8.h),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${c.userName}:',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: Color(0xFF1D2B47),
),
),
TextSpan(
text: c.content,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF495A7A),
),
),
],
),
),
),
if (post.replyCount > 3)
Row(
children: [
Text(
'共${post.replyCount}条评论',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF687FAD),
),
),
const Icon(
Icons.chevron_right,
size: 14,
color: Color(0xFF687FAD),
),
],
),
],
),
);
}
}
/// 评论输入行
class CommentInput extends StatefulWidget {
const CommentInput({super.key, required this.onSubmit});
final ValueChanged<String> onSubmit;
@override
State<CommentInput> createState() => _CommentInputState();
}
class _CommentInputState extends State<CommentInput> {
final TextEditingController _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _send() {
final text = _controller.text.trim();
if (text.isEmpty) return;
widget.onSubmit(text);
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Container(
height: 30.h,
padding: EdgeInsets.symmetric(horizontal: 12.w),
alignment: Alignment.centerLeft,
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: const Color(0xFFE3ECFF)),
borderRadius: BorderRadius.circular(10.r),
),
child: TextField(
controller: _controller,
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
hintText: '写下你的评论',
hintStyle: TextStyle(fontSize: 12, color: Color(0xFFB4C2E0)),
),
onSubmitted: (_) => _send(),
),
),
),
SizedBox(width: 10.w),
GestureDetector(
onTap: _send,
child: Container(
width: 51.w,
height: 30.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
),
borderRadius: BorderRadius.circular(6.r),
),
child: const Text(
'发送',
style: TextStyle(fontSize: 13, color: Colors.white),
),
),
),
],
);
}
}
class _SubjectColor {
const _SubjectColor(this.fg, this.bg);
final int fg;
final int bg;
}
@@ -0,0 +1,327 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:new_learn/core/api/class/model.dart';
import 'package:new_learn/core/components/user_circle_avatar.dart';
import 'package:new_learn/core/utils/img_url.dart';
/// ============================================================
/// 【积分排行组件】由 point_rank_view.dart 抽离的展示组件
///
/// RankRow(排行榜单行)/ EnergyRuleModal(积分规则弹窗)/
/// RuleSection(规则段落)/ TriangleClipper(白三角裁剪)
/// 头像复用 core/components/user_circle_avatar.dart。
/// ============================================================
/// 排行榜单行(对标 rankItem.vue)
class RankRow extends StatelessWidget {
const RankRow({
super.key,
required this.item,
required this.category,
required this.isSelf,
});
final UserRank item;
final int category;
final bool isSelf;
bool get _isTop => item.rank >= 1 && item.rank <= 3;
String _formatPoints(double p) {
if (p == p.roundToDouble()) return p.toInt().toString();
return p.toString();
}
String get _avatar {
final url = ImgUrl.build(item.userAvatar);
return url.isEmpty
? ImgUrl.build('/StaticResource/2026-04-11/default_avatar.png')
: url;
}
@override
Widget build(BuildContext context) {
final subInfo = (category == 1 || category == 2) ? item.userClassName : '';
return Container(
height: 60.h,
margin: EdgeInsets.only(bottom: _isTop ? 10.h : 0),
padding: EdgeInsets.symmetric(horizontal: 10.w),
decoration: _isTop
? BoxDecoration(
gradient: LinearGradient(colors: _topColors(item.rank)),
borderRadius: BorderRadius.circular(10.r),
)
: null,
child: Row(
children: [
if (_isTop)
CachedNetworkImage(
imageUrl: _medalUrl(item.rank),
width: 39.w,
height: 60.h,
fit: BoxFit.fill,
errorWidget: (_, _, _) => const SizedBox(width: 39, height: 60),
)
else
SizedBox(
width: 36.w,
child: Center(
child: Text(
'${item.rank}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFB4C2E0),
),
),
),
),
SizedBox(width: 8.w),
UserCircleAvatar(url: _avatar, radius: 22.w),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Flexible(
child: Text(
item.userName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF1D2B47),
),
),
),
if (isSelf) ...[
SizedBox(width: 8.w),
Container(
width: 16.w,
height: 16.h,
alignment: Alignment.center,
decoration: BoxDecoration(
color: const Color(0xFFB4C2E0),
borderRadius: BorderRadius.circular(6.r),
),
child: const Text(
'我',
style: TextStyle(fontSize: 10, color: Colors.white),
),
),
],
],
),
if (subInfo.isNotEmpty) ...[
SizedBox(height: 4.h),
Text(
subInfo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF687FAD),
),
),
],
],
),
),
Text(
_formatPoints(item.points),
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Color(0xFF3D7DF5),
),
),
SizedBox(width: 20.w),
],
),
);
}
List<Color> _topColors(int rank) {
switch (rank) {
case 1:
return [const Color(0xFFFCF4DE), Colors.white];
case 2:
return [const Color(0xFFE8F0F7), Colors.white];
default:
return [const Color(0xFFFAEAE6), Colors.white];
}
}
String _medalUrl(int rank) {
final name = switch (rank) {
1 => 'ranking1Position1785292048368.png',
2 => 'ranking2Position1785292048369.png',
_ => 'ranking3Position1785292048369.png',
};
return ImgUrl.build('/StaticResource/2026-07-29/$name');
}
}
/// 积分规则弹窗(对标 EnergyRuleModal)
class EnergyRuleModal extends StatelessWidget {
const EnergyRuleModal({super.key, required this.onClose});
final VoidCallback onClose;
static const List<String> _rule1 = [
'1.每日登录+1分每日提交作业+3分',
'2.连续打卡奖励连续打卡3天+5分;连续打卡7天+12分;连续打卡14天+25分;连续打卡30天+50分',
'3.答题正确率奖励 正确率≥80%额外+2分每攻克一个知识点+5分',
'4.每日上传学习图片 图片>1张+2分',
'5.笔记被设为精选+10分(每日可叠加)',
'6.每日点赞(非同一个人同一个笔记/攻克知识点)单条+0.5分(单日上限5分)每日评论(同上)单条+1分(单日上限5分)',
];
static const List<String> _rule2 = [
'1.初始等级:0 级,0 积分',
'2.0 升 1 级:累计积攒 100 积分',
'3.1 升 2 级:再积攒 100 积分(累计 200)',
'4.2 升 3 级:再积攒 100 积分(累计 300)',
'5.3 升 4 级:再积攒 100 积分(累计 400)',
'6.4 升 5 级:再积攒 100 积分(累计 500)',
'7.5 升 6 级:再积攒 100 积分(累计 600)',
'...',
];
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned.fill(child: ColoredBox(color: const Color(0x99000000))),
Center(
child: SizedBox(
width: 301.5.w,
height: 530.5.h,
child: Stack(
children: [
Positioned.fill(
child: CachedNetworkImage(
imageUrl: ImgUrl.build(
'/StaticResource/2026-06-26/pointsRule_bg1782467735027.png',
),
fit: BoxFit.fill,
errorWidget: (_, _, _) =>
const ColoredBox(color: Colors.white),
),
),
Positioned(
left: 25.w,
right: 25.w,
top: 198.h,
bottom: 35.h,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RuleSection(title: '一、积分获取规则', items: _rule1),
SizedBox(height: 10.h),
RuleSection(title: '二、等级晋升规则', items: _rule2),
],
),
),
),
],
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: 90.h),
child: GestureDetector(
onTap: onClose,
child: Container(
width: 150.w,
height: 49.h,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFF8AD1FD),
Color(0xFF3C84FF),
Color(0xFF3D7DF5),
],
),
borderRadius: BorderRadius.circular(49.h),
),
child: const Text(
'我知道了',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
),
],
);
}
}
class RuleSection extends StatelessWidget {
const RuleSection({super.key, required this.title, required this.items});
final String title;
final List<String> items;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
),
),
SizedBox(height: 8.h),
for (final item in items)
Padding(
padding: EdgeInsets.only(bottom: 10.h),
child: Text(
item,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2B47),
height: 36 / 24,
),
),
),
],
);
}
}
/// 白色向下小三角
class TriangleClipper extends CustomClipper<Path> {
@override
Path getClip(Size size) {
return Path()
..moveTo(0, 0)
..lineTo(size.width, 0)
..lineTo(size.width / 2, size.height)
..close();
}
@override
bool shouldReclip(covariant CustomClipper<Path> oldClipper) => false;
}