1. 将ResolveProblemBox从class_update_widgets.dart抽离到公共组件目录 2. 替换静态渐变背景为CDN资源背景图,适配平板和手机端 3. 将点赞评论按钮的图标从内置Icons替换为本地png资源 4. 更新pubspec.yaml添加新的图片资源路径 5. 删除冗余的空行配置
622 lines
18 KiB
Dart
622 lines
18 KiB
Dart
import 'dart:ui' as ui;
|
||
|
||
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/image_preview_viewer.dart';
|
||
import 'package:new_learn/core/components/resolve_problem_box.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 / AlbumDesc / AlbumGrid / LikeCommentBar / ...
|
||
/// ResolveProblemBox 已抽离到 core/components/resolve_problem_box.dart
|
||
/// 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,
|
||
this.showCommentComposer = true,
|
||
this.hasCardBackground = true,
|
||
this.showLikeCommentBar = true,
|
||
});
|
||
|
||
final UserPostDto post;
|
||
final VoidCallback onLike;
|
||
final ValueChanged<String> onComment;
|
||
|
||
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
|
||
final bool showCommentComposer;
|
||
|
||
/// 是否显示白色卡片背景(列表页 true;详情页与留言区共用一张大卡片 → false)
|
||
final bool hasCardBackground;
|
||
|
||
/// 是否显示点赞/评论标记栏(列表页 true;详情页不需要 → false)
|
||
final bool showLikeCommentBar;
|
||
|
||
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) {
|
||
final card = Stack(
|
||
children: [
|
||
Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 85.h,
|
||
child: IgnorePointer(
|
||
// 卡片顶部右上角光晕,对标 Vue SubjectCard::before 的双层 radial-gradient
|
||
child: CustomPaint(painter: _CardTopGlowPainter()),
|
||
),
|
||
),
|
||
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),
|
||
if (showLikeCommentBar)
|
||
LikeCommentBar(
|
||
post: post,
|
||
onLike: onLike,
|
||
onComment: onComment,
|
||
showComposer: showCommentComposer,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
if (!hasCardBackground) return card;
|
||
return Container(
|
||
margin: EdgeInsets.only(bottom: 10.h),
|
||
clipBehavior: Clip.antiAlias,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(15.r),
|
||
),
|
||
child: card,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 卡片顶部光晕:对标 Vue `SubjectCard::before` 的两层 radial-gradient
|
||
/// (椭圆均以右上角为圆心,CSS 先写的在上层):
|
||
/// radial-gradient(120% 85% at 100% 0%,
|
||
/// rgba(206,233,255,1) 0%, rgba(206,233,255,.26) 10%, rgba(255,255,255,0) 58%)
|
||
/// radial-gradient(75% 55% at 100% 0%,
|
||
/// rgba(196,226,255,.88) 0%, rgba(255,255,255,1) 64%)
|
||
class _CardTopGlowPainter extends CustomPainter {
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
// 先画下层(更小、偏白的光晕),再画上层(更大的浅蓝光晕)
|
||
_paintGlow(
|
||
canvas,
|
||
size,
|
||
rx: size.width * 0.75,
|
||
ry: size.height * 0.55,
|
||
colors: [const Color(0xFFC4E2FF).withValues(alpha: 0.88), Colors.white],
|
||
stops: const [0, 0.64],
|
||
);
|
||
_paintGlow(
|
||
canvas,
|
||
size,
|
||
rx: size.width * 1.20,
|
||
ry: size.height * 0.85,
|
||
colors: [
|
||
const Color(0xFFCEE9FF),
|
||
const Color(0xFFCEE9FF).withValues(alpha: 0.26),
|
||
Colors.white.withValues(alpha: 0.0),
|
||
],
|
||
stops: const [0, 0.10, 0.58],
|
||
);
|
||
}
|
||
|
||
/// 用 canvas.scale 把圆形径向渐变拉成 CSS 的椭圆(圆心 = 右上角)
|
||
void _paintGlow(
|
||
Canvas canvas,
|
||
Size size, {
|
||
required double rx,
|
||
required double ry,
|
||
required List<Color> colors,
|
||
required List<double> stops,
|
||
}) {
|
||
canvas.save();
|
||
canvas.translate(size.width, 0);
|
||
canvas.scale(rx, ry);
|
||
final Paint paint = Paint()
|
||
..shader = ui.Gradient.radial(Offset.zero, 1.0, colors, stops);
|
||
canvas.drawRect(const Rect.fromLTWH(-1, 0, 1, 1), paint);
|
||
canvas.restore();
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|
||
|
||
/// 卡片标题:头像 + 姓名 + 学科标签 + 时间
|
||
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))),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 纯文字笔记(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++)
|
||
GestureDetector(
|
||
onTap: () => _preview(context, urls, i),
|
||
child: _gridItem(displayed[i], i, urls.length),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// 点击相册图:进入整组大图预览,定位到点中的那张
|
||
void _preview(BuildContext context, List<String> allUrls, int index) {
|
||
ImagePreviewViewer.show(
|
||
context,
|
||
items: [
|
||
for (final u in allUrls)
|
||
PreviewImageItem(imageProvider: CachedNetworkImageProvider(u)),
|
||
],
|
||
initialIndex: index,
|
||
);
|
||
}
|
||
|
||
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,
|
||
this.showComposer = true,
|
||
});
|
||
|
||
final UserPostDto post;
|
||
final VoidCallback onLike;
|
||
final ValueChanged<String> onComment;
|
||
|
||
/// 是否显示评论预览 + 输入框(列表页 true;详情页 false,由全量留言列表接管)
|
||
final bool showComposer;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.end,
|
||
children: [
|
||
MetaButton(
|
||
icon: Image.asset(
|
||
post.userLikeStatus
|
||
? 'assets/img/class/like-check-icon.png'
|
||
: 'assets/img/class/like-icon.png',
|
||
width: 13.w,
|
||
height: 13.w,
|
||
),
|
||
count: post.likeCount,
|
||
active: post.userLikeStatus,
|
||
onTap: onLike,
|
||
),
|
||
SizedBox(width: 8.w),
|
||
MetaButton(
|
||
icon: Image.asset(
|
||
'assets/img/class/comment-icon.png',
|
||
width: 13.w,
|
||
height: 13.w,
|
||
),
|
||
count: post.replyCount,
|
||
),
|
||
],
|
||
),
|
||
if (showComposer) ...[
|
||
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 Widget 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,
|
||
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(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Expanded(
|
||
child: Container(
|
||
constraints: BoxConstraints(minHeight: 30.h),
|
||
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 5.h),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border.all(color: const Color(0xFFE3ECFF)),
|
||
borderRadius: BorderRadius.circular(10.r),
|
||
),
|
||
// multiline:键盘右下角为「换行」,可换行写多条评论
|
||
child: TextField(
|
||
controller: _controller,
|
||
style: const TextStyle(fontSize: 12, color: Color(0xFF1D2B47)),
|
||
keyboardType: TextInputType.multiline,
|
||
textInputAction: TextInputAction.newline,
|
||
minLines: 1,
|
||
maxLines: 4,
|
||
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;
|
||
}
|