Files
xianyan/lib/features/check/presentation/check_page.dart
Developer ae1df22732 feat: v6.10.3 多语言翻译补全 + 17项功能修复
- 引导页协议多语言支持(languageId传递)
- 登录页双书名号修复 + 注册页协议勾选
- 个人中心页面多语言(18个翻译键)
- 网络断开提示增加关闭/刷新按钮
- 了解我们:新增秋叶qy开发者 + ayk签名修改 + 贡献者精简 + 微风暴微信搜索
- iOS快捷按钮重复修复(删除Info.plist静态定义)
- 测试账号123456警告提示
- 扫码登录自动跳转(HTTP轮询+WebSocket双通道)
- 登录页老用户按钮改次要色
- Syncfusion图表崩溃修复(DeferredBuilder+animationDuration:0)
- macOS标题栏跟随软件夜间模式
- 平台兼容分发渠道弹窗
- 软件著作权图片+交叉水印
- 桌面小部件平台兼容说明默认收起
- iOS/macOS图标更新+名称确认为闲言
- 12个语言文件补全roleNative+7个分发渠道翻译字段
2026-06-02 04:50:32 +08:00

1007 lines
32 KiB
Dart

// ============================================================
// 闲言APP — 内容查重页面
// 创建时间: 2026-04-29
// 更新时间: 2026-04-30
// 作用: 输入文本+选择数据源+查重模式+结果展示
// 上次更新: 全面UI增强 — 交错动画+相似度仪表盘+风险可视化+匹配详情
// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/theme/app_theme.dart';
import '../../../shared/widgets/containers/deferred_builder.dart';
import '../../../shared/widgets/containers/glass_container.dart';
import '../models/check_models.dart';
import '../providers/check_provider.dart';
import '../../../shared/widgets/adaptive/adaptive_back_button.dart';
class CheckPage extends ConsumerStatefulWidget {
const CheckPage({super.key});
@override
ConsumerState<CheckPage> createState() => _CheckPageState();
}
class _CheckPageState extends ConsumerState<CheckPage> {
final _textController = TextEditingController();
@override
void dispose() {
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(checkProvider);
final ext = Theme.of(context).extension<AppThemeExtension>()!;
return CupertinoPageScaffold(
navigationBar: const CupertinoNavigationBar(
leading: AdaptiveBackButton(),
middle: Text('🔍 内容查重'),
previousPageTitle: '返回',
),
child: SafeArea(
child: CupertinoScrollbar(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
CupertinoSliverRefreshControl(
onRefresh: () async {
ref.read(checkProvider.notifier).clearResult();
},
),
SliverToBoxAdapter(
child: _TextInputSection(controller: _textController, ext: ext)
.animate()
.fadeIn(duration: 400.ms)
.slideY(
begin: 0.06,
end: 0,
duration: 400.ms,
curve: Curves.easeOutCubic,
),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
SliverToBoxAdapter(
child: _SourceSelector(
ext: ext,
).animate().fadeIn(duration: 400.ms, delay: 80.ms),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
SliverToBoxAdapter(
child: _ModeSelector(
ext: ext,
).animate().fadeIn(duration: 400.ms, delay: 160.ms),
),
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.md)),
SliverToBoxAdapter(
child: _CheckButton(
ext: ext,
isChecking: state.isChecking,
onCheck: () => ref
.read(checkProvider.notifier)
.check(text: _textController.text),
).animate().fadeIn(duration: 400.ms, delay: 240.ms),
),
if (state.error != null)
SliverToBoxAdapter(
child: _ErrorBanner(error: state.error!, ext: ext)
.animate()
.fadeIn(duration: 300.ms)
.shake(
hz: 4,
offset: const Offset(4, 0),
duration: 400.ms,
),
),
if (state.result != null) ...[
const SliverToBoxAdapter(
child: SizedBox(height: AppSpacing.md),
),
SliverToBoxAdapter(
child: _CheckResultDashboard(result: state.result!, ext: ext)
.animate()
.fadeIn(duration: 500.ms)
.slideY(
begin: 0.08,
end: 0,
duration: 500.ms,
curve: Curves.easeOutCubic,
),
),
],
const SliverToBoxAdapter(child: SizedBox(height: AppSpacing.xl)),
],
),
),
),
);
}
}
class _SimPie {
const _SimPie(this.label, this.value, this.color);
final String label;
final double value;
final Color color;
}
class _TextInputSection extends StatelessWidget {
const _TextInputSection({required this.controller, required this.ext});
final TextEditingController controller;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: GlassContainer(
depth: GlassDepth.elevated,
borderRadius: AppRadius.lgBorder,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: const Center(
child: Text('📝', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'输入待查重文本',
style: AppTypography.title3.copyWith(color: ext.textPrimary),
),
],
),
const SizedBox(height: AppSpacing.md),
CupertinoTextField(
controller: controller,
placeholder: '粘贴或输入需要查重的文本内容...',
style: AppTypography.body.copyWith(
color: ext.textPrimary,
height: 1.6,
),
decoration: BoxDecoration(
color: ext.bgSecondary.withValues(alpha: 0.5),
borderRadius: AppRadius.mdBorder,
),
padding: const EdgeInsets.all(AppSpacing.md),
maxLines: 6,
minLines: 4,
),
const SizedBox(height: AppSpacing.sm),
ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (context, value, _) {
final count = value.text.length;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
'$count / 5000',
style: AppTypography.caption2.copyWith(
color: count > 5000
? CupertinoColors.systemRed
: ext.textHint,
),
),
],
);
},
),
],
),
),
);
}
}
class _SourceSelector extends ConsumerWidget {
const _SourceSelector({required this.ext});
final AppThemeExtension ext;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(checkProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: GlassContainer(
borderRadius: AppRadius.lgBorder,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: const Center(
child: Text('📂', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'数据源',
style: AppTypography.callout.copyWith(
color: ext.textSecondary,
),
),
const Spacer(),
if (state.selectedSources.isNotEmpty)
Text(
'已选 ${state.selectedSources.length}',
style: AppTypography.caption2.copyWith(color: ext.accent),
),
],
),
const SizedBox(height: AppSpacing.sm),
Wrap(
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: CheckSource.sources.map((src) {
final isSelected = state.selectedSources.contains(src.id);
return GestureDetector(
onTap: () =>
ref.read(checkProvider.notifier).toggleSource(src.id),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: isSelected
? ext.accent.withValues(alpha: 0.15)
: ext.bgSecondary.withValues(alpha: 0.5),
borderRadius: AppRadius.pillBorder,
border: isSelected
? Border.all(color: ext.accent, width: 1.5)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(src.emoji, style: const TextStyle(fontSize: 12)),
const SizedBox(width: 4),
Text(
src.name,
style: AppTypography.caption1.copyWith(
color: isSelected ? ext.accent : ext.textSecondary,
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
],
),
),
);
}
}
class _ModeSelector extends ConsumerWidget {
const _ModeSelector({required this.ext});
final AppThemeExtension ext;
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(checkProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: GlassContainer(
borderRadius: AppRadius.lgBorder,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: const Center(
child: Text('⚙️', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'查重模式',
style: AppTypography.callout.copyWith(
color: ext.textSecondary,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
CupertinoSlidingSegmentedControl<CheckMode>(
groupValue: state.selectedMode,
onValueChanged: (mode) {
if (mode != null) {
ref.read(checkProvider.notifier).setMode(mode);
}
},
children: {
CheckMode.exact: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🎯', style: TextStyle(fontSize: 12)),
const SizedBox(width: 4),
Text(
'精确',
style: AppTypography.caption1.copyWith(
color: ext.textPrimary,
),
),
],
),
),
CheckMode.fuzzy: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('🔍', style: TextStyle(fontSize: 12)),
const SizedBox(width: 4),
Text(
'模糊',
style: AppTypography.caption1.copyWith(
color: ext.textPrimary,
),
),
],
),
),
CheckMode.similar: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📊', style: TextStyle(fontSize: 12)),
const SizedBox(width: 4),
Text(
'相似度',
style: AppTypography.caption1.copyWith(
color: ext.textPrimary,
),
),
],
),
),
CheckMode.report: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📋', style: TextStyle(fontSize: 12)),
const SizedBox(width: 4),
Text(
'综合',
style: AppTypography.caption1.copyWith(
color: ext.textPrimary,
),
),
],
),
),
},
),
const SizedBox(height: AppSpacing.xs),
Text(
_modeDescription(state.selectedMode),
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
),
);
}
String _modeDescription(CheckMode mode) {
switch (mode) {
case CheckMode.exact:
return '精确匹配,查找完全相同的内容';
case CheckMode.fuzzy:
return '模糊匹配,查找近似内容';
case CheckMode.similar:
return '计算相似度,量化匹配程度';
case CheckMode.report:
return '综合分析,生成完整查重报告';
}
}
}
class _CheckButton extends StatelessWidget {
const _CheckButton({
required this.ext,
required this.isChecking,
required this.onCheck,
});
final AppThemeExtension ext;
final bool isChecking;
final VoidCallback onCheck;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: SizedBox(
width: double.infinity,
height: 50,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.lgBorder,
onPressed: isChecking ? null : onCheck,
child: isChecking
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CupertinoActivityIndicator(
color: CupertinoColors.white,
),
SizedBox(width: AppSpacing.sm),
Text(
'正在查重...',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
],
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'🔍',
style: TextStyle(
fontSize: 18,
color: CupertinoColors.white,
),
),
SizedBox(width: 8),
Text(
'开始查重',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: CupertinoColors.white,
),
),
],
),
),
),
);
}
}
class _ErrorBanner extends StatelessWidget {
const _ErrorBanner({required this.error, required this.ext});
final String error;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: CupertinoColors.systemRed.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: CupertinoColors.systemRed.withValues(alpha: 0.15),
borderRadius: AppRadius.mdBorder,
),
child: const Center(
child: Text('', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
error,
style: AppTypography.subhead.copyWith(
color: CupertinoColors.systemRed,
),
),
),
],
),
),
);
}
}
class _CheckResultDashboard extends StatelessWidget {
const _CheckResultDashboard({required this.result, required this.ext});
final CheckResult result;
final AppThemeExtension ext;
Color _riskColor() {
switch (result.riskLevel) {
case 'high':
return CupertinoColors.systemRed;
case 'medium':
return CupertinoColors.systemOrange;
default:
return CupertinoColors.systemGreen;
}
}
String _riskLabel() {
switch (result.riskLevel) {
case 'high':
return '高风险';
case 'medium':
return '中风险';
default:
return '低风险';
}
}
String _riskEmoji() {
switch (result.riskLevel) {
case 'high':
return '🔴';
case 'medium':
return '🟡';
default:
return '🟢';
}
}
@override
Widget build(BuildContext context) {
final riskColor = _riskColor();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
children: [
GlassContainer(
depth: GlassDepth.elevated,
borderRadius: AppRadius.lgBorder,
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: riskColor.withValues(alpha: 0.15),
borderRadius: AppRadius.mdBorder,
),
child: Center(
child: Text(
_riskEmoji(),
style: const TextStyle(fontSize: 16),
),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'查重结果',
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 4,
),
decoration: BoxDecoration(
color: riskColor.withValues(alpha: 0.15),
borderRadius: AppRadius.pillBorder,
),
child: Text(
_riskLabel(),
style: AppTypography.caption1.copyWith(
color: riskColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
Center(
child: _SimilarityGauge(
similarity: result.maxSimilarity,
riskScore: result.riskScore,
riskColor: riskColor,
ext: ext,
),
),
const SizedBox(height: AppSpacing.lg),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ResultStatItem(
emoji: '⚠️',
label: '风险分',
value: '${result.riskScore}',
color: riskColor,
ext: ext,
),
_ResultStatItem(
emoji: '📊',
label: '最大相似度',
value:
'${(result.maxSimilarity * 100).toStringAsFixed(1)}%',
color: riskColor,
ext: ext,
),
_ResultStatItem(
emoji: '🔗',
label: '匹配源',
value: '${result.matches.length}',
color: ext.accent,
ext: ext,
),
],
),
],
),
),
if (result.matches.isNotEmpty) ...[
const SizedBox(height: AppSpacing.md),
_MatchDetailSection(
matches: result.matches,
riskColor: riskColor,
ext: ext,
),
],
],
),
);
}
}
class _SimilarityGauge extends StatelessWidget {
const _SimilarityGauge({
required this.similarity,
required this.riskScore,
required this.riskColor,
required this.ext,
});
final double similarity;
final int riskScore;
final Color riskColor;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
final simPercent = (similarity * 100).clamp(0.0, 100.0);
return SizedBox(
width: 160,
height: 160,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 160,
height: 160,
child: DeferredBuilder(
builder: (context) => SfCircularChart(
margin: EdgeInsets.zero,
series: [
DoughnutSeries<_SimPie, String>(
animationDuration: 0,
dataSource: [
_SimPie('相似', simPercent, riskColor),
_SimPie('差异', 100 - simPercent, ext.bgSecondary),
],
xValueMapper: (d, _) => d.label,
yValueMapper: (d, _) => d.value,
pointColorMapper: (d, _) => d.color,
innerRadius: '68%',
radius: '82%',
strokeWidth: 0,
),
],
),
),
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${simPercent.toStringAsFixed(1)}%',
style: AppTypography.title1.copyWith(
color: riskColor,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'相似度',
style: AppTypography.caption1.copyWith(color: ext.textHint),
),
],
),
],
),
);
}
}
class _ResultStatItem extends StatelessWidget {
const _ResultStatItem({
required this.emoji,
required this.label,
required this.value,
required this.color,
required this.ext,
});
final String emoji;
final String label;
final String value;
final Color color;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(emoji, style: const TextStyle(fontSize: 18)),
const SizedBox(height: 4),
Text(
value,
style: AppTypography.headline.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
label,
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
);
}
}
class _MatchDetailSection extends StatelessWidget {
const _MatchDetailSection({
required this.matches,
required this.riskColor,
required this.ext,
});
final List<CheckMatch> matches;
final Color riskColor;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return GlassContainer(
borderRadius: AppRadius.lgBorder,
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.mdBorder,
),
child: const Center(
child: Text('📋', style: TextStyle(fontSize: 16)),
),
),
const SizedBox(width: AppSpacing.sm),
Text(
'匹配详情',
style: AppTypography.callout.copyWith(color: ext.textSecondary),
),
const Spacer(),
Text(
'${matches.length} 条匹配',
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
const SizedBox(height: AppSpacing.md),
...matches.asMap().entries.map((entry) {
final index = entry.key;
final match = entry.value;
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
child: _MatchCard(match: match, riskColor: riskColor, ext: ext)
.animate()
.fadeIn(duration: 300.ms, delay: (index * 60).ms)
.slideX(
begin: 0.06,
end: 0,
duration: 300.ms,
curve: Curves.easeOutCubic,
delay: (index * 60).ms,
),
);
}),
],
),
);
}
}
class _MatchCard extends StatelessWidget {
const _MatchCard({
required this.match,
required this.riskColor,
required this.ext,
});
final CheckMatch match;
final Color riskColor;
final AppThemeExtension ext;
String _sourceEmoji() {
switch (match.sourceType) {
case 'poetry':
return '📜';
case 'chengyu':
return '🔗';
case 'story':
return '📖';
case 'wisdom':
return '💡';
case 'article':
return '📝';
default:
return '📄';
}
}
@override
Widget build(BuildContext context) {
final simPercent = (match.similarity * 100).toStringAsFixed(1);
return Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgSecondary.withValues(alpha: 0.5),
borderRadius: AppRadius.mdBorder,
border: Border.all(color: riskColor.withValues(alpha: 0.1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: riskColor.withValues(alpha: 0.1),
borderRadius: AppRadius.smBorder,
),
child: Center(
child: Text(
_sourceEmoji(),
style: const TextStyle(fontSize: 14),
),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
match.sourceTitle,
style: AppTypography.callout.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: 2,
),
decoration: BoxDecoration(
color: riskColor.withValues(alpha: 0.1),
borderRadius: AppRadius.pillBorder,
),
child: Text(
'$simPercent%',
style: AppTypography.caption1.copyWith(
color: riskColor,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
ClipRRect(
borderRadius: AppRadius.smBorder,
child: LinearProgressIndicator(
value: match.similarity.clamp(0.0, 1.0),
backgroundColor: ext.bgSecondary,
valueColor: AlwaysStoppedAnimation<Color>(riskColor),
minHeight: 4,
),
),
if (match.matchedText.isNotEmpty) ...[
const SizedBox(height: AppSpacing.sm),
Container(
padding: const EdgeInsets.all(AppSpacing.sm),
decoration: BoxDecoration(
color: ext.bgPrimary.withValues(alpha: 0.5),
borderRadius: AppRadius.smBorder,
),
child: Text(
match.matchedText,
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
height: 1.5,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
);
}
}