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
@@ -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;
}