diff --git a/lib/src/features/diff/json_diff_view.dart b/lib/src/features/diff/json_diff_view.dart new file mode 100644 index 0000000..de64cd6 --- /dev/null +++ b/lib/src/features/diff/json_diff_view.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import '../widgets/text_diff_view.dart'; + +class TextComparePage extends StatelessWidget { + const TextComparePage({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('文本对比'), + backgroundColor: Theme.of(context).colorScheme.surfaceVariant, + ), + body: const TextCompareBody(), + ); + } +} + +class TextCompareBody extends StatefulWidget { + const TextCompareBody({super.key}); + + @override + State createState() => _TextCompareBodyState(); +} + +class _TextCompareBodyState extends State { + final TextEditingController _controller1 = TextEditingController(); + final TextEditingController _controller2 = TextEditingController(); + List? _diffs; + + @override + void dispose() { + _controller1.dispose(); + _controller2.dispose(); + super.dispose(); + } + + void _compareTexts() { + final text1 = _controller1.text; + final text2 = _controller2.text; + if (text1.isEmpty || text2.isEmpty) { + setState(() { + _diffs = null; + }); + return; + } + + setState(() { + _diffs = TextDiffView.computeDiffs(text1, text2); + }); + } + + void _clearTexts() { + setState(() { + _controller1.clear(); + _controller2.clear(); + _diffs = null; + }); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + // 输入区域 + SizedBox( + height: _diffs == null + ? constraints.maxHeight * 0.8 + : constraints.maxHeight * 0.4, + child: Row( + children: [ + Expanded( + child: _buildInputCard( + controller: _controller1, + hintText: '原文本', + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildInputCard( + controller: _controller2, + hintText: '对比文本', + ), + ), + ], + ), + ), + + const SizedBox(height: 16), + + // 操作按钮 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FilledButton.icon( + onPressed: _compareTexts, + icon: const Icon(Icons.compare_arrows), + label: const Text('对比'), + ), + const SizedBox(width: 16), + FilledButton.icon( + onPressed: _clearTexts, + icon: const Icon(Icons.clear_all), + label: const Text('清空'), + ), + const SizedBox(width: 16), + FilledButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData( + text: '${_controller1.text}\n---\n${_controller2.text}', + )); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已复制到剪贴板')), + ); + }, + icon: const Icon(Icons.copy), + label: const Text('复制'), + ), + ], + ), + + // 对比结果 + if (_diffs != null) ...[ + const SizedBox(height: 16), + Expanded( + child: Card( + elevation: 2, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: TextDiffView( + diffs: _diffs!, + background: Theme.of(context).colorScheme.surface, + ), + ), + ), + ), + ], + ], + ), + ); + }, + ); + } + + Widget _buildInputCard({ + required TextEditingController controller, + required String hintText, + }) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: controller, + maxLines: null, + expands: true, + decoration: InputDecoration( + hintText: hintText, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ); + } +} diff --git a/lib/src/features/diff/text_diff_view.dart b/lib/src/features/diff/text_diff_view.dart index d774a7d..3bfbfc7 100644 --- a/lib/src/features/diff/text_diff_view.dart +++ b/lib/src/features/diff/text_diff_view.dart @@ -1,5 +1,26 @@ import 'package:flutter/material.dart'; import 'package:diff_match_patch/diff_match_patch.dart'; +import 'dart:math'; + +enum DiffType { + equal, + delete, + insert, +} + +class DiffLine { + final DiffType type; + final String content; + final int? originalLineNumber; + final int? changedLineNumber; + + DiffLine({ + required this.type, + required this.content, + this.originalLineNumber, + this.changedLineNumber, + }); +} class TextDiffView extends StatefulWidget { @override @@ -10,6 +31,7 @@ class _TextDiffPageState extends State { final TextEditingController _originalController = TextEditingController(); final TextEditingController _changedController = TextEditingController(); List _diffs = []; + final ScrollController _verticalScrollController = ScrollController(); void _calculateDiff() { final dmp = DiffMatchPatch(); @@ -20,30 +42,31 @@ class _TextDiffPageState extends State { } Widget _buildInputSection(String title, TextEditingController controller) { - return Container( - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey, - width: 2.0, - ), - ), + return Card( + elevation: 2, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( - color: Colors.grey[800], - padding: EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(12)), + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(title, - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - TextButton( - child: Text("Clear"), - onPressed: () { - controller.clear(); - }, + Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + FilledButton.tonal( + onPressed: () => controller.clear(), + child: const Text('Clear'), ), ], ), @@ -53,9 +76,12 @@ class _TextDiffPageState extends State { controller: controller, maxLines: null, expands: true, + style: Theme.of(context).textTheme.bodyMedium, decoration: InputDecoration( - // border: OutlineInputBorder(), - contentPadding: EdgeInsets.all(8), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + contentPadding: const EdgeInsets.all(16), + border: InputBorder.none, ), ), ), @@ -65,88 +91,299 @@ class _TextDiffPageState extends State { } Widget _buildDiffResult() { - List diffWidgets = []; - int lineNumber = 1; + if (_diffs.isEmpty) return const SizedBox.shrink(); - for (var diff in _diffs) { + final originalLines = _originalController.text.split('\n'); + final changedLines = _changedController.text.split('\n'); + + // 构建差异行数据结构 + List diffLines = []; + int originalLineNumber = 1; + int changedLineNumber = 1; + + // 直接使用 diff_match_patch 的基础比较功能 + final dmp = DiffMatchPatch(); + final diffs = dmp.diff(originalLines.join('\n'), changedLines.join('\n')); + dmp.diffCleanupSemantic(diffs); + + for (var diff in diffs) { final lines = diff.text.split('\n'); - for (var i = 0; i < lines.length; i++) { - if (i > 0) { - lineNumber++; - } + for (var line in lines) { + // 跳过最后一个空行 + if (line.isEmpty && lines.last == line) continue; - Color? bgColor; - String prefix; - if (diff.operation == DIFF_DELETE) { - bgColor = Colors.red.withOpacity(0.2); - prefix = '-'; - } else if (diff.operation == DIFF_INSERT) { - bgColor = Colors.green.withOpacity(0.2); - prefix = '+'; - } else { - prefix = ' '; + switch (diff.operation) { + case DIFF_EQUAL: + diffLines.add(DiffLine( + type: DiffType.equal, + content: line, + originalLineNumber: originalLineNumber++, + changedLineNumber: changedLineNumber++, + )); + break; + case DIFF_DELETE: + diffLines.add(DiffLine( + type: DiffType.delete, + content: line, + originalLineNumber: originalLineNumber++, + changedLineNumber: null, + )); + break; + case DIFF_INSERT: + diffLines.add(DiffLine( + type: DiffType.insert, + content: line, + originalLineNumber: null, + changedLineNumber: changedLineNumber++, + )); + break; } + } + } - diffWidgets.add( - Container( - color: bgColor, - child: Row( - children: [ - SizedBox( - width: 50, + return Card( + elevation: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDiffHeader(), + Expanded( + child: _buildDiffContent(diffLines), + ), + ], + ), + ); + } + + Widget _buildDiffContent(List diffLines) { + return ListView.builder( + controller: _verticalScrollController, + itemCount: diffLines.length, + itemBuilder: (context, index) { + final line = diffLines[index]; + return Container( + height: 24, + child: Row( + children: [ + // 左侧行号 + SizedBox( + width: 48, + child: Text( + line.originalLineNumber?.toString() ?? '', + textAlign: TextAlign.right, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + // 左侧内容 + Expanded( + child: Container( + decoration: BoxDecoration( + color: _getDiffBackgroundColor(line.type, true), + border: Border( + left: BorderSide( + color: _getDiffBorderColor(line.type, true), + width: 4, + ), + ), + ), + padding: EdgeInsets.symmetric(horizontal: 8), child: Text( - lineNumber.toString(), - textAlign: TextAlign.right, - style: TextStyle(color: Colors.grey), + (line.type == DiffType.equal || + line.type == DiffType.delete) + ? line.content + : '', + style: TextStyle( + color: _getDiffTextColor(line.type, true), + ), ), ), - SizedBox(width: 10), - Text(prefix), - SizedBox(width: 10), - Expanded(child: Text(lines[i])), - ], - ), + ), + // 右侧行号 + SizedBox( + width: 48, + child: Text( + line.changedLineNumber?.toString() ?? '', + textAlign: TextAlign.right, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + ), + // 右侧内容 + Expanded( + child: Container( + decoration: BoxDecoration( + color: _getDiffBackgroundColor(line.type, false), + border: Border( + left: BorderSide( + color: _getDiffBorderColor(line.type, false), + width: 4, + ), + ), + ), + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text( + (line.type == DiffType.equal || + line.type == DiffType.insert) + ? line.content + : '', + style: TextStyle( + color: _getDiffTextColor(line.type, false), + ), + ), + ), + ), + ], ), ); + }, + ); + } - if (i < lines.length - 1 || diff != _diffs.last) { - diffWidgets.add(SizedBox(height: 1)); - } - } + Color _getDiffBackgroundColor(DiffType type, bool isLeft) { + final colors = Theme.of(context).colorScheme; + switch (type) { + case DiffType.delete: + return isLeft + ? colors.errorContainer.withOpacity(0.1) + : Colors.transparent; + case DiffType.insert: + return isLeft + ? Colors.transparent + : colors.primaryContainer.withOpacity(0.1); + case DiffType.equal: + return Colors.transparent; } + } - return ListView(children: diffWidgets); + Color _getDiffBorderColor(DiffType type, bool isLeft) { + final colors = Theme.of(context).colorScheme; + switch (type) { + case DiffType.delete: + return isLeft ? colors.error : Colors.transparent; + case DiffType.insert: + return isLeft ? Colors.transparent : colors.primary; + case DiffType.equal: + return Colors.transparent; + } + } + + Color _getDiffTextColor(DiffType type, bool isLeft) { + final colors = Theme.of(context).colorScheme; + switch (type) { + case DiffType.delete: + return isLeft ? colors.error : colors.onSurface; + case DiffType.insert: + return isLeft ? colors.onSurface : colors.primary; + case DiffType.equal: + return colors.onSurface; + } + } + + Widget _buildDiffHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Icon( + Icons.remove_circle_outline, + size: 16, + color: Theme.of(context).colorScheme.error, + ), + const SizedBox(width: 8), + Text( + 'Original', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ], + ), + ), + Expanded( + child: Row( + children: [ + Icon( + Icons.add_circle_outline, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + 'Changed', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: + Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ], + ), + ), + ], + ), + ); } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Text Diff Tool')), + appBar: AppBar( + title: const Text('Text Diff Tool'), + centerTitle: true, + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + ), body: Padding( padding: const EdgeInsets.all(16.0), child: Column( children: [ Expanded( + flex: _diffs.isEmpty ? 8 : 4, // 80% or 40% of available height child: Row( children: [ Expanded( - child: - _buildInputSection('Original', _originalController)), - SizedBox(width: 16), + child: _buildInputSection('Original', _originalController), + ), + const SizedBox(width: 16), Expanded( - child: _buildInputSection('Changed', _changedController)), + child: _buildInputSection('Changed', _changedController), + ), ], ), ), const SizedBox(height: 16), - ElevatedButton( + FilledButton.icon( onPressed: _calculateDiff, - child: Text('Compare'), + icon: const Icon(Icons.compare_arrows), + label: const Text('Compare'), ), - Expanded(child: _buildDiffResult()), + if (_diffs.isNotEmpty) ...[ + const SizedBox(height: 16), + Expanded( + flex: 6, // 60% of available height when showing results + child: _buildDiffResult(), + ), + ], ], ), ), ); } + + @override + void dispose() { + _verticalScrollController.dispose(); + super.dispose(); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 86914bb..d3a6f49 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,7 +4,7 @@ description: "A new dev tools." # Prevent accidental publishing to pub.dev. publish_to: 'none' -version: 1.2.1 +version: 1.2.2 environment: sdk: '>=3.2.0 <4.0.0'