// ============================================================ // 闲言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/safe_chart_widget.dart'; import '../../shared/widgets/containers/glass_container.dart'; import 'check_core.dart'; import '../../shared/widgets/adaptive/adaptive_back_button.dart'; class CheckPage extends ConsumerStatefulWidget { const CheckPage({super.key}); @override ConsumerState createState() => _CheckPageState(); } class _CheckPageState extends ConsumerState { 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()!; 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( 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( 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: SafeChartWidget( chartName: '相似度', chartBuilder: (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 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(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, ), ), ], ], ), ); } }