feat: 新增全局确认弹窗、多页面UI优化与网络日志开关
1. 新增全局二次确认弹窗组件confirm_box,支持自定义参数并对标小程序实现
2. 优化班级板块多处UI:
- 修复point_rank_view中Web端字体溢出问题,使用FittedBox自适应缩放
- 重构class_update_widgets的PostCard,新增卡片背景控制开关并替换光晕实现
- 优化class_update_view的在线同学栏与头像组布局,支持动态计算展示数量
- 调整my_update_detail_page的评论区结构,修复评论高亮与删除浮层定位问题
3. 为dio_client新增网络日志总开关,可按需开启请求响应打印
4. 修复多处代码格式与注释细节问题
This commit is contained in:
@@ -0,0 +1,184 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
|
||||||
|
import '../utils/img_url.dart';
|
||||||
|
|
||||||
|
/// ============================================================
|
||||||
|
/// 【ConfirmBox】全局二次确认弹窗 —— 对标小程序 components/ConfirmBox
|
||||||
|
///
|
||||||
|
/// 一、与 Vue3 版的架构差异:
|
||||||
|
/// 小程序不支持动态插入 DOM,所以 Vue 版需要每个页面手动放置
|
||||||
|
/// `<ConfirmBox />`,并在 service.js 里按路由注册 controller。
|
||||||
|
/// Flutter 的 showDialog 可以从任意 context 直接弹出(等价于 Web
|
||||||
|
/// 动态挂载),因此这里无需注册机制,单文件即可全局使用。
|
||||||
|
///
|
||||||
|
/// 二、用法(对标 Vue 的 `await proxy.$confirm({...})`):
|
||||||
|
/// final ok = await showConfirmBox(
|
||||||
|
/// context,
|
||||||
|
/// title: '提示',
|
||||||
|
/// content: '确定要删除吗?',
|
||||||
|
/// );
|
||||||
|
/// 点"确定"返回 true;点"取消"、点遮罩、按返回键返回 false。
|
||||||
|
/// ============================================================
|
||||||
|
Future<bool> showConfirmBox(
|
||||||
|
BuildContext context, {
|
||||||
|
String title = '提示',
|
||||||
|
String content = '',
|
||||||
|
String cancelText = '取消',
|
||||||
|
String confirmText = '确定',
|
||||||
|
bool showCancel = true,
|
||||||
|
double? minWidth, // 弹窗最小宽度(逻辑像素,调用方自行 .w 换算)
|
||||||
|
}) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true, // 点遮罩关闭 → false(对标 onPopupClose)
|
||||||
|
barrierColor: Colors.black54,
|
||||||
|
builder: (_) => ConfirmBox(
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
cancelText: cancelText,
|
||||||
|
confirmText: confirmText,
|
||||||
|
showCancel: showCancel,
|
||||||
|
minWidth: minWidth,
|
||||||
|
),
|
||||||
|
).then((result) => result ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConfirmBox extends StatelessWidget {
|
||||||
|
const ConfirmBox({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
required this.cancelText,
|
||||||
|
required this.confirmText,
|
||||||
|
required this.showCancel,
|
||||||
|
this.minWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
final String cancelText;
|
||||||
|
final String confirmText;
|
||||||
|
final bool showCancel;
|
||||||
|
final double? minWidth;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
child: Container(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 30.w, horizontal: 15.w), // 60rpx 30rpx
|
||||||
|
constraints:
|
||||||
|
minWidth != null ? BoxConstraints(minWidth: minWidth!) : null,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20.w), // 40rpx
|
||||||
|
image: DecorationImage(
|
||||||
|
image: NetworkImage(
|
||||||
|
ImgUrl.build('/StaticResource/2026-03-31/header-bac.png')),
|
||||||
|
fit: BoxFit.fill, // background-size: 100% 100%
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (title.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16.sp, // 32rpx
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (content.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 18.w), // 36rpx
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: 263.w), // max-width 526rpx
|
||||||
|
child: Text(
|
||||||
|
content,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp, // 28rpx
|
||||||
|
color: const Color(0xFF1D2B47),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 23.w), // 46rpx
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (showCancel) ...[
|
||||||
|
_buildButton(
|
||||||
|
text: cancelText,
|
||||||
|
backgroundColor: const Color(0xFFFDFFFF),
|
||||||
|
textColor: const Color(0xFF6195F6),
|
||||||
|
onTap: () => Navigator.of(context).pop(false),
|
||||||
|
),
|
||||||
|
SizedBox(width: 10.w), // 20rpx
|
||||||
|
],
|
||||||
|
_buildButton(
|
||||||
|
text: confirmText,
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
// CSS: linear-gradient(360deg, #8AD1FD -11.22%, #3C84FF 60.2%, #3D7DF5 100%)
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
colors: [
|
||||||
|
Color(0xFF8AD1FD),
|
||||||
|
Color(0xFF3C84FF),
|
||||||
|
Color(0xFF3D7DF5),
|
||||||
|
],
|
||||||
|
stops: [0, 0.602, 1],
|
||||||
|
),
|
||||||
|
textColor: Colors.white,
|
||||||
|
onTap: () => Navigator.of(context).pop(true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 胶囊按钮(对标 default-button5 渐变确定键 / default-button6 白底取消键)
|
||||||
|
Widget _buildButton({
|
||||||
|
required String text,
|
||||||
|
required Color textColor,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
Color? backgroundColor,
|
||||||
|
Gradient? gradient,
|
||||||
|
}) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 46.w, // 92rpx
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||||
|
constraints: BoxConstraints(minWidth: 135.w), // min-width 270rpx
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
gradient: gradient,
|
||||||
|
borderRadius: BorderRadius.circular(100), // border-radius: 100px
|
||||||
|
boxShadow: gradient != null
|
||||||
|
? [
|
||||||
|
// CSS: 0px 15px 11.3px rgba(61,125,245,0.08)
|
||||||
|
// (inset 内发光 Flutter 不支持,省略)
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0x143D7DF5),
|
||||||
|
offset: Offset(0, 15.w),
|
||||||
|
blurRadius: 11.w,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(fontSize: 16.sp, color: textColor), // 32rpx
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,9 @@ import 'package:talker_dio_logger/talker_dio_logger.dart';
|
|||||||
/// 5. Android 真机请求网络必须加 INTERNET 权限(本工程 Manifest 已加)。
|
/// 5. Android 真机请求网络必须加 INTERNET 权限(本工程 Manifest 已加)。
|
||||||
/// ============================================================
|
/// ============================================================
|
||||||
class DioClient {
|
class DioClient {
|
||||||
|
/// 网络日志总开关:true = 控制台打印请求/响应数据,false = 静默
|
||||||
|
static const bool enableNetworkLog = false;
|
||||||
|
|
||||||
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
|
// ---------- 1. 单例:整个 App 只有一个 Dio ----------
|
||||||
DioClient._()
|
DioClient._()
|
||||||
: _dio = Dio(
|
: _dio = Dio(
|
||||||
@@ -46,22 +49,20 @@ class DioClient {
|
|||||||
connectTimeout: const Duration(seconds: 10), // 连接超时
|
connectTimeout: const Duration(seconds: 10), // 连接超时
|
||||||
sendTimeout: const Duration(seconds: 10), // 发送超时
|
sendTimeout: const Duration(seconds: 10), // 发送超时
|
||||||
receiveTimeout: const Duration(seconds: 10), // 接收超时
|
receiveTimeout: const Duration(seconds: 10), // 接收超时
|
||||||
headers: {
|
headers: {'Content-Type': 'application/json; charset=utf-8'},
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
if (enableNetworkLog) {
|
||||||
_dio.interceptors.add(
|
_dio.interceptors.add(
|
||||||
TalkerDioLogger(
|
TalkerDioLogger(
|
||||||
talker: talker,
|
talker: talker,
|
||||||
settings: const TalkerDioLoggerSettings(
|
settings: const TalkerDioLoggerSettings(
|
||||||
printResponseTime: true,
|
printResponseTime: true,
|
||||||
hiddenHeaders: <String>{
|
hiddenHeaders: <String>{'authorization'},
|
||||||
'authorization',
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
_dio.interceptors.add(_buildInterceptors());
|
_dio.interceptors.add(_buildInterceptors());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,21 +90,26 @@ class DioClient {
|
|||||||
onRequest: (options, handler) {
|
onRequest: (options, handler) {
|
||||||
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
|
// OSS 直传会带自己的 authorization 头(OSS4-HMAC-SHA256 签名),
|
||||||
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
|
// 任何形式存在 authorization 都不再加 Bearer,否则会覆盖 OSS 签名
|
||||||
final hasAuth = options.headers['Authorization'] != null ||
|
final hasAuth =
|
||||||
|
options.headers['Authorization'] != null ||
|
||||||
options.headers['authorization'] != null;
|
options.headers['authorization'] != null;
|
||||||
if (accessToken != null && !hasAuth) {
|
if (accessToken != null && !hasAuth) {
|
||||||
options.headers['Authorization'] = 'Bearer $accessToken';
|
options.headers['Authorization'] = 'Bearer $accessToken';
|
||||||
}
|
}
|
||||||
|
if (enableNetworkLog) {
|
||||||
debugPrint('▶ 【请求】${options.method} ${options.uri}');
|
debugPrint('▶ 【请求】${options.method} ${options.uri}');
|
||||||
|
}
|
||||||
handler.next(options); // 放行
|
handler.next(options); // 放行
|
||||||
},
|
},
|
||||||
|
|
||||||
// ② 收到响应后:打印日志
|
// ② 收到响应后:打印日志
|
||||||
onResponse: (response, handler) {
|
onResponse: (response, handler) {
|
||||||
|
if (enableNetworkLog) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'◀ 【响应】${response.statusCode} '
|
'◀ 【响应】${response.statusCode} '
|
||||||
'${response.requestOptions.method} ${response.requestOptions.uri}',
|
'${response.requestOptions.method} ${response.requestOptions.uri}',
|
||||||
);
|
);
|
||||||
|
}
|
||||||
handler.next(response); // 放行
|
handler.next(response); // 放行
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -143,10 +149,8 @@ class DioClient {
|
|||||||
final message = switch (e.type) {
|
final message = switch (e.type) {
|
||||||
DioExceptionType.connectionTimeout ||
|
DioExceptionType.connectionTimeout ||
|
||||||
DioExceptionType.sendTimeout ||
|
DioExceptionType.sendTimeout ||
|
||||||
DioExceptionType.receiveTimeout =>
|
DioExceptionType.receiveTimeout => '网络连接超时,请稍后重试',
|
||||||
'网络连接超时,请稍后重试',
|
DioExceptionType.badResponse => '服务器开小差了(${e.response?.statusCode})',
|
||||||
DioExceptionType.badResponse =>
|
|
||||||
'服务器开小差了(${e.response?.statusCode})',
|
|
||||||
DioExceptionType.cancel => '请求已取消',
|
DioExceptionType.cancel => '请求已取消',
|
||||||
DioExceptionType.connectionError => '网络异常,请检查网络连接',
|
DioExceptionType.connectionError => '网络异常,请检查网络连接',
|
||||||
_ => '未知错误(${e.type.name})',
|
_ => '未知错误(${e.type.name})',
|
||||||
@@ -262,8 +266,7 @@ class DioClient {
|
|||||||
DioExceptionType.connectionError ||
|
DioExceptionType.connectionError ||
|
||||||
DioExceptionType.connectionTimeout ||
|
DioExceptionType.connectionTimeout ||
|
||||||
DioExceptionType.sendTimeout ||
|
DioExceptionType.sendTimeout ||
|
||||||
DioExceptionType.receiveTimeout =>
|
DioExceptionType.receiveTimeout => true,
|
||||||
true,
|
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
return Container(
|
return Container(
|
||||||
height: 58.h,
|
height: 58.h,
|
||||||
margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h),
|
margin: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 15.h),
|
||||||
padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h),
|
// padding: EdgeInsets.fromLTRB(2.w, 2.h, 5.w, 2.h),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
image: DecorationImage(
|
image: DecorationImage(
|
||||||
image: CachedNetworkImageProvider(
|
image: CachedNetworkImageProvider(
|
||||||
@@ -339,12 +339,30 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
fit: BoxFit.fill,
|
fit: BoxFit.fill,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
LayoutBuilder(
|
||||||
|
builder: (context, rowConstraints) {
|
||||||
|
final rowWidth = rowConstraints.maxWidth;
|
||||||
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
_buildOnlineLeft(),
|
_buildOnlineLeft(),
|
||||||
const Spacer(),
|
Expanded(
|
||||||
_buildAvatarStack(),
|
// 占据剩余空间,把真实可用宽度传给头像组动态计算数量
|
||||||
_buildAllButton(),
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) =>
|
||||||
|
_buildAvatarStack(constraints.maxWidth, rowWidth),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 预留「全部」按钮宽度(按钮已悬浮于右上角,不在 Row 内占位)
|
||||||
|
SizedBox(width: 62.w),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// 「全部」按钮:悬浮右上角(对标 Vue position: absolute; top/right: 2rpx)
|
||||||
|
Positioned(top: 1, right: 1, child: _buildAllButton()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -354,7 +372,38 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(width: 15.w),
|
SizedBox(width: 15.w),
|
||||||
const Text('在线同学', style: TextStyle(fontSize: 14, color: _titleColor)),
|
// 「在线同学」底部渐变高亮条(对标 Vue 的 linear-gradient 下划线)
|
||||||
|
// clipBehavior: Clip.none 允许渐变条溢出 Stack 边界(bottom 为负值时不被裁剪)
|
||||||
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
bottom: -4,
|
||||||
|
child: Container(
|
||||||
|
width: 59.5.w,
|
||||||
|
height: 9.h,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(50.r),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
stops: [0, 0.3578, 1],
|
||||||
|
colors: [
|
||||||
|
Color(0x808AECFD), // rgba(138,236,253,0.5)
|
||||||
|
Color(0x331D9CFF), // rgba(29,156,255,0.2)
|
||||||
|
Color(0x003D7DF5), // rgba(61,125,245,0)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'在线同学',
|
||||||
|
style: TextStyle(fontSize: 14, color: _titleColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
SizedBox(width: 5.w),
|
SizedBox(width: 5.w),
|
||||||
Text(
|
Text(
|
||||||
'$_onlineCount',
|
'$_onlineCount',
|
||||||
@@ -370,13 +419,22 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 头像组:在线在前、离线在后,最多展示 6 个
|
/// 头像组:在线在前、离线在后,数量按剩余宽度动态计算(对标 Vue updateAvatarMaxCount,最多 20)
|
||||||
Widget _buildAvatarStack() {
|
/// 可用宽度限制在右侧背景块内(对标 Vue 左区 40% + 44rpx 内边距):
|
||||||
final urls = _avatarUrls.take(6).toList();
|
/// 左界 = 行宽 40% + 22px,右界 = 「全部」按钮左缘;同时不超过文字右侧空间,避免压住文字
|
||||||
if (urls.isEmpty) return const SizedBox.shrink();
|
Widget _buildAvatarStack(double maxWidth, double rowWidth) {
|
||||||
final size = 25.w;
|
final size = 25.w;
|
||||||
final overlap = size * 0.94;
|
final overlap = size * 0.94;
|
||||||
return SizedBox(
|
// 0.6 行宽 − 22px 左间距 − 62px 按钮宽
|
||||||
|
final rightBlockAvail = rowWidth * 0.6 - 84.w;
|
||||||
|
final avail = maxWidth < rightBlockAvail ? maxWidth : rightBlockAvail;
|
||||||
|
// 首个占满 size,后续每个只前进 overlap
|
||||||
|
final fitCount = avail >= size ? ((avail - size) / overlap).floor() + 1 : 0;
|
||||||
|
final urls = _avatarUrls.take(fitCount > 20 ? 20 : fitCount).toList();
|
||||||
|
if (urls.isEmpty) return const SizedBox.shrink();
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: SizedBox(
|
||||||
width: size + (urls.length - 1) * overlap,
|
width: size + (urls.length - 1) * overlap,
|
||||||
height: size,
|
height: size,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -395,6 +453,7 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,7 +463,7 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
onTap: () => context.push('/class/all-student'),
|
onTap: () => context.push('/class/all-student'),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 62.w,
|
width: 62.w,
|
||||||
height: 34.h,
|
height: 33.5.h,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
@@ -412,10 +471,32 @@ class _ClassUpdateViewState extends ConsumerState<ClassUpdateView> {
|
|||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
|
colors: [Color(0xFF3D7DF5), Color(0xFF3C84FF), Color(0xFF8AD1FD)],
|
||||||
),
|
),
|
||||||
|
// 对标 Vue border-radius: 0 30rpx 0 40rpx(右上 15 / 左下 20,其余直角)
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topRight: Radius.circular(15.r),
|
topRight: Radius.circular(15.r),
|
||||||
bottomLeft: Radius.circular(15.r),
|
bottomLeft: Radius.circular(20.r),
|
||||||
bottomRight: Radius.circular(20.r),
|
),
|
||||||
|
// 对标 Vue box-shadow 外层:0 15rpx 11.3rpx rgba(61,125,245,0.08)
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x143D7DF5),
|
||||||
|
offset: Offset(0, 7.5),
|
||||||
|
blurRadius: 5.65,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 对标 Vue box-shadow 内层 inset 0 2rpx 18.4rpx rgba(183,236,255,0.56)
|
||||||
|
// Flutter 无内阴影,用顶部渐变近似(浅蓝光晕从顶边向下淡出)
|
||||||
|
foregroundDecoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topRight: Radius.circular(15.r),
|
||||||
|
bottomLeft: Radius.circular(20.r),
|
||||||
|
),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
stops: [0, 0.3],
|
||||||
|
colors: [Color(0x8FB7ECFF), Color(0x00B7ECFF)],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class _MyUpdateDetailPageState extends ConsumerState<MyUpdateDetailPage> {
|
|||||||
/// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail)
|
/// 用详情接口增量更新评论相关字段(对标 Vue fetchDetail)
|
||||||
///
|
///
|
||||||
/// 关键:只有当 detail 确实带增量内容时才更新,且单个字段非空才覆盖。
|
/// 关键:只有当 detail 确实带增量内容时才更新,且单个字段非空才覆盖。
|
||||||
/// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据“清空”
|
/// 否则后端未返回评论/计数(空壳)时,会把列表带入的正确数据"清空"
|
||||||
/// —— 表现为初次闪现对的数据后瞬间空白。
|
/// —— 表现为初次闪现对的数据后瞬间空白。
|
||||||
Future<void> _fetchDetail() async {
|
Future<void> _fetchDetail() async {
|
||||||
try {
|
try {
|
||||||
@@ -143,17 +143,28 @@ class _MyUpdateDetailPageState extends ConsumerState<MyUpdateDetailPage> {
|
|||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 24.h),
|
padding: EdgeInsets.fromLTRB(14.w, 12.h, 14.w, 24.h),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(15.r),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
PostCard(
|
PostCard(
|
||||||
post: _post,
|
post: _post,
|
||||||
showCommentComposer: false,
|
showCommentComposer: false,
|
||||||
onLike: _toggleLike,
|
onLike: _toggleLike,
|
||||||
onComment: (_) {},
|
onComment: (_) {},
|
||||||
|
hasCardBackground: false,
|
||||||
),
|
),
|
||||||
SizedBox(height: 10.h),
|
|
||||||
_DetailComments(post: _post, onDelete: _deleteComment),
|
_DetailComments(post: _post, onDelete: _deleteComment),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,9 +187,13 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
/// 二次确认弹窗期间保持背景高亮(对标 Vue pendingDeleteIndex)
|
/// 二次确认弹窗期间保持背景高亮(对标 Vue pendingDeleteIndex)
|
||||||
int? _pending;
|
int? _pending;
|
||||||
|
|
||||||
|
/// 每条评论的定位锚点(懒加载,始终创建)
|
||||||
|
final Map<int, LayerLink> _links = {};
|
||||||
|
LayerLink _linkFor(int index) => _links.putIfAbsent(index, () => LayerLink());
|
||||||
|
|
||||||
UserPostDto get _post => widget.post;
|
UserPostDto get _post => widget.post;
|
||||||
|
|
||||||
/// 点浮层「删除」:先关闭浮层再弹确认框;确认弹窗期间用 _pending 保持高亮
|
/// 点浮层「删除」→ 关浮层 + 弹确认框
|
||||||
Future<void> _handleDelete(int index, UserPostCommonDto c) async {
|
Future<void> _handleDelete(int index, UserPostCommonDto c) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_active = null;
|
_active = null;
|
||||||
@@ -202,7 +217,6 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
// 弹窗关闭后无论确认/取消都移除高亮
|
|
||||||
setState(() => _pending = null);
|
setState(() => _pending = null);
|
||||||
if (ok != true) return;
|
if (ok != true) return;
|
||||||
await widget.onDelete(c);
|
await widget.onDelete(c);
|
||||||
@@ -212,26 +226,10 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final me = ref.watch(currentUserProvider).value;
|
final me = ref.watch(currentUserProvider).value;
|
||||||
final myId = me?.userId ?? 0;
|
final myId = me?.userId ?? 0;
|
||||||
return Container(
|
// 构建评论项时顺手收集所有 LayerLink(确保全部创建好)
|
||||||
width: double.infinity,
|
final items = <Widget>[];
|
||||||
// 横向内边距改由各分区自行包裹,评论列表本身无横向内边距即可铺满卡片宽度
|
if (_post.userLikeAvatars.isNotEmpty) {
|
||||||
padding: EdgeInsets.only(top: 18.h),
|
items.add(
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(15.r),
|
|
||||||
),
|
|
||||||
// 外层捕获非评论区域的点击 → 关闭「删除」浮层(对标 Vue 容器 @tap="closeDeletePopover")
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () {
|
|
||||||
if (_active != null) setState(() => _active = null);
|
|
||||||
},
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// ---------- 点赞人头像 ----------
|
|
||||||
if (_post.userLikeAvatars.isNotEmpty) ...[
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -264,9 +262,10 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 16.h),
|
);
|
||||||
],
|
items.add(SizedBox(height: 16.h));
|
||||||
// ---------- 全部留言 ----------
|
}
|
||||||
|
items.add(
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
padding: EdgeInsets.symmetric(horizontal: 16.w),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -274,22 +273,71 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
style: TextStyle(fontSize: 15, color: Color(0xFF1D2B47)),
|
style: TextStyle(fontSize: 15, color: Color(0xFF1D2B47)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 8.h),
|
);
|
||||||
if (_post.commons.isEmpty)
|
items.add(SizedBox(height: 8.h));
|
||||||
|
if (_post.commons.isEmpty) {
|
||||||
|
items.add(
|
||||||
Padding(
|
Padding(
|
||||||
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h),
|
padding: EdgeInsets.fromLTRB(16.w, 12.h, 16.w, 12.h),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'暂无留言',
|
'暂无留言',
|
||||||
style: TextStyle(fontSize: 13, color: Color(0xFFB4C2E0)),
|
style: TextStyle(fontSize: 13, color: Color(0xFFB4C2E0)),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
else
|
);
|
||||||
for (int i = 0; i < _post.commons.length; i++)
|
} else {
|
||||||
_commentItem(_post.commons[i], i, myId),
|
for (int i = 0; i < _post.commons.length; i++) {
|
||||||
SizedBox(height: 16.h),
|
items.add(_commentItem(_post.commons[i], i, myId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.add(SizedBox(height: 16.h));
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.only(top: 18.h),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(15.r),
|
||||||
|
),
|
||||||
|
// Stack 包裹 Column + 悬浮 Follower
|
||||||
|
// Follower 放在最后绘制 → 浮层在最顶层,既能显示也能接收到点击
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none, // 允许浮层溢出 Stack 边界(向上悬浮在评论上方)
|
||||||
|
children: [
|
||||||
|
// 主内容
|
||||||
|
GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () {
|
||||||
|
if (_active != null) setState(() => _active = null);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: items,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Follower 悬浮层:active 评论的「删除」浮层(最后绘制 → 顶层)
|
||||||
|
_buildPopoverFollower(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 悬浮层:CompositedTransformFollower 定位到 active 评论文本上方
|
||||||
|
///
|
||||||
|
/// 关键:Follower 直接放在 Stack 里(不是 OverlayPortal),
|
||||||
|
/// 和 Target 在同一 widget 树 → link 一定能连上,坐标计算无跨 Overlay 问题。
|
||||||
|
Widget _buildPopoverFollower() {
|
||||||
|
final index = _active;
|
||||||
|
if (index == null || index >= _post.commons.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return CompositedTransformFollower(
|
||||||
|
link: _linkFor(index),
|
||||||
|
targetAnchor: Alignment.topCenter,
|
||||||
|
followerAnchor: Alignment.bottomCenter,
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
child: _deletePopover(index, _post.commons[index]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,9 +347,14 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
final canDelete = (myId == c.userId) || (myId == _post.userId);
|
final canDelete = (myId == c.userId) || (myId == _post.userId);
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
// 长按(仅可删时)→ 高亮该条 + 弹出「删除」浮层(对标 Vue @longpress)
|
onLongPress: canDelete
|
||||||
onLongPress: canDelete ? () => setState(() => _active = index) : null,
|
? () {
|
||||||
// 单击评论本身:吸收点击、保持浮层与高亮(对标 Vue @tap.stop,避免事件冒泡到外层关闭浮层)
|
// 确保 link 已创建
|
||||||
|
_linkFor(index);
|
||||||
|
setState(() => _active = index);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
// active 时吸收点击 → 不冒泡到外层关闭浮层
|
||||||
onTap: active ? () {} : null,
|
onTap: active ? () {} : null,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -309,6 +362,9 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
color: (active || pending)
|
color: (active || pending)
|
||||||
? const Color(0xFFF5F8FA)
|
? const Color(0xFFF5F8FA)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
|
// 包整个 Row 作为锚点 → 浮层对准整条评论 item 的水平中心
|
||||||
|
child: CompositedTransformTarget(
|
||||||
|
link: _linkFor(index),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -339,58 +395,50 @@ class _DetailCommentsState extends ConsumerState<_DetailComments> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
SizedBox(height: 6.h),
|
SizedBox(height: 6.h),
|
||||||
// 文本 + 长按浮层(覆盖在文本上方居中)
|
_commentText(c.content),
|
||||||
Stack(
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
c.content,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: Color(0xFF495A7A),
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (active)
|
|
||||||
Positioned(
|
|
||||||
top: -46.h,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: Center(child: _deletePopover(index, c)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 长按后弹出的「删除」黑色浮层(对标 Vue delete-popover)
|
Widget _commentText(String content) => Text(
|
||||||
|
content,
|
||||||
|
style: const TextStyle(fontSize: 14, color: Color(0xFF495A7A), height: 1.5),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 「删除」黑色浮层:黑框在上 + 三角在下,三角紧贴黑框底部指向下方文本
|
||||||
Widget _deletePopover(int index, UserPostCommonDto c) {
|
Widget _deletePopover(int index, UserPostCommonDto c) {
|
||||||
const bg = Color(0xBF000000); // rgba(0,0,0,0.75)
|
const bg = Color(0xBF000000); // rgba(0,0,0,0.75)
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _handleDelete(index, c),
|
onTap: () => _handleDelete(index, c),
|
||||||
child: Container(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 黑框在上
|
||||||
|
Container(
|
||||||
width: 60.w,
|
width: 60.w,
|
||||||
padding: EdgeInsets.fromLTRB(0, 8.h, 0, 0),
|
padding: EdgeInsets.symmetric(vertical: 8.h),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(8.r),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: const Text(
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'删除',
|
'删除',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 14, color: Colors.white),
|
style: TextStyle(fontSize: 14, color: Colors.white),
|
||||||
),
|
),
|
||||||
Icon(Icons.arrow_drop_down, color: bg, size: 18),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
// 三角:往上拉 4px 紧贴黑框(Icons.arrow_drop_down 有内置留白)
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -9.5),
|
||||||
|
child: Icon(Icons.arrow_drop_down, color: bg, size: 22),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,9 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
|
|||||||
borderRadius: BorderRadius.circular(8.r),
|
borderRadius: BorderRadius.circular(8.r),
|
||||||
border: active ? null : Border.all(color: Colors.white, width: 1),
|
border: active ? null : Border.all(color: Colors.white, width: 1),
|
||||||
),
|
),
|
||||||
|
// Web 端回退字体行高偏大,固定高度可能溢出 → 内容放不下时自动缩小
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.scaleDown,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
@@ -198,6 +201,7 @@ class _PointRankViewState extends ConsumerState<PointRankView> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (active) _buildTriangle(),
|
if (active) _buildTriangle(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
@@ -25,6 +27,7 @@ class PostCard extends StatelessWidget {
|
|||||||
required this.onLike,
|
required this.onLike,
|
||||||
required this.onComment,
|
required this.onComment,
|
||||||
this.showCommentComposer = true,
|
this.showCommentComposer = true,
|
||||||
|
this.hasCardBackground = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final UserPostDto post;
|
final UserPostDto post;
|
||||||
@@ -34,6 +37,9 @@ class PostCard extends StatelessWidget {
|
|||||||
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
|
/// 是否显示卡片内嵌的评论预览 + 输入框(列表页 true;详情页由全量留言接管)
|
||||||
final bool showCommentComposer;
|
final bool showCommentComposer;
|
||||||
|
|
||||||
|
/// 是否显示白色卡片背景(列表页 true;详情页与留言区共用一张大卡片 → false)
|
||||||
|
final bool hasCardBackground;
|
||||||
|
|
||||||
String get _avatar {
|
String get _avatar {
|
||||||
final url = ImgUrl.build(post.avatar);
|
final url = ImgUrl.build(post.avatar);
|
||||||
return url.isEmpty
|
return url.isEmpty
|
||||||
@@ -54,14 +60,7 @@ class PostCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final card = Stack(
|
||||||
margin: EdgeInsets.only(bottom: 10.h),
|
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(15.r),
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -69,18 +68,8 @@ class PostCard extends StatelessWidget {
|
|||||||
right: 0,
|
right: 0,
|
||||||
height: 85.h,
|
height: 85.h,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: DecoratedBox(
|
// 卡片顶部右上角光晕,对标 Vue SubjectCard::before 的双层 radial-gradient
|
||||||
decoration: BoxDecoration(
|
child: CustomPaint(painter: _CardTopGlowPainter()),
|
||||||
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(
|
||||||
@@ -117,11 +106,74 @@ class PostCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
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 {
|
class CardHeader extends StatelessWidget {
|
||||||
const CardHeader({
|
const CardHeader({
|
||||||
|
|||||||
Reference in New Issue
Block a user