ソースを参照

工具页:计算全部交给 Symja,输出分开可复制、加重置;新增两个工具;控制台设置(MCP 端口 / E-Ink);函数面板支持中文检索

工具页
- 15 个工具的计算全部改走 Symja(原来 11 个在 Dart 里用 rational / math_expressions
  自己算),Dart 只剩取输入、排版和绘图采样
- 新增公共外壳 lib/tools/tool_kit.dart:输入框 + 计算/重置 + 一条条可点复制的输出
  (图表不参与复制)。每个工具因此都自带重置按钮
- 表达式里**乘号必须显式写**:Symja 会把 `n (v+1)` 解析成函数调用、静默算错,
  这次逐个表达式在 JVM 上对着 Symja 3.2.0 验过
- 算式不受计算器顶栏"近似模式"影响(SymjaSession.compute 新增 applyMode,默认关)
- 删掉 lib/utils/math_utils.dart、rationalsqrt2.dart,pubspec 去掉 rational 与
  math_expressions —— 同一个算式不再有 Dart 和 Symja 两份实现
- 新增工具:分组分配(k!·S(n,k))、n 边形染色((k−1)ⁿ+(k−1)(−1)ⁿ)
- 工具页原本没有的说明文字不加(二项分布那种公式只存在于代码注释里)

控制台设置
- 齿轮按钮 / `settings` 命令:MCP 端口、E-Ink 模式
- 端口存 settings.json,启动 MCP 时传给原生侧;服务在跑而端口变了会先停再起
- E-Ink:一个全局灰阶滤镜 + 关动画(零时长主题动画、无路由过渡、无水波纹、
  MediaQuery.disableAnimations)。切换时保持 widget 树形状不变,否则
  Navigator 会被重建、一开就被踢回首页
- 计算器的函数面板现在也能按中文说明搜("方差" → Variance)

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 3 週間 前
親
コミット
8874572e45

+ 5 - 1
android/app/src/main/java/com/nonvioce/cal/McpService.java

@@ -61,10 +61,14 @@ public class McpService extends Service {
         }
 
         int port = (intent != null) ? intent.getIntExtra(EXTRA_PORT, DEFAULT_PORT) : DEFAULT_PORT;
-        currentPort = port;
 
         if (!running) {
             startServer(port);
+        } else if (port != currentPort) {
+            // 端口改了:原来"已经在跑就不动"会让新端口静默不生效
+            // (设置页改了端口却还连在旧端口上,最难查的那种)
+            stopServer();
+            startServer(port);
         }
 
         return START_STICKY;

+ 4 - 1
android/app/src/main/kotlin/com/nonvioce/cal/MainActivity.kt

@@ -70,12 +70,15 @@ class MainActivity : FlutterActivity() {
                     "status" -> result.success(mcpStatus())
 
                     "start" -> {
+                        // 端口来自设置页;没传就用服务里的默认值
+                        val port = call.argument<Int>("port")
                         // 用 ContextCompat 而不是直接调 startForegroundService:
                         // minSdk 是 24,而这个 API 要 26 才有。
                         ContextCompat.startForegroundService(
                             this,
                             Intent(this, McpService::class.java)
-                                .setAction(McpService.ACTION_START),
+                                .setAction(McpService.ACTION_START)
+                                .apply { if (port != null) putExtra(McpService.EXTRA_PORT, port) },
                         )
                         result.success(mcpStatus().toMutableMap().apply { put("running", true) })
                     }

+ 11 - 3
lib/calculator_page.dart

@@ -649,6 +649,14 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
   final _search = TextEditingController();
   String _q = '';
 
+  /// 名字按大小写不敏感匹配;中文只出现在说明里,所以说明也拿来当关键词 ——
+  /// 想找方差的人不该非得记住那个词叫 `Variance`。
+  static bool _matches(String fn, String q) {
+    if (fn.toLowerCase().contains(q.toLowerCase())) return true;
+    final help = kSymjaFunctionHelp[fn];
+    return help != null && help.contains(q);
+  }
+
   @override
   void dispose() {
     _search.dispose();
@@ -657,9 +665,9 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
 
   @override
   Widget build(BuildContext context) {
-    final q = _q.trim().toLowerCase();
+    final q = _q.trim();
     final fns = kSymjaFunctions
-        .where((f) => q.isEmpty || f.toLowerCase().contains(q))
+        .where((f) => q.isEmpty || _matches(f, q))
         .toList(growable: false);
 
     return DraggableScrollableSheet(
@@ -675,7 +683,7 @@ class _FunctionPaletteState extends State<_FunctionPalette> {
               autofocus: true,
               decoration: InputDecoration(
                 isDense: true,
-                hintText: '函数名(共 ${kSymjaFunctions.length} 个)',
+                hintText: '函数名或中文说明(共 ${kSymjaFunctions.length} 个)',
                 prefixIcon: const Icon(Icons.search),
                 border: const OutlineInputBorder(),
               ),

+ 12 - 1
lib/console_page.dart

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
 import 'curve_plot.dart';
 import 'mcp.dart';
 import 'session_store.dart';
+import 'settings.dart';
 import 'symja.dart';
 
 class ConsolePage extends StatefulWidget {
@@ -35,7 +36,8 @@ class _ConsolePageState extends State<ConsolePage> {
   vars             列出当前变量
   plot 表达式       画图(x 从 -6.3 到 6.3)
   N 表达式 [位数]   取近似值,默认 15 位
-  mcp              MCP 服务器开关''';
+  mcp              MCP 服务器开关
+  settings         设置(MCP 端口、E-Ink 模式)''';
 
   final _ctrl = TextEditingController();
   final _focus = FocusNode();
@@ -213,6 +215,10 @@ class _ConsolePageState extends State<ConsolePage> {
         if (mounted) await showMcpPanel(context);
         return true;
 
+      case 'settings':
+        if (mounted) await showSettingsPanel(context);
+        return true;
+
       case 'plot':
         final expr = input.substring('plot'.length).trim();
         if (expr.isEmpty) {
@@ -323,6 +329,11 @@ class _ConsolePageState extends State<ConsolePage> {
             icon: const Icon(Icons.cleaning_services_outlined),
             onPressed: () => _submit('cls'),
           ),
+          IconButton(
+            tooltip: '设置',
+            icon: const Icon(Icons.settings_outlined),
+            onPressed: () => showSettingsPanel(context),
+          ),
         ],
       ),
       body: Column(

+ 95 - 7
lib/main.dart

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
 
 import 'calculator_page.dart';
 import 'console_page.dart';
+import 'settings.dart';
 import 'symja.dart';
 import 'tools_page.dart';
 
@@ -13,16 +14,103 @@ class CalApp extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
-    return ChangeNotifierProvider(
-      create: (_) => SymjaSession(),
-      child: MaterialApp(
-        title: '某人的计算器2',
-        debugShowCheckedModeBanner: false,
-        theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true),
-        home: const ShellPage(),
+    return MultiProvider(
+      providers: [
+        ChangeNotifierProvider(create: (_) => SymjaSession()),
+        // 设置异步读盘,读完 notifyListeners 一次,界面自己会换上 E-Ink
+        ChangeNotifierProvider(create: (_) => AppSettings()..load()),
+      ],
+      child: const _App(),
+    );
+  }
+}
+
+class _App extends StatelessWidget {
+  const _App();
+
+  @override
+  Widget build(BuildContext context) {
+    final eInk = context.watch<AppSettings>().eInk;
+
+    return MaterialApp(
+      title: '某人的计算器2',
+      debugShowCheckedModeBanner: false,
+      theme: _theme(eInk),
+      themeAnimationDuration: eInk ? Duration.zero : kThemeAnimationDuration,
+      // **始终**套同样两层的 widget,只改参数:切 E-Ink 时若把这两层加进来/
+      // 拿出去,Navigator 会因为"这一格的 widget 类型变了"被整个重建,
+      // 表现是"一开 E-Ink 就被踢回首页、弹着的面板也没了"。
+      builder: (context, child) => MediaQuery(
+        data: MediaQuery.of(context)
+            .copyWith(disableAnimations: eInk),
+        child: ColorFiltered(
+          // 关掉时用单位矩阵,等于没过滤镜(`ColorFiltered` 本身还在,
+          // 但任何颜色乘上单位矩阵都不变)
+          colorFilter: eInk ? _Grayscale.filter : _Grayscale.identity,
+          child: child!,
+        ),
       ),
+      home: const ShellPage(),
     );
   }
+
+  /// E-Ink 模式下额外关掉动画:路由切换和点击水波纹是屏幕上最明显的两处动画。
+  ThemeData _theme(bool eInk) {
+    final base = ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true);
+    if (!eInk) return base;
+    return base.copyWith(
+      splashFactory: NoSplash.splashFactory,
+      pageTransitionsTheme: const PageTransitionsTheme(
+        builders: <TargetPlatform, PageTransitionsBuilder>{
+          TargetPlatform.android: _NoTransition(),
+          TargetPlatform.iOS: _NoTransition(),
+          TargetPlatform.fuchsia: _NoTransition(),
+          TargetPlatform.linux: _NoTransition(),
+          TargetPlatform.macOS: _NoTransition(),
+          TargetPlatform.windows: _NoTransition(),
+        },
+      ),
+    );
+  }
+}
+
+class _NoTransition extends PageTransitionsBuilder {
+  const _NoTransition();
+
+  @override
+  Widget buildTransitions<T>(
+    PageRoute<T> route,
+    BuildContext context,
+    Animation<double> animation,
+    Animation<double> secondaryAnimation,
+    Widget child,
+  ) =>
+      child;
+}
+
+/// 灰阶滤镜。
+///
+/// 用**一个**颜色滤镜覆盖全局,而不是去给每个配色/每张图表换一套灰阶调色板 ——
+/// 那些颜色散在主题、工具卡片、绘图组件、Symja 的 PNG 里,逐个改必然漏。
+/// E-Ink 关掉时套的是单位矩阵(同样的 widget、同样的开销,只是颜色不变),
+/// 这样切换不会动到 widget 树的形状。
+class _Grayscale {
+  const _Grayscale._();
+
+  /// 亮度权重就是通常的 Rec.709 公式。
+  static const ColorFilter filter = ColorFilter.matrix(<double>[
+    0.2126, 0.7152, 0.0722, 0, 0, //
+    0.2126, 0.7152, 0.0722, 0, 0, //
+    0.2126, 0.7152, 0.0722, 0, 0, //
+    0, 0, 0, 1, 0, //
+  ]);
+
+  static const ColorFilter identity = ColorFilter.matrix(<double>[
+    1, 0, 0, 0, 0, //
+    0, 1, 0, 0, 0, //
+    0, 0, 1, 0, 0, //
+    0, 0, 0, 1, 0, //
+  ]);
 }
 
 /// 三个界面(Q16 的「双界面」+ 插件页):

+ 13 - 3
lib/mcp.dart

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:provider/provider.dart';
 
+import 'settings.dart';
+
 /// MCP 服务器的控制通道。
 const MethodChannel mcpChannel = MethodChannel('com.nonvioce.cal/mcp');
 
@@ -44,11 +46,17 @@ class McpController extends ChangeNotifier {
     notifyListeners();
   }
 
-  Future<void> start() async {
+  /// 启动。[port] 来自设置页,空则用原生侧的默认端口。
+  ///
+  /// 服务已经在跑而端口又变了的时候,原生侧会先停再起 —— 否则新端口不生效。
+  Future<void> start({int? port}) async {
     busy = true;
     notifyListeners();
     try {
-      final m = await mcpChannel.invokeMapMethod<String, dynamic>('start');
+      final m = await mcpChannel.invokeMapMethod<String, dynamic>(
+        'start',
+        <String, dynamic>{'port': ?port},
+      );
       status = _parse(m);
     } on PlatformException catch (e) {
       status = McpStatus(running: false, port: 0, error: e.message);
@@ -169,7 +177,9 @@ class _McpSheet extends StatelessWidget {
                 FilledButton.icon(
                   onPressed: ctrl.busy
                       ? null
-                      : () => s.running ? ctrl.stop() : ctrl.start(),
+                      : () => s.running
+                          ? ctrl.stop()
+                          : ctrl.start(port: context.read<AppSettings>().mcpPort),
                   icon: Icon(s.running ? Icons.stop : Icons.play_arrow),
                   label: Text(s.running ? '停止' : '启动'),
                 ),

+ 14 - 0
lib/plugins.dart

@@ -6,7 +6,9 @@ import 'tools/chi_square.dart';
 import 'tools/circle_equation.dart';
 import 'tools/convolution_calculator.dart';
 import 'tools/cuo_wei_xiang_jian.dart';
+import 'tools/cycle_coloring.dart';
 import 'tools/function_plotter.dart';
+import 'tools/group_allocation.dart';
 import 'tools/histogram.dart';
 import 'tools/hypergeometric_distribution.dart';
 import 'tools/mixed_variance.dart';
@@ -149,4 +151,16 @@ final List<ToolPlugin> kTools = [
     keywords: ['parametric', 'csfc', '绘图'],
     build: () => ParametricPlotterPage(),
   ),
+  ToolPlugin(
+    name: '分组分配',
+    icon: Icons.groups_outlined,
+    keywords: ['stirling', 'fzfp', '分组', '组合'],
+    build: () => GroupAllocationPage(),
+  ),
+  ToolPlugin(
+    name: 'n 边形染色',
+    icon: Icons.palette_outlined,
+    keywords: ['coloring', 'ranse', '染色', '图论'],
+    build: () => CycleColoringPage(),
+  ),
 ];

+ 7 - 2
lib/session_store.dart

@@ -9,12 +9,17 @@ import 'package:path_provider/path_provider.dart';
 /// 写入走「临时文件 + 改名」,保证原子性:写到一半被杀不会留下半个坏存档,
 /// 否则下次启动 JSON 解析失败就会丢掉全部变量。
 class SessionStore {
-  static const _fileName = 'session.json';
   static const _version = 1;
 
+  /// 存档文件名。设置另存一个文件 —— 会话存档会被工具/控制台频繁重写,
+  /// 混在一个文件里就意味着「存历史」和「存设置」要互相读改写。
+  SessionStore({this.fileName = 'session.json'});
+
+  final String fileName;
+
   Future<File> _file() async {
     final dir = await getApplicationDocumentsDirectory();
-    return File('${dir.path}/$_fileName');
+    return File('${dir.path}/$fileName');
   }
 
   /// 读存档。任何异常都当成「没有存档」返回 null —— 存档问题绝不能挡住启动。

+ 153 - 0
lib/settings.dart

@@ -0,0 +1,153 @@
+import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
+
+import 'session_store.dart';
+
+/// 全局设置。目前两项:MCP 端口、E-Ink 模式。
+///
+/// 存成单独一个 `settings.json`(见 [SessionStore]),不跟会话存档混在一起 ——
+/// 会话存档每次求值都会重写,设置却是偶尔改一次的东西。
+class AppSettings extends ChangeNotifier {
+  AppSettings({SessionStore? store}) : _store = store ?? SessionStore(fileName: 'settings.json');
+
+  final SessionStore _store;
+
+  static const int defaultMcpPort = 8734;
+  static const int minPort = 1024;
+  static const int maxPort = 65535;
+
+  int _mcpPort = defaultMcpPort;
+  bool _eInk = false;
+  bool _loaded = false;
+
+  /// MCP 服务器监听的端口。
+  int get mcpPort => _mcpPort;
+
+  /// 是否已从存档读过一次(界面据此决定要不要提示"设置已恢复")。
+  bool get loaded => _loaded;
+
+  /// E-Ink 模式:所有颜色换成灰阶,动画关掉。
+  ///
+  /// 给墨水屏/阳光下看屏幕用 —— 彩色在这种屏上会被抖成难看的网点,
+  /// 动画则会留下拖影。
+  bool get eInk => _eInk;
+
+  Future<void> load() async {
+    final data = await _store.load();
+    if (data != null) {
+      final p = (data['mcpPort'] as num?)?.toInt();
+      if (p != null && p >= minPort && p <= maxPort) _mcpPort = p;
+      _eInk = data['eInk'] == true;
+    }
+    _loaded = true;
+    notifyListeners();
+  }
+
+  Future<void> setMcpPort(int port) async {
+    if (port < minPort || port > maxPort || port == _mcpPort) return;
+    _mcpPort = port;
+    notifyListeners();
+    await _save();
+  }
+
+  Future<void> setEInk(bool on) async {
+    if (on == _eInk) return;
+    _eInk = on;
+    notifyListeners();
+    await _save();
+  }
+
+  Future<void> _save() => _store.save(<String, dynamic>{
+        'mcpPort': _mcpPort,
+        'eInk': _eInk,
+      });
+}
+
+/// 弹出设置面板。控制台顶栏的齿轮按钮走这里。
+Future<void> showSettingsPanel(BuildContext context) async {
+  await showModalBottomSheet<void>(
+    context: context,
+    showDragHandle: true,
+    isScrollControlled: true,
+    builder: (_) => const _SettingsSheet(),
+  );
+}
+
+class _SettingsSheet extends StatefulWidget {
+  const _SettingsSheet();
+
+  @override
+  State<_SettingsSheet> createState() => _SettingsSheetState();
+}
+
+class _SettingsSheetState extends State<_SettingsSheet> {
+  late final AppSettings _settings = context.read<AppSettings>();
+  late final TextEditingController _port =
+      TextEditingController(text: '${_settings.mcpPort}');
+  String? _portError;
+
+  @override
+  void dispose() {
+    _port.dispose();
+    super.dispose();
+  }
+
+  void _applyPort() {
+    final v = int.tryParse(_port.text.trim());
+    if (v == null || v < AppSettings.minPort || v > AppSettings.maxPort) {
+      setState(() => _portError =
+          '端口要在 ${AppSettings.minPort}~${AppSettings.maxPort} 之间');
+      return;
+    }
+    setState(() => _portError = null);
+    _settings.setMcpPort(v);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final eInk = context.watch<AppSettings>().eInk;
+
+    return SafeArea(
+      child: Padding(
+        padding: EdgeInsets.fromLTRB(
+          20,
+          0,
+          20,
+          20 + MediaQuery.viewInsetsOf(context).bottom,
+        ),
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Text('设置', style: Theme.of(context).textTheme.titleMedium),
+            const SizedBox(height: 16),
+            TextField(
+              controller: _port,
+              keyboardType: TextInputType.number,
+              onSubmitted: (_) => _applyPort(),
+              onTapOutside: (_) {
+                FocusScope.of(context).unfocus();
+                _applyPort();
+              },
+              decoration: InputDecoration(
+                labelText: 'MCP 服务器端口',
+                helperText: '默认 ${AppSettings.defaultMcpPort}。服务器正在运行时改端口会重启它。',
+                errorText: _portError,
+                border: const OutlineInputBorder(),
+                isDense: true,
+              ),
+            ),
+            const SizedBox(height: 8),
+            SwitchListTile(
+              contentPadding: EdgeInsets.zero,
+              value: eInk,
+              title: const Text('E-Ink 模式'),
+              subtitle: const Text('所有颜色换成灰阶,动画关闭'),
+              onChanged: (v) => _settings.setEInk(v),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 9 - 1
lib/symja.dart

@@ -210,17 +210,25 @@ class SymjaSession extends ChangeNotifier {
   ///
   /// 工具页做内部计算用它:那些表达式不是用户在控制台里敲的,塞进历史只是噪音;
   /// 而 [preview] 会把错误吞掉,工具却需要把错误显示出来。
+  ///
+  /// 默认**不套**顶栏那个「精确 / 近似」开关([applyMode]),工具要近似值时
+  /// 自己在表达式里写 `N[...]` —— 否则"精确值"那一条也会被改写成近似值,
+  /// 两条输出就没区别了。
   Future<SymjaResult> compute(
     String expr, {
     Duration timeout = const Duration(seconds: 20),
     bool wantTeX = true,
+    bool applyMode = false,
   }) async {
     final input = expr.trim();
     if (input.isEmpty) throw const SymjaException('表达式为空');
     try {
       final m = await symjaChannel.invokeMapMethod<String, dynamic>('evaluate', {
         'taskId': ++_seq,
-        'expr': _applyMode(input),
+        // 工具页传 applyMode: false —— 它在表达式里自己写 N[...],
+        // 不该被计算器顶栏那个"精确 / 近似"开关二次改写(否则"精确值"
+        // 那一条也会变成近似值,两条输出就没区别了)。
+        'expr': applyMode ? _applyMode(input) : input,
         'timeoutMs': timeout.inMilliseconds,
         'wantTeX': wantTeX,
         // preview=true 同时意味着原生侧不更新 lastOutput,

+ 125 - 243
lib/tools/binomial_distribution.dart

@@ -1,260 +1,142 @@
 // tools/binomial_distribution.dart
-import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
 import 'package:fl_chart/fl_chart.dart';
-import '../utils/math_utils.dart';
+import 'package:flutter/material.dart';
 
+import 'tool_kit.dart';
+
+/// 二项分布 B(n, p)。
+///
+///   期望 EX = n·p
+///   方差 DX = n·p·(1-p)
+///   P(X = i) = C(n,i)·p^i·(1-p)^(n-i),i = 0…n
+///
+/// 算式全部交给 Symja:p 允许写 `1/3` 这样的分数,精确概率就原样给分数。
+/// 只有画图要 double,才另跑一份数值版表达式(绘图是唯一允许在 Dart 侧碰数的地方)。
 class BinomialDistributionPage extends StatefulWidget {
+  const BinomialDistributionPage({super.key});
+
   @override
-  _BinomialDistributionPageState createState() => _BinomialDistributionPageState();
+  State<BinomialDistributionPage> createState() =>
+      _BinomialDistributionPageState();
 }
 
 class _BinomialDistributionPageState extends State<BinomialDistributionPage> {
-  final TextEditingController nController = TextEditingController();
-  final TextEditingController pController = TextEditingController();
-
-  String expectation = '';
-  String variance = '';
-  List<String> probabilities = [];
-  List<BarChartGroupData> barGroups = [];
+  final _n = ToolField('n (试验次数)', hint: '整数,如 4');
+  final _p = ToolField('p (成功概率)', hint: '分数或小数,如 1/3');
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('二项分布')),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: SingleChildScrollView( // 添加滚动容器
-          child: Column(
-            children: [
-              _buildInputField('n (试验次数)', nController),
-              _buildInputField('p (成功概率)', pController),
-              SizedBox(height: 20),
-              ElevatedButton(
-                onPressed: _calculate,
-                child: Text('计算'),
-              ),
-              SizedBox(height: 20),
-              if (expectation.isNotEmpty) ...[
-                _buildResultCard('期望', expectation),
-                _buildResultCard('方差', variance),
-                SizedBox(height: 10),
-                Text('概率分布:', style: TextStyle(fontWeight: FontWeight.bold)),
-                if (barGroups.isNotEmpty) ...[
-                  SizedBox(height: 10),
-                  Container(
-                    height: 300,
-                    child: BarChart(
-                      BarChartData(
-                        barTouchData: BarTouchData(
-                          enabled: false, // 禁用触摸交互
-                        ),
-                        titlesData: FlTitlesData(
-                          show: true,
-                          bottomTitles: AxisTitles(
-                            sideTitles: SideTitles(
-                              showTitles: true,
-                              getTitlesWidget: (value, meta) {
-                                return Padding(
-                                  padding: const EdgeInsets.only(top: 8.0),
-                                  child: Text(
-                                    value.toInt().toString(),
-                                    style: TextStyle(fontSize: 10),
-                                  ),
-                                );
-                              },
-                              reservedSize: 30,
-                            ),
-                          ),
-                          leftTitles: AxisTitles(
-                            sideTitles: SideTitles(
-                              showTitles: true,
-                              getTitlesWidget: (value, meta) {
-                                if (value % 0.1 == 0) {
-                                  return Text(
-                                    value.toStringAsFixed(1),
-                                    style: TextStyle(fontSize: 10),
-                                  );
-                                }
-                                return Text('');
-                              },
-                              reservedSize: 40,
-                            ),
-                          ),
-                          topTitles: AxisTitles(
-                            sideTitles: SideTitles(showTitles: false),
-                          ),
-                          rightTitles: AxisTitles(
-                            sideTitles: SideTitles(showTitles: false),
-                          ),
-                        ),
-                        borderData: FlBorderData(show: true),
-                        barGroups: barGroups,
-                        gridData: FlGridData(show: true),
-                        alignment: BarChartAlignment.spaceAround,
-                        maxY: _getMaxY(),
-                      ),
-                    ),
-                  ),
-                  SizedBox(height: 10),
-                ],
-                // 移除Expanded并替换为固定高度的ListView
-                Container(
-                  height: 200, // 设置固定高度
-                  child: ListView.builder(
-                    itemCount: probabilities.length,
-                    itemBuilder: (context, index) {
-                      return ListTile(
-                        title: Text(probabilities[index]),
-                      );
-                    },
-                  ),
-                ),
-              ],
-            ],
-          ),
-        ),
-      ),
-    );
-  }
-
-  double _getMaxY() {
-    if (barGroups.isEmpty) return 1.0;
-    double maxY = 0.0;
-    for (var group in barGroups) {
-      for (var rod in group.barRods) {
-        if (rod.toY > maxY) maxY = rod.toY;
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '二项分布',
+        fields: [_n, _p],
+        compute: (session) async {
+          // n 只用来数个数、定循环范围,取整数是记账不是算数;
+          // p 原样交给 Symja 解析(1/3 这种分数不能被 Dart 折成小数)。
+          final n = int.tryParse(_n.text);
+          if (n == null || n < 0) {
+            throw Exception('试验次数 n 必须是非负整数,现在是「${_n.text}」');
+          }
+          final p = _p.text;
+          if (p.isEmpty) throw Exception('请填写成功概率 p');
+
+          final outputs = <ToolOutput>[
+            ...await exactAndApprox(
+                session, '期望 EX', 'With[{n=$n,p=$p}, n*p]'),
+            ...await exactAndApprox(
+                session, '方差 DX', 'With[{n=$n,p=$p}, n*p*(1-p)]'),
+          ];
+
+          // 每个 i 的概率:整列一次算出来,再拆成一条一条,这样每条都能单独复制。
+          final exact = await session.compute(
+            'With[{n=$n,p=$p}, Table[Binomial[n,i]*p^i*(1-p)^(n-i), {i,0,n}]]',
+          );
+          final probs = parseSymjaList(exact.text);
+          for (var i = 0; i < probs.length; i++) {
+            outputs.add(ToolText('P(X = $i)', '${probs[i]}'));
+          }
+
+          // 图表:柱高是概率。Symja 给 double 的小数,Dart 只负责画。
+          final numeric = await session.compute(
+            'With[{n=$n,p=$p}, N[Table[Binomial[n,i]*p^i*(1-p)^(n-i), {i,0,n}], 8]]',
+          );
+          final values = parseSymjaList(numeric.text)
+              .map(asNumber)
+              .whereType<double>()
+              .toList(growable: false);
+          if (values.length == probs.length) {
+            outputs.add(ToolChart('概率分布', _barChart(values)));
+          }
+          return outputs;
+        },
+      );
+
+  /// 柱状图:最高的那一段(可能并排几个)涂红,左边蓝、右边绿 —— 保留原来的配色规则。
+  Widget _barChart(List<double> probs) {
+    var maxProb = 0.0;
+    for (final v in probs) {
+      if (v > maxProb) maxProb = v;
+    }
+    var firstMax = -1, lastMax = -1;
+    for (var i = 0; i < probs.length; i++) {
+      if (probs[i] == maxProb) {
+        if (firstMax < 0) firstMax = i;
+        lastMax = i;
       }
     }
-    return maxY * 1.1;
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: TextField(
-        controller: controller,
-        keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-        decoration: InputDecoration(
-          labelText: label,
-          border: OutlineInputBorder(),
-        ),
-      ),
-    );
-  }
-
-  Widget _buildResultCard(String title, String value) {
-    return Card(
-      child: ListTile(
-        title: Text(title),
-        subtitle: Text(value),
-      ),
-    );
-  }
-
-  Rational _combination(int n, int k) {
-    if (k < 0 || k > n) return Rational.zero;
-    if (k == 0 || k == n) return Rational.one;
-
-    BigInt result = BigInt.one;
-    for (int i = 1; i <= k; i++) {
-      result = result * BigInt.from(n - i + 1) ~/ BigInt.from(i);
+    Color colorAt(int i) {
+      if (i >= firstMax && i <= lastMax) return Colors.red;
+      return i < firstMax ? Colors.blue.shade300 : Colors.green.shade300;
     }
-    return Rational(result);
-  }
-
-  void _calculate() {
-    try {
-      final n = int.parse(nController.text);
-      final p = MathUtils.parseRational(pController.text);
-
-      final EX = MathUtils.fromInt(n) * p;
-      final DX = MathUtils.fromInt(n) * p * (Rational.one - p);
-
-      final probs = <String>[];
-      final barGroupsData = <BarChartGroupData>[];
-
-      // 首先计算所有概率并找到最大值
-      List<double> probValues = [];
-      double maxProb = 0.0;
-      
-      for (int i = 0; i <= n; i++) {
-        final comb = _combination(n, i);
-        final prob = comb * p.pow(i) * (Rational.one - p).pow(n - i);
-        final probDouble = prob.toDouble();
-        
-        probValues.add(probDouble);
-        if (probDouble > maxProb) {
-          maxProb = probDouble;
-        }
-        
-        probs.add('P(X = $i) = ${MathUtils.rationalToString(prob)}');
-      }
 
-      // 找到所有具有最大概率的i值
-      List<int> maxProbIndices = [];
-      for (int i = 0; i < probValues.length; i++) {
-        if (probValues[i] == maxProb) {
-          maxProbIndices.add(i);
-        }
-      }
-
-      // 确定最高组的范围
-      int minMaxIndex = maxProbIndices.reduce((a, b) => a < b ? a : b);
-      int maxMaxIndex = maxProbIndices.reduce((a, b) => a > b ? a : b);
-
-      // 构建柱状图数据
-      for (int i = 0; i <= n; i++) {
-        double probDouble = probValues[i];
-        
-        barGroupsData.add(
-          BarChartGroupData(
-            x: i,
-            barRods: [
-              BarChartRodData(
-                toY: probDouble,
-                color: _getBarColor(i, minMaxIndex, maxMaxIndex),
-                width: 16,
-                borderRadius: BorderRadius.circular(4),
+    return SizedBox(
+      height: 300,
+      child: BarChart(
+        BarChartData(
+          barTouchData: BarTouchData(enabled: false),
+          alignment: BarChartAlignment.spaceAround,
+          maxY: maxProb == 0 ? 1.0 : maxProb * 1.1,
+          borderData: FlBorderData(show: true),
+          gridData: FlGridData(show: true),
+          titlesData: FlTitlesData(
+            bottomTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 30,
+                getTitlesWidget: (value, meta) => Padding(
+                  padding: const EdgeInsets.only(top: 8),
+                  child: Text('${value.toInt()}',
+                      style: const TextStyle(fontSize: 10)),
+                ),
               ),
-            ],
+            ),
+            leftTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 40,
+                getTitlesWidget: (value, meta) => value % 0.1 == 0
+                    ? Text(value.toStringAsFixed(1),
+                        style: const TextStyle(fontSize: 10))
+                    : const Text(''),
+              ),
+            ),
+            topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+            rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
           ),
-        );
-      }
-
-      setState(() {
-        expectation = 'EX = ${MathUtils.rationalToString(EX)}';
-        variance = 'DX = ${MathUtils.rationalToString(DX)}';
-        probabilities = probs;
-        barGroups = barGroupsData;
-      });
-    } catch (e) {
-      setState(() {
-        expectation = '错误: $e';
-        variance = '';
-        probabilities = [];
-        barGroups = [];
-      });
-    }
-  }
-
-  Color _getBarColor(int x, int minMaxIndex, int maxMaxIndex) {
-    if (x >= minMaxIndex && x <= maxMaxIndex) {
-      // 最高组(可能有多组)
-      return Colors.red;
-    } else if (x < minMaxIndex) {
-      // 最高组左边的组
-      return Colors.blue.shade300;
-    } else {
-      // 最高组右边的组
-      return Colors.green.shade300;
-    }
-  }
-
-  @override
-  void dispose() {
-    nController.dispose();
-    pController.dispose();
-    super.dispose();
+          barGroups: [
+            for (var i = 0; i < probs.length; i++)
+              BarChartGroupData(
+                x: i,
+                barRods: [
+                  BarChartRodData(
+                    toY: probs[i],
+                    color: colorAt(i),
+                    width: 16,
+                    borderRadius: BorderRadius.circular(4),
+                  ),
+                ],
+              ),
+          ],
+        ),
+      ),
+    );
   }
-}
+}

+ 137 - 265
lib/tools/bisection_method.dart

@@ -1,280 +1,152 @@
 // tools/bisection_method.dart
 import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
 
-import '../symja.dart';
+import 'tool_kit.dart';
 
+/// 二分法求方程近似解。
+///
+/// 整个算法(循环、取中点、判号、记录过程)都在 **Symja 里一次跑完** ——
+/// Dart 只负责拼表达式和排版。以前是 Dart 写循环、每一步回调 Symja 算一次
+/// f(x):既多跑好几个来回,又把"算法"劈成两半。现在一条表达式返回
+/// 「过程表 + 中点 + 迭代次数 + 最终误差」,拆开显示即可。
 class BisectionMethodPage extends StatefulWidget {
+  const BisectionMethodPage({super.key});
+
   @override
-  _BisectionMethodPageState createState() => _BisectionMethodPageState();
+  State<BisectionMethodPage> createState() => _BisectionMethodPageState();
 }
 
 class _BisectionMethodPageState extends State<BisectionMethodPage> {
-  final TextEditingController _functionController = TextEditingController(text: 'x^3 - x - 2');
-  final TextEditingController _aController = TextEditingController(text: '1');
-  final TextEditingController _bController = TextEditingController(text: '2');
-  final TextEditingController _toleranceController = TextEditingController(text: '0.0001');
-  final TextEditingController _maxIterationsController = TextEditingController(text: '100');
-
-  List<String> _iterations = [];
-  String _result = '';
-  bool _isCalculating = false;
-
-  /// 用 Symja 求 f(x) 在 [x] 处的值。
-  ///
-  /// 表达式**直接交给 Symja 解析**(以前用 math_expressions:那是另一套语法、
-  /// 另一套优先级,同一个式子可能"在这里算得出来、在别处又不对")。
-  Future<double> _evaluateFunction(String function, double x) async {
-    final session = context.read<SymjaSession>();
-    final r = await session.compute('N[$function /. x -> $x]');
-    // Symja 的 N[...] 输出可能带精度标记(形如 3.14`2.5),去掉再解析
-    final cleaned = r.text.trim().replaceAll(RegExp(r'`[0-9.]*$'), '');
-    final v = double.tryParse(cleaned);
-    if (v == null) throw Exception('f($x) = ${r.text},不是数值');
-    return v;
-  }
-
-  Future<void> _calculate() async {
-    if (_isCalculating) return;
-    setState(() {
-      _isCalculating = true;
-      _iterations.clear();
-      _result = '';
-    });
-
-    try {
-      double a = double.parse(_aController.text);
-      double b = double.parse(_bController.text);
-      double tolerance = double.parse(_toleranceController.text);
-      int maxIterations = int.parse(_maxIterationsController.text);
-      String function = _functionController.text;
-
-      if (a >= b) {
-        throw Exception('区间左端点必须小于右端点');
-      }
-
-      double fa = await _evaluateFunction(function, a);
-      double fb = await _evaluateFunction(function, b);
-
-      if (fa.isNaN || fb.isNaN) {
-        throw Exception('函数在端点处计算失败,请检查表达式');
-      }
-
-      if (fa * fb > 0) {
-        throw Exception('函数在区间两端点必须异号\nf(a) = $fa, f(b) = $fb');
-      }
-
-      int iteration = 0;
-      double c = a;
-
-      while ((b - a).abs() > tolerance && iteration < maxIterations) {
-        c = (a + b) / 2;
-        double fc = await _evaluateFunction(function, c);
-
-        if (fc.isNaN) {
-          throw Exception('迭代过程中函数计算失败');
-        }
-
-        if (!mounted) return;
-        _iterations.add('迭代 ${iteration + 1}: a=${a.toStringAsFixed(6)}, b=${b.toStringAsFixed(6)}, c=${c.toStringAsFixed(6)}, f(c)=${fc.toStringAsFixed(6)}');
-
-        if (fc.abs() < tolerance) {
-          break;
-        } else if (fa * fc < 0) {
-          b = c;
-          fb = fc;
-        } else {
-          a = c;
-          fa = fc;
-        }
-
-        iteration++;
-      }
-
-      if (!mounted) return;
-      setState(() {
-        _result = '近似解: ${c.toStringAsFixed(8)}\n'
-                 '迭代次数: $iteration\n'
-                 '最终误差: ${(b - a).abs().toStringAsFixed(8)}';
-      });
+  // 输入框在 State 里建(外壳不持有它们,见 ToolScaffold 的注释)。
+  final _function = ToolField(
+    '函数 f(x)',
+    hint: '例如 x^3 - x - 2,变量用 x',
+    // 函数要打字母、^、括号,不能只给数字键盘。
+    keyboard: TextInputType.text,
+  );
+  final _a = ToolField('区间左端点 a');
+  final _b = ToolField('区间右端点 b');
+  final _tolerance = ToolField('精度 tolerance');
+  final _maxIterations = ToolField('最大迭代次数');
 
-    } on SymjaException catch (e) {
-      if (mounted) setState(() => _result = '错误: $e');
-    } catch (e) {
-      if (mounted) setState(() => _result = '错误: $e');
-    } finally {
-      if (mounted) setState(() => _isCalculating = false);
-    }
-  }
-
-  void _clear() {
-    setState(() {
-      _functionController.text = 'x^3 - x - 2';
-      _aController.text = '1';
-      _bController.text = '2';
-      _toleranceController.text = '0.0001';
-      _maxIterationsController.text = '100';
-      _iterations.clear();
-      _result = '';
-    });
+  @override
+  void initState() {
+    super.initState();
+    // ToolField 没有初值参数,默认值直接填在它暴露的 controller 上。
+    // (「重置」由外壳清空,不是恢复默认值。)
+    _function.controller.text = 'x^3 - x - 2';
+    _a.controller.text = '1';
+    _b.controller.text = '2';
+    _tolerance.controller.text = '0.0001';
+    _maxIterations.controller.text = '100';
   }
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: Text('二分法求根', style: TextStyle(color: Colors.black)),
-        backgroundColor: Colors.white,
-        elevation: 1,
-        iconTheme: IconThemeData(color: Colors.black),
-      ),
-      body: SingleChildScrollView(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '二分法求方程近似解',
-              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.deepPurple),
-            ),
-            SizedBox(height: 16),
-            Text(
-              '使用方法:\n'
-              '1. 输入函数表达式(支持: +, -, *, /, ^, sin, cos, tan, ln, e, sqrt 等)\n'
-              '2. 输入有根区间 [a, b]\n'
-              '3. 设置精度和最大迭代次数\n'
-              '4. 点击计算获取近似解\n\n'
-              '示例表达式:\n'
-              '• x^3 - x - 2\n'
-              '• sin(x) + cos(x)\n'
-              '• e^x - 2\n'
-              '• ln(x) + sqrt(x)',
-              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
-            ),
-            SizedBox(height: 20),
-            
-            // 函数输入
-            _buildInputField('函数 f(x)', '例如: x^3 - x - 2', _functionController),
-            SizedBox(height: 16),
-            
-            Row(
-              children: [
-                Expanded(child: _buildInputField('区间左端点 a', '1', _aController)),
-                SizedBox(width: 16),
-                Expanded(child: _buildInputField('区间右端点 b', '2', _bController)),
-              ],
-            ),
-            SizedBox(height: 16),
-            
-            Row(
-              children: [
-                Expanded(child: _buildInputField('精度 tolerance', '0.0001', _toleranceController)),
-                SizedBox(width: 16),
-                Expanded(child: _buildInputField('最大迭代次数', '100', _maxIterationsController)),
-              ],
-            ),
-            SizedBox(height: 24),
-            
-            // 按钮
-            Row(
-              children: [
-                Expanded(
-                  child: ElevatedButton(
-                    onPressed: _isCalculating ? null : _calculate,
-                    style: ElevatedButton.styleFrom(
-                      backgroundColor: Colors.deepPurple,
-                      padding: EdgeInsets.symmetric(vertical: 16),
-                    ),
-                    child: _isCalculating 
-                        ? SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white))
-                        : Text('计算', style: TextStyle(fontSize: 16, color: Colors.white)),
-                  ),
-                ),
-                SizedBox(width: 16),
-                Expanded(
-                  child: OutlinedButton(
-                    onPressed: _clear,
-                    style: OutlinedButton.styleFrom(
-                      padding: EdgeInsets.symmetric(vertical: 16),
-                    ),
-                    child: Text('清空', style: TextStyle(fontSize: 16)),
-                  ),
-                ),
-              ],
-            ),
-            SizedBox(height: 24),
-            
-            // 结果展示
-            if (_result.isNotEmpty) ...[
-              Container(
-                width: double.infinity,
-                padding: EdgeInsets.all(16),
-                decoration: BoxDecoration(
-                  color: _result.contains('错误') ? Colors.red[50] : Colors.green[50],
-                  borderRadius: BorderRadius.circular(8),
-                  border: Border.all(color: _result.contains('错误') ? Colors.red : Colors.green),
-                ),
-                child: Text(
-                  _result,
-                  style: TextStyle(
-                    fontSize: 16, 
-                    fontWeight: FontWeight.bold, 
-                    color: _result.contains('错误') ? Colors.red[800] : Colors.green[800]
-                  ),
-                ),
-              ),
-              SizedBox(height: 16),
-            ],
-            
-            // 迭代过程
-            if (_iterations.isNotEmpty) ...[
-              Text('迭代过程:', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
-              SizedBox(height: 8),
-              Container(
-                height: 200,
-                decoration: BoxDecoration(
-                  border: Border.all(color: Colors.grey[300]!),
-                  borderRadius: BorderRadius.circular(8),
-                ),
-                child: ListView.builder(
-                  itemCount: _iterations.length,
-                  itemBuilder: (context, index) {
-                    return Container(
-                      padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-                      decoration: BoxDecoration(
-                        border: index > 0 ? Border(top: BorderSide(color: Colors.grey[200]!)) : null,
-                        color: index % 2 == 0 ? Colors.grey[50] : Colors.white,
-                      ),
-                      child: Text(
-                        _iterations[index],
-                        style: TextStyle(fontSize: 12, fontFamily: 'Monospace'),
-                      ),
-                    );
-                  },
-                ),
-              ),
-            ],
-          ],
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '二分法求根',
+        // 有默认值,一打开就该看到解,不用先按一下按钮。
+        autoRun: true,
+        fields: [_function, _a, _b, _tolerance, _maxIterations],
+        intro: const Text(
+          '二分法求方程 f(x) = 0 的近似解:\n'
+          '要求区间 [a, b] 两端点处 f 异号;每次取中点把区间折半,\n'
+          '直到区间长度小于精度 tolerance 或达到最大迭代次数。\n\n'
+          '示例表达式:x^3 - x - 2、sin(x) + cos(x)、e^x - 2、ln(x) + sqrt(x)',
         ),
-      ),
-    );
-  }
+        compute: (session) async {
+          final f = _function.text;
+          if (f.isEmpty) throw Exception('请输入函数表达式 f(x)');
+          if (_a.text.isEmpty || _b.text.isEmpty) {
+            throw Exception('请填写区间端点 a 和 b');
+          }
+          final maxIter = int.tryParse(_maxIterations.text);
+          if (maxIter == null || maxIter <= 0) {
+            throw Exception('最大迭代次数必须是正整数');
+          }
+
+          // 整数端点补一个小数点,逼 Symja 走实数运算 ——
+          // 否则中点保持精确分数,过程表里会显示 3/2 而不是 1.5。
+          final ra = _real(_a.text);
+          final rb = _real(_b.text);
+
+          // 守卫(不是工具的数学):拿 Symja 算出的 f(a)、f(b) 和端点值,
+          // 在 Dart 里比大小 / 比符号。
+          final guard = parseSymjaList(
+            (await session.compute(
+              'N[{$f /. x -> $ra, $f /. x -> $rb, $ra, $rb}]',
+            ))
+                .text,
+          );
+          if (guard.length < 4) throw Exception('无法计算区间端点处的函数值');
+          final fa = asNumber(guard[0]);
+          final fb = asNumber(guard[1]);
+          final aVal = asNumber(guard[2]);
+          final bVal = asNumber(guard[3]);
+          if (fa == null || fb == null) {
+            throw Exception('函数在区间端点处无定义或不是实数,请检查表达式与区间');
+          }
+          if (aVal == null || bVal == null || aVal >= bVal) {
+            throw Exception(
+              '区间左端点必须小于右端点(a = ${guard[2]},b = ${guard[3]})',
+            );
+          }
+          if (fa * fb > 0) {
+            throw Exception('函数在区间两端点必须异号:f(a) = $fa,f(b) = $fb');
+          }
+
+          // 整个二分过程在 Symja 里跑完(循环、判号、Break 都在里面)。
+          final expr = 'Module[{f=$f, aa=$ra, bb=$rb, tol=${_tolerance.text}, '
+              'i=0, c, fa, fb, fc, out={}}, '
+              'fa=N[f/.x->aa]; fb=N[f/.x->bb]; '
+              'While[Abs[bb-aa]>tol && i<$maxIter, '
+              'c=(aa+bb)/2; fc=N[f/.x->c]; AppendTo[out,{i+1,aa,bb,c,fc}]; '
+              'Which[Abs[fc]<tol, Break[], fa*fc<0, bb=c; fb=fc, True, aa=c; fa=fc]; '
+              'i++]; '
+              '{out, c, i, Abs[bb-aa]}]';
+          final parsed = parseSymjaList((await session.compute(expr)).text);
+          if (parsed.length < 4) throw Exception('二分法计算没有返回预期结果');
+          final rows = parsed[0];
+          final cText = '${parsed[1]}';
+          final iterText = '${parsed[2]}';
+          final errText = '${parsed[3]}';
+
+          // 过程表拼成**一条** ToolText:它天然是一整块。
+          final lines = <String>[];
+          if (rows is List) {
+            for (final row in rows) {
+              if (row is! List || row.length < 5) continue;
+              lines.add('迭代 ${row[0]}: '
+                  'a=${_fmt6(row[1])}, '
+                  'b=${_fmt6(row[2])}, '
+                  'c=${_fmt6(row[3])}, '
+                  'f(c)=${_fmt6(row[4])}');
+            }
+          }
+
+          return <ToolOutput>[
+            ToolText('近似解', _fmt6(cText)),
+            ToolText('迭代次数', iterText),
+            ToolText('最终误差', _fmt6(errText)),
+            ToolText(
+              '迭代过程',
+              lines.isEmpty ? '(区间长度一开始就不大于精度,未迭代)' : lines.join('\n'),
+            ),
+          ];
+        },
+      );
+}
 
-  Widget _buildInputField(String label, String hint, TextEditingController controller) {
-    return Column(
-      crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
-        SizedBox(height: 4),
-        TextField(
-          controller: controller,
-          decoration: InputDecoration(
-            hintText: hint,
-            border: OutlineInputBorder(),
-            contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-          ),
-        ),
-      ],
-    );
-  }
-}
+/// 纯整数字面量补 `.`,让它成为实数:`1` → `1.`、`-2` → `-2.`;
+/// 小数、分数、科学计数法原样保留(交给 Symja 自己解析)。
+String _real(String s) {
+  final t = s.trim();
+  return RegExp(r'^[+-]?\d+$').hasMatch(t) ? '$t.' : t;
+}
+
+/// 把 Symja 的数值文本格式化成 6 位小数(格式化在 Dart 里做没关系);
+/// 解析不了(如符号 c)就原样返回。
+String _fmt6(Object? v) {
+  final s = v is String ? v : '$v';
+  final n = asNumber(s);
+  return n == null ? s : n.toStringAsFixed(6);
+}

+ 149 - 246
lib/tools/chi_square.dart

@@ -1,268 +1,171 @@
 // tools/chi_square.dart
-import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
 import 'package:fl_chart/fl_chart.dart';
-import '../utils/math_utils.dart';
+import 'package:flutter/material.dart';
+
+import 'tool_kit.dart';
 
+/// 卡方检验(2×2 列联表)。
+///
+///     K² = n(ad − bc)² / ((a+b)(c+d)(a+c)(b+d)),n = a+b+c+d
+///
+/// 精确值给分数、近似值给小数,都从 Symja 拿。图表里的比例数据同样走 Symja
+/// (`N[...]`),Dart 只负责把数字喂给 fl_chart —— 图表不参与复制。
 class ChiSquarePage extends StatefulWidget {
+  const ChiSquarePage({super.key});
+
   @override
-  _ChiSquarePageState createState() => _ChiSquarePageState();
+  State<ChiSquarePage> createState() => _ChiSquarePageState();
 }
 
 class _ChiSquarePageState extends State<ChiSquarePage> {
-  final TextEditingController aController = TextEditingController();
-  final TextEditingController bController = TextEditingController();
-  final TextEditingController cController = TextEditingController();
-  final TextEditingController dController = TextEditingController();
-
-  String result = '';
-  List<BarChartGroupData> barGroups = [];
-  bool showChart = false;
+  final _a = ToolField('a', hint: '第一行左');
+  final _b = ToolField('b', hint: '第一行右');
+  final _c = ToolField('c', hint: '第二行左');
+  final _d = ToolField('d', hint: '第二行右');
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('卡方检验')),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            Text('请输入2×2列联表数据:'),
-            SizedBox(height: 20),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('a', aController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('b', bController)),
-              ],
-            ),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('c', cController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('d', dController)),
-              ],
-            ),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算卡方值'),
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 16),
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '卡方检验',
+        fields: [_a, _b, _c, _d],
+        intro: const Text('请输入 2×2 列联表数据:'),
+        compute: (session) async {
+          final a = _need(_a);
+          final b = _need(_b);
+          final c = _need(_c);
+          final d = _need(_d);
+          final bind = 'a=$a,b=$b,c=$c,d=$d';
+
+          final k2 = 'With[{$bind}, Module[{nn=a+b+c+d}, '
+              'nn*(a*d-b*c)^2/((a+b)*(c+d)*(a+c)*(b+d))]]';
+
+          // 各组比例(给图表,不复制):组1 = a/(a+b)、b/(a+b),组2 = c/(c+d)、d/(c+d)
+          final prop =
+              'With[{$bind}, N[{a/(a+b), b/(a+b), c/(c+d), d/(c+d)}]]';
+          final p = parseSymjaList((await session.compute(prop)).text);
+          final n0 = p.isNotEmpty ? asNumber(p[0]) ?? 0.0 : 0.0;
+          final n1 = p.length > 1 ? asNumber(p[1]) ?? 0.0 : 0.0;
+          final n2 = p.length > 2 ? asNumber(p[2]) ?? 0.0 : 0.0;
+          final n3 = p.length > 3 ? asNumber(p[3]) ?? 0.0 : 0.0;
+
+          return [
+            ...await exactAndApprox(session, 'K²', k2),
+            ToolChart('各组比例', _barChart(n0, n1, n2, n3)),
+          ];
+        },
+      );
+
+  /// 等高堆积条形图:两组各按 类别A / 类别B 的比例堆积,纵轴百分比。
+  Widget _barChart(double a1, double b1, double a2, double b2) => Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          SizedBox(
+            height: 220,
+            child: BarChart(
+              BarChartData(
+                alignment: BarChartAlignment.spaceAround,
+                maxY: 1.0,
+                barTouchData: BarTouchData(enabled: false),
+                titlesData: FlTitlesData(
+                  show: true,
+                  bottomTitles: AxisTitles(
+                    sideTitles: SideTitles(
+                      showTitles: true,
+                      getTitlesWidget: (value, meta) =>
+                          Text(value.toInt() == 0 ? '组1' : '组2'),
+                    ),
                   ),
-                ),
-              ),
-            SizedBox(height: 20),
-            if (showChart && barGroups.isNotEmpty)
-              Expanded(
-                child: Card(
-                  elevation: 4,
-                  child: Padding(
-                    padding: EdgeInsets.all(16),
-                    child: Column(
-                      children: [
-                        Text(
-                          '等高堆积条形图',
-                          style: TextStyle(
-                            fontSize: 18,
-                            fontWeight: FontWeight.bold,
-                          ),
-                        ),
-                        SizedBox(height: 10),
-                        Expanded(
-                          child: BarChart(
-                            BarChartData(
-                              alignment: BarChartAlignment.spaceAround,
-                              maxY: 1.0,
-                              barTouchData: BarTouchData(
-                                enabled: false,
-                              ),
-                              titlesData: FlTitlesData(
-                                show: true,
-                                bottomTitles: AxisTitles(
-                                  sideTitles: SideTitles(
-                                    showTitles: true,
-                                    getTitlesWidget: (value, meta) {
-                                      switch (value.toInt()) {
-                                        case 0:
-                                          return Text('组1');
-                                        case 1:
-                                          return Text('组2');
-                                        default:
-                                          return Text('');
-                                      }
-                                    },
-                                  ),
-                                ),
-                                leftTitles: AxisTitles(
-                                  sideTitles: SideTitles(
-                                    showTitles: true,
-                                    getTitlesWidget: (value, meta) {
-                                      return Text('${(value * 100).toInt()}%');
-                                    },
-                                    reservedSize: 40,
-                                  ),
-                                ),
-                                topTitles: AxisTitles(
-                                  sideTitles: SideTitles(showTitles: false),
-                                ),
-                                rightTitles: AxisTitles(
-                                  sideTitles: SideTitles(showTitles: false),
-                                ),
-                              ),
-                              gridData: FlGridData(
-                                show: true,
-                                drawVerticalLine: false,
-                                getDrawingHorizontalLine: (value) {
-                                  return FlLine(
-                                    color: Colors.grey.withOpacity(0.3),
-                                    strokeWidth: 1,
-                                  );
-                                },
-                              ),
-                              borderData: FlBorderData(
-                                show: false,
-                              ),
-                              barGroups: barGroups,
-                            ),
-                          ),
-                        ),
-                        SizedBox(height: 10),
-                        Row(
-                          mainAxisAlignment: MainAxisAlignment.center,
-                          children: [
-                            _buildLegend(Colors.blue, '类别A'),
-                            SizedBox(width: 20),
-                            _buildLegend(Colors.red, '类别B'),
-                          ],
-                        ),
-                      ],
+                  leftTitles: AxisTitles(
+                    sideTitles: SideTitles(
+                      showTitles: true,
+                      reservedSize: 40,
+                      getTitlesWidget: (value, meta) =>
+                          Text('${(value * 100).toInt()}%'),
                     ),
                   ),
+                  topTitles: AxisTitles(
+                    sideTitles: SideTitles(showTitles: false),
+                  ),
+                  rightTitles: AxisTitles(
+                    sideTitles: SideTitles(showTitles: false),
+                  ),
+                ),
+                gridData: FlGridData(
+                  show: true,
+                  drawVerticalLine: false,
+                  getDrawingHorizontalLine: (value) => FlLine(
+                    color: Colors.grey.withValues(alpha: 0.3),
+                    strokeWidth: 1,
+                  ),
                 ),
+                borderData: FlBorderData(show: false),
+                barGroups: [
+                  BarChartGroupData(
+                    x: 0,
+                    groupVertically: true,
+                    barsSpace: 4,
+                    barRods: [
+                      BarChartRodData(
+                        fromY: 0,
+                        toY: a1,
+                        color: Colors.blue,
+                        width: 30,
+                      ),
+                      BarChartRodData(
+                        fromY: a1,
+                        toY: a1 + b1,
+                        color: Colors.red,
+                        width: 30,
+                      ),
+                    ],
+                  ),
+                  BarChartGroupData(
+                    x: 1,
+                    groupVertically: true,
+                    barsSpace: 4,
+                    barRods: [
+                      BarChartRodData(
+                        fromY: 0,
+                        toY: a2,
+                        color: Colors.blue,
+                        width: 30,
+                      ),
+                      BarChartRodData(
+                        fromY: a2,
+                        toY: a2 + b2,
+                        color: Colors.red,
+                        width: 30,
+                      ),
+                    ],
+                  ),
+                ],
               ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return TextField(
-      controller: controller,
-      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-      decoration: InputDecoration(
-        labelText: label,
-        border: OutlineInputBorder(),
-      ),
-    );
-  }
-
-  Widget _buildLegend(Color color, String text) {
-    return Row(
-      mainAxisSize: MainAxisSize.min,
-      children: [
-        Container(
-          width: 16,
-          height: 16,
-          color: color,
-        ),
-        SizedBox(width: 8),
-        Text(text),
-      ],
-    );
-  }
-
-  void _calculate() {
-  try {
-    final a = int.parse(aController.text);
-    final b = int.parse(bController.text);
-    final c = int.parse(cController.text);
-    final d = int.parse(dController.text);
-
-    final n = a + b + c + d;
-    final numerator = Rational.fromInt(n) *
-    Rational.fromInt((a * d - b * c) * (a * d - b * c));
-    final denominator = Rational.fromInt((a + b) * (c + d) * (a + c) * (b + d));
-    final K2 = numerator / denominator;
-
-    // 计算比例用于图表
-    final totalGroup1 = a + b;
-    final totalGroup2 = c + d;
-    
-    final proportionA1 = totalGroup1 > 0 ? a / totalGroup1 : 0.0;
-    final proportionB1 = totalGroup1 > 0 ? b / totalGroup1 : 0.0;
-    final proportionA2 = totalGroup2 > 0 ? c / totalGroup2 : 0.0;
-    final proportionB2 = totalGroup2 > 0 ? d / totalGroup2 : 0.0;
-
-    // 创建条形图数据 - 修复堆积问题
-    barGroups = [
-      BarChartGroupData(
-        x: 0,
-        groupVertically: true, // 启用垂直堆积
-        barsSpace: 4, // 条形之间的间距
-        barRods: [
-          BarChartRodData(
-            fromY: 0,
-            toY: proportionA1,
-            color: Colors.blue,
-            width: 30,
+            ),
           ),
-          BarChartRodData(
-            fromY: proportionA1, // 从第一个条形的顶部开始
-            toY: proportionA1 + proportionB1,
-            color: Colors.red,
-            width: 30,
+          const SizedBox(height: 10),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.center,
+            children: [
+              _legend(Colors.blue, '类别A'),
+              const SizedBox(width: 20),
+              _legend(Colors.red, '类别B'),
+            ],
           ),
         ],
-      ),
-      BarChartGroupData(
-        x: 1,
-        groupVertically: true, // 启用垂直堆积
-        barsSpace: 4, // 条形之间的间距
-        barRods: [
-          BarChartRodData(
-            fromY: 0,
-            toY: proportionA2,
-            color: Colors.blue,
-            width: 30,
-          ),
-          BarChartRodData(
-            fromY: proportionA2, // 从第一个条形的顶部开始
-            toY: proportionA2 + proportionB2,
-            color: Colors.red,
-            width: 30,
-          ),
+      );
+
+  Widget _legend(Color color, String text) => Row(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Container(width: 16, height: 16, color: color),
+          const SizedBox(width: 8),
+          Text(text),
         ],
-      ),
-    ];
+      );
 
-    setState(() {
-      result = 'K² = ${MathUtils.rationalToString(K2)}\n'
-      '近似值: ${K2.toDouble().toStringAsFixed(4)}';
-      showChart = true;
-    });
-  } catch (e) {
-    setState(() {
-      result = '错误: $e';
-      showChart = false;
-    });
+  /// 取一个标量输入框:空着或填了多个值都直接报中文错。
+  String _need(ToolField f) {
+    if (f.count == 0) throw Exception('请填写「${f.label}」');
+    if (f.count > 1) throw Exception('「${f.label}」只能填一个数');
+    return f.text;
   }
 }
-
-  @override
-  void dispose() {
-    aController.dispose();
-    bController.dispose();
-    cController.dispose();
-    dController.dispose();
-    super.dispose();
-  }
-}

+ 58 - 120
lib/tools/circle_equation.dart

@@ -1,137 +1,75 @@
 // tools/circle_equation.dart
 import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
-import '../utils/math_utils.dart';
 
+import 'tool_kit.dart';
+
+/// 圆的方程 —— 过三点的圆。
+///
+/// 设圆为 x² + y² + Dx + Ey + F = 0,把三个点代进去解 D、E、F。
+/// 未知量在 Symja 侧用 dd/ee/ff 命名(D、E 是 Symja 的内置名)。
+/// 三点共线时 Symja 的 Solve 返回空集,我们的表达式直接回一句中文说明。
 class CircleEquationPage extends StatefulWidget {
+  const CircleEquationPage({super.key});
+
   @override
-  _CircleEquationPageState createState() => _CircleEquationPageState();
+  State<CircleEquationPage> createState() => _CircleEquationPageState();
 }
 
 class _CircleEquationPageState extends State<CircleEquationPage> {
-  final TextEditingController x1Controller = TextEditingController();
-  final TextEditingController y1Controller = TextEditingController();
-  final TextEditingController x2Controller = TextEditingController();
-  final TextEditingController y2Controller = TextEditingController();
-  final TextEditingController x3Controller = TextEditingController();
-  final TextEditingController y3Controller = TextEditingController();
-
-  String result = '';
+  final _x1 = ToolField('点1 x', hint: '分数如 1/2,或小数');
+  final _y1 = ToolField('点1 y', hint: '分数如 1/2,或小数');
+  final _x2 = ToolField('点2 x', hint: '分数如 1/2,或小数');
+  final _y2 = ToolField('点2 y', hint: '分数如 1/2,或小数');
+  final _x3 = ToolField('点3 x', hint: '分数如 1/2,或小数');
+  final _y3 = ToolField('点3 y', hint: '分数如 1/2,或小数');
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('圆的方程')),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            Text('请输入三个点的坐标:'),
-            SizedBox(height: 10),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('点1 x', x1Controller)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('点1 y', y1Controller)),
-              ],
-            ),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('点2 x', x2Controller)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('点2 y', y2Controller)),
-              ],
-            ),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('点3 x', x3Controller)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('点3 y', y3Controller)),
-              ],
-            ),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算圆的方程'),
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 14, fontFamily: 'Monospace'),
-                  ),
-                ),
-              ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return TextField(
-      controller: controller,
-      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-      decoration: InputDecoration(
-        labelText: label,
-        border: OutlineInputBorder(),
-        hintText: '输入分数如: 1/2 或小数',
-      ),
-    );
-  }
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '圆的方程',
+        fields: [_x1, _y1, _x2, _y2, _x3, _y3],
+        intro: const Text('请输入三个点的坐标:'),
+        compute: (session) async {
+          final bind = 'x1=${_need(_x1)},y1=${_need(_y1)},'
+              'x2=${_need(_x2)},y2=${_need(_y2)},'
+              'x3=${_need(_x3)},y3=${_need(_y3)}';
+          final eqs = '{x1^2+y1^2+dd*x1+ee*y1+ff==0, '
+              'x2^2+y2^2+dd*x2+ee*y2+ff==0, '
+              'x3^2+y3^2+dd*x3+ee*y3+ff==0}';
+          const noCircle = '"三点共线(没有外接圆)"';
 
-  void _calculate() {
-    try {
-      final x1 = MathUtils.parseRational(x1Controller.text);
-      final y1 = MathUtils.parseRational(y1Controller.text);
-      final x2 = MathUtils.parseRational(x2Controller.text);
-      final y2 = MathUtils.parseRational(y2Controller.text);
-      final x3 = MathUtils.parseRational(x3Controller.text);
-      final y3 = MathUtils.parseRational(y3Controller.text);
+          // 三个系数
+          final defExpr = 'With[{$bind}, Module[{s}, s=Solve[$eqs,{dd,ee,ff}]; '
+              'If[s==={}, $noCircle, {dd,ee,ff} /. First[s]]]]';
+          final defs = parseSymjaList((await session.compute(defExpr)).text);
+          if (defs.length != 3) {
+            // 共线:Symja 返回的就是那句中文,直接显示。
+            return [ToolText('结果', '${defs.isEmpty ? '' : defs.first}')];
+          }
 
-      final z1 = x1 * x1 + y1 * y1;
-      final z2 = x2 * x2 + y2 * y2;
-      final z3 = x3 * x3 + y3 * y3;
+          // 圆心 (-dd/2, -ee/2) 与半径平方 (dd²+ee²-4ff)/4
+          final centerExpr =
+              'With[{$bind}, Module[{s}, s=Solve[$eqs,{dd,ee,ff}]; '
+              'If[s==={}, $noCircle, '
+              '{-dd/2, -ee/2, (dd^2+ee^2-4*ff)/4} /. First[s]]]]';
+          final center = parseSymjaList((await session.compute(centerExpr)).text);
 
-      final solutions = MathUtils.solveLinear2(
-        x1 - x2, y1 - y2, z2 - z1,
-        x1 - x3, y1 - y3, z3 - z1,
+          final out = <ToolOutput>[
+            ToolText('D', '${defs[0]}'),
+            ToolText('E', '${defs[1]}'),
+            ToolText('F', '${defs[2]}'),
+          ];
+          if (center.length == 3) {
+            out.add(ToolText('圆心', '(${center[0]}, ${center[1]})'));
+            out.add(ToolText('半径平方', '${center[2]}'));
+          }
+          return out;
+        },
       );
 
-      final D = solutions[0];
-      final E = solutions[1];
-      final F = -z1 - D * x1 - E * y1;
-      final r2 = (D * D + E * E - Rational.fromInt(4) * F)/Rational.fromInt(4);
-
-      setState(() {
-        result = '''
-      圆的方程: x² + y² + Dx + Ey + F = 0
-      D = ${MathUtils.rationalToString(D)}
-      E = ${MathUtils.rationalToString(E)}
-      F = ${MathUtils.rationalToString(F)}
-      圆心: (${MathUtils.rationalToString(-D / Rational.fromInt(2))},
-      ${MathUtils.rationalToString(-E / Rational.fromInt(2))})
-      半径平方: ${MathUtils.rationalToString(r2)}
-      ''';
-      });
-    } catch (e) {
-      setState(() {
-        result = '错误: $e\n注意: 三个点不能共线';
-      });
-    }
-  }
-
-  @override
-  void dispose() {
-    x1Controller.dispose();
-    y1Controller.dispose();
-    x2Controller.dispose();
-    y2Controller.dispose();
-    x3Controller.dispose();
-    y3Controller.dispose();
-    super.dispose();
+  /// 取一个标量输入框:空着或填了多个值都直接报中文错。
+  String _need(ToolField f) {
+    if (f.count == 0) throw Exception('请填写「${f.label}」');
+    if (f.count > 1) throw Exception('「${f.label}」只能填一个数');
+    return f.text;
   }
 }

+ 136 - 418
lib/tools/convolution_calculator.dart

@@ -1,453 +1,171 @@
 // tools/convolution_calculator.dart
 import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
 
+import 'tool_kit.dart';
+
+/// 卷积(分布列相加):把 X、Y 两个分布列逐对组合,得到 X + Y 的分布列。
+///
+/// 行数可变,输入框在 State 里按行维护(每次 setState 生成一份新的 fields 给外壳)。
+/// 概率全部交给 Symja:两两相乘、合并同取值时求和都是 Symja 算的精确分数,
+/// Dart 只在最后把"取值相同"的项归到一组(分组是记账,不是算数)。
 class ConvolutionCalculatorPage extends StatefulWidget {
+  const ConvolutionCalculatorPage({super.key});
+
   @override
-  _ConvolutionCalculatorPageState createState() => _ConvolutionCalculatorPageState();
+  State<ConvolutionCalculatorPage> createState() =>
+      _ConvolutionCalculatorPageState();
 }
 
-class _ConvolutionCalculatorPageState extends State<ConvolutionCalculatorPage> {
-  final List<TextEditingController> _xValueControllers = [];
-  final List<TextEditingController> _xProbControllers = [];
-  final List<TextEditingController> _yValueControllers = [];
-  final List<TextEditingController> _yProbControllers = [];
-  
-  List<Map<String, Rational>> _convolutionResult = [];
-  bool _showResult = false;
+/// 一行输入:一个取值 + 一个概率。
+class _Row {
+  _Row(String name, int index, String v, String p)
+      : value = ToolField('$name 取值 $index', hint: '如 0'),
+        prob = ToolField('$name 概率 $index', hint: '如 1/2') {
+    value.controller.text = v;
+    prob.controller.text = p;
+  }
+
+  final ToolField value;
+  final ToolField prob;
+}
+
+class _ConvolutionCalculatorPageState
+    extends State<ConvolutionCalculatorPage> {
+  // 只允许在末尾增删,行号(标签里的 1、2…)才不会错位。
+  final List<_Row> _x = [];
+  final List<_Row> _y = [];
 
   @override
   void initState() {
     super.initState();
-    // 初始化默认数据
-    _initializeDefaultData();
+    // 默认 X = (0, 1/2), (1, 1/2);Y = (0, 1/3), (1, 1/3), (2, 1/3)。
+    _x.add(_Row('X', 1, '0', '1/2'));
+    _x.add(_Row('X', 2, '1', '1/2'));
+    _y.add(_Row('Y', 1, '0', '1/3'));
+    _y.add(_Row('Y', 2, '1', '1/3'));
+    _y.add(_Row('Y', 3, '2', '1/3'));
   }
 
-  void _initializeDefaultData() {
-    // 添加X的默认数据
-    _addXRow(value: '0', prob: '1/2');
-    _addXRow(value: '1', prob: '1/2');
-    
-    // 添加Y的默认数据
-    _addYRow(value: '0', prob: '1/3');
-    _addYRow(value: '1', prob: '1/3');
-    _addYRow(value: '2', prob: '1/3');
+  void _addRow(String name, List<_Row> rows) {
+    setState(() => rows.add(_Row(name, rows.length + 1, '', '')));
   }
 
-  void _addXRow({String value = '', String prob = ''}) {
-    setState(() {
-      _xValueControllers.add(TextEditingController(text: value));
-      _xProbControllers.add(TextEditingController(text: prob));
-    });
+  void _removeRow(List<_Row> rows) {
+    if (rows.length <= 1) return;
+    setState(() => rows.removeLast());
   }
 
-  void _removeXRow(int index) {
-    setState(() {
-      _xValueControllers.removeAt(index);
-      _xProbControllers.removeAt(index);
-    });
-  }
+  List<ToolField> get _fields => [
+        for (final r in _x) ...[r.value, r.prob],
+        for (final r in _y) ...[r.value, r.prob],
+      ];
 
-  void _addYRow({String value = '', String prob = ''}) {
-    setState(() {
-      _yValueControllers.add(TextEditingController(text: value));
-      _yProbControllers.add(TextEditingController(text: prob));
-    });
-  }
+  String _pairs(List<_Row> rows) =>
+      '{${rows.map((r) => '{${r.value.text}, ${r.prob.text}}').join(', ')}}';
 
-  void _removeYRow(int index) {
-    setState(() {
-      _yValueControllers.removeAt(index);
-      _yProbControllers.removeAt(index);
-    });
-  }
+  String _probs(List<_Row> rows) =>
+      '{${rows.map((r) => r.prob.text).join(', ')}}';
 
-  Rational _parseRational(String input) {
-    try {
-      if (input.contains('/')) {
-        List<String> parts = input.split('/');
-        if (parts.length == 2) {
-          return Rational.parse(parts[0].trim()) / Rational.parse(parts[1].trim());
-        }
+  void _validate(String name, List<_Row> rows) {
+    for (var i = 0; i < rows.length; i++) {
+      if (rows[i].value.text.isEmpty || rows[i].prob.text.isEmpty) {
+        throw Exception('$name 的第 ${i + 1} 行还有空栏,请把取值和概率都填上');
       }
-      return Rational.parse(input.trim());
-    } catch (e) {
-      throw FormatException('无效的概率格式: $input');
     }
   }
 
-  void _calculateConvolution() {
-    try {
-      // 解析X分布
-      Map<Rational, Rational> xDistribution = {};
-      Rational xSum = Rational.zero;
-      for (int i = 0; i < _xValueControllers.length; i++) {
-        Rational value = _parseRational(_xValueControllers[i].text);
-        Rational prob = _parseRational(_xProbControllers[i].text);
-        xDistribution[value] = prob;
-        xSum += prob;
-      }
-
-      // 解析Y分布
-      Map<Rational, Rational> yDistribution = {};
-      Rational ySum = Rational.zero;
-      for (int i = 0; i < _yValueControllers.length; i++) {
-        Rational value = _parseRational(_yValueControllers[i].text);
-        Rational prob = _parseRational(_yProbControllers[i].text);
-        yDistribution[value] = prob;
-        ySum += prob;
-      }
-
-      // 验证概率和是否为1
-      if (xSum != Rational.one) {
-        throw FormatException('X的概率和必须为1,当前为: $xSum');
-      }
-      if (ySum != Rational.one) {
-        throw FormatException('Y的概率和必须为1,当前为: $ySum');
-      }
-
-      // 计算卷积
-      Map<Rational, Rational> convolution = {};
-      for (var xEntry in xDistribution.entries) {
-        for (var yEntry in yDistribution.entries) {
-          Rational sumValue = xEntry.key + yEntry.key;
-          Rational prob = xEntry.value * yEntry.value;
-          convolution[sumValue] = (convolution[sumValue] ?? Rational.zero) + prob;
-        }
-      }
-
-      // 转换为列表并排序
-      _convolutionResult = convolution.entries
-          .map((entry) => {'value': entry.key, 'probability': entry.value})
-          .toList();
-      
-      _convolutionResult.sort((a, b) => a['value']!.compareTo(b['value']!));
-
-      setState(() {
-        _showResult = true;
-      });
-
-    } catch (e) {
-      _showErrorDialog(e.toString());
-    }
-  }
-
-  void _showErrorDialog(String message) {
-    showDialog(
-      context: context,
-      builder: (context) => AlertDialog(
-        title: Text('计算错误'),
-        content: Text(message),
-        actions: [
-          TextButton(
-            onPressed: () => Navigator.of(context).pop(),
-            child: Text('确定'),
-          ),
-        ],
-      ),
-    );
-  }
-
-  void _clearAll() {
-    setState(() {
-      _xValueControllers.clear();
-      _xProbControllers.clear();
-      _yValueControllers.clear();
-      _yProbControllers.clear();
-      _convolutionResult.clear();
-      _showResult = false;
-      _initializeDefaultData();
-    });
-  }
-
   @override
-  void dispose() {
-    for (var controller in _xValueControllers) {
-      controller.dispose();
-    }
-    for (var controller in _xProbControllers) {
-      controller.dispose();
-    }
-    for (var controller in _yValueControllers) {
-      controller.dispose();
-    }
-    for (var controller in _yProbControllers) {
-      controller.dispose();
-    }
-    super.dispose();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: Text('卷积分布列计算'),
-        backgroundColor: Colors.white,
-        elevation: 1,
-        actions: [
-          IconButton(
-            icon: Icon(Icons.clear_all),
-            onPressed: _clearAll,
-            tooltip: '清空所有',
-          ),
-        ],
-      ),
-      body: SingleChildScrollView(
-        padding: EdgeInsets.all(16),
-        child: Column(
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '卷积分布列',
+        fields: _fields,
+        intro: Column(
           crossAxisAlignment: CrossAxisAlignment.start,
           children: [
-            _buildDistributionInput('随机变量 X 的分布列', _xValueControllers, _xProbControllers, _addXRow, _removeXRow),
-            SizedBox(height: 24),
-            _buildDistributionInput('随机变量 Y 的分布列', _yValueControllers, _yProbControllers, _addYRow, _removeYRow),
-            SizedBox(height: 24),
-            Center(
-              child: ElevatedButton.icon(
-                onPressed: _calculateConvolution,
-                icon: Icon(Icons.calculate),
-                label: Text('计算卷积分布列'),
-                style: ElevatedButton.styleFrom(
-                  padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12),
-                ),
-              ),
-            ),
-            SizedBox(height: 24),
-            if (_showResult) _buildResultTable(),
+            _sectionTitle('X 的分布列', 'X', _x),
+            _sectionTitle('Y 的分布列', 'Y', _y),
           ],
         ),
-      ),
-    );
-  }
-
-  Widget _buildDistributionInput(
-    String title,
-    List<TextEditingController> valueControllers,
-    List<TextEditingController> probControllers,
-    VoidCallback onAdd,
-    Function(int) onRemove,
-  ) {
-    return Card(
-      elevation: 2,
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
+        compute: (session) async {
+          _validate('X', _x);
+          _validate('Y', _y);
+
+          final xPairs = _pairs(_x);
+          final yPairs = _pairs(_y);
+
+          // 概率之和必须为 1:交给 Symja 求 Total。
+          final xSum = await session.compute('Total[${_probs(_x)}]');
+          if (xSum.text != '1') {
+            throw Exception('X 的概率之和必须为 1,当前为 ${xSum.text}');
+          }
+          final ySum = await session.compute('Total[${_probs(_y)}]');
+          if (ySum.text != '1') {
+            throw Exception('Y 的概率之和必须为 1,当前为 ${ySum.text}');
+          }
+
+          // 卷积:逐对组合,先不合并(Symja 给的是一对对 {取值, 概率})。
+          final conv = await session.compute(
+            'With[{x=$xPairs, y=$yPairs}, '
+            'Flatten[Table[{x[[i,1]]+y[[j,1]], x[[i,2]]*y[[j,2]]}, '
+            '{i,1,Length[x]}, {j,1,Length[y]}], 1]]',
+          );
+          final pairs = parseSymjaList(conv.text);
+
+          // 按取值原文分组(纯记账)。每组的概率仍交给 Symja 相加,保证是精确分数。
+          final order = <String>[];
+          final groups = <String, List<String>>{};
+          for (final item in pairs) {
+            if (item is! List || item.length < 2) continue;
+            final v = '${item[0]}';
+            final p = '${item[1]}';
+            groups.putIfAbsent(v, () {
+              order.add(v);
+              return <String>[];
+            }).add(p);
+          }
+
+          // 排序也交给 Symja:Sort 按数值大小排,比 Dart 的字符串排序可靠
+          // (字符串排序会把 10 排在 2 前面)。取值不多,单独问一次开销很小。
+          final sorted = order.isEmpty
+              ? const <Object>[]
+              : parseSymjaList(
+                  (await session.compute('Sort[{${order.join(', ')}}]')).text);
+
+          final outputs = <ToolOutput>[];
+          final groupSums = <String>[];
+          for (final v in sorted) {
+            final key = '$v';
+            final ps = groups[key];
+            if (ps == null) continue;
+            final total = await session.compute('Total[{${ps.join(', ')}}]');
+            groupSums.add(total.text);
+            outputs.add(ToolText('X + Y = $key', total.text));
+          }
+
+          final grand = await session.compute('Total[{${groupSums.join(', ')}}]');
+          outputs.add(ToolText('概率总和', grand.text));
+          return outputs;
+        },
+      );
+
+  Widget _sectionTitle(String title, String name, List<_Row> rows) => Padding(
+        padding: const EdgeInsets.only(top: 8),
+        child: Row(
           children: [
-            Text(
-              title,
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 16),
-            _buildTableHeader(),
-            SizedBox(height: 8),
-            ...List.generate(valueControllers.length, (index) {
-              return _buildInputRow(
-                valueControllers[index],
-                probControllers[index],
-                index,
-                onRemove,
-                valueControllers.length,
-              );
-            }),
-            SizedBox(height: 16),
-            Center(
-              child: OutlinedButton.icon(
-                onPressed: onAdd,
-                icon: Icon(Icons.add, size: 18),
-                label: Text('添加一行'),
-              ),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildTableHeader() {
-    return Row(
-      children: [
-        Expanded(
-          flex: 2,
-          child: Text('取值', style: TextStyle(fontWeight: FontWeight.bold)),
-        ),
-        SizedBox(width: 16),
-        Expanded(
-          flex: 3,
-          child: Text('概率 (支持分数如 1/2)', style: TextStyle(fontWeight: FontWeight.bold)),
-        ),
-        SizedBox(width: 16),
-        Expanded(
-          flex: 1,
-          child: Text('操作', style: TextStyle(fontWeight: FontWeight.bold)),
-        ),
-      ],
-    );
-  }
-
-  Widget _buildInputRow(
-    TextEditingController valueController,
-    TextEditingController probController,
-    int index,
-    Function(int) onRemove,
-    int totalRows,
-  ) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: Row(
-        children: [
-          Expanded(
-            flex: 2,
-            child: TextField(
-              controller: valueController,
-              decoration: InputDecoration(
-                hintText: '如: 0, 1, 2...',
-                border: OutlineInputBorder(),
-                contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-              ),
-              keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
+            Expanded(
+              child: Text(title,
+                  style: const TextStyle(fontWeight: FontWeight.bold)),
             ),
-          ),
-          SizedBox(width: 16),
-          Expanded(
-            flex: 3,
-            child: TextField(
-              controller: probController,
-              decoration: InputDecoration(
-                hintText: '如: 1/2, 0.25, 0.3',
-                border: OutlineInputBorder(),
-                contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
-              ),
+            TextButton.icon(
+              onPressed: () => _removeRow(rows),
+              icon: const Icon(Icons.remove, size: 18),
+              label: const Text('删除一行'),
             ),
-          ),
-          SizedBox(width: 16),
-          Expanded(
-            flex: 1,
-            child: IconButton(
-              icon: Icon(Icons.remove_circle, color: Colors.red),
-              onPressed: totalRows > 1 ? () => onRemove(index) : null,
-              tooltip: '删除此行',
+            TextButton.icon(
+              onPressed: () => _addRow(name, rows),
+              icon: const Icon(Icons.add, size: 18),
+              label: const Text('添加一行'),
             ),
-          ),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildResultTable() {
-    return Card(
-      elevation: 2,
-      color: Colors.green[50],
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '卷积分布列 X + Y',
-              style: TextStyle(
-                fontSize: 18,
-                fontWeight: FontWeight.bold,
-                color: Colors.green[800],
-              ),
-            ),
-            SizedBox(height: 16),
-            _buildResultTableHeader(),
-            ..._convolutionResult.map(_buildResultRow).toList(),
-            SizedBox(height: 16),
-            _buildProbabilitySum(),
           ],
         ),
-      ),
-    );
-  }
-
-  Widget _buildResultTableHeader() {
-    return Container(
-      padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
-      decoration: BoxDecoration(
-        color: Colors.green[100],
-        borderRadius: BorderRadius.vertical(top: Radius.circular(8)),
-      ),
-      child: Row(
-        children: [
-          Expanded(
-            child: Text('X + Y 取值', style: TextStyle(fontWeight: FontWeight.bold)),
-          ),
-          Expanded(
-            child: Text('概率 (分数)', style: TextStyle(fontWeight: FontWeight.bold)),
-          ),
-          Expanded(
-            child: Text('概率 (小数)', style: TextStyle(fontWeight: FontWeight.bold)),
-          ),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildResultRow(Map<String, Rational> entry) {
-    Rational value = entry['value']!;
-    Rational probability = entry['probability']!;
-    double decimalProb = probability.toDouble();
-
-    return Container(
-      padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16),
-      decoration: BoxDecoration(
-        border: Border(bottom: BorderSide(color: Colors.green[100]!)),
-      ),
-      child: Row(
-        children: [
-          Expanded(
-            child: Text(
-              value.toString(),
-              style: TextStyle(fontSize: 16),
-            ),
-          ),
-          Expanded(
-            child: Text(
-              _toFractionString(probability),
-              style: TextStyle(fontSize: 16, fontFamily: 'Monospace'),
-            ),
-          ),
-          Expanded(
-            child: Text(
-              decimalProb.toStringAsFixed(6),
-              style: TextStyle(fontSize: 16, fontFamily: 'Monospace'),
-            ),
-          ),
-        ],
-      ),
-    );
-  }
-
-  String _toFractionString(Rational rational) {
-    if (rational.denominator == BigInt.one) {
-      return rational.numerator.toString();
-    }
-    return '${rational.numerator}/${rational.denominator}';
-  }
-
-  Widget _buildProbabilitySum() {
-    Rational total = _convolutionResult.fold(
-      Rational.zero,
-      (sum, entry) => sum + entry['probability']!,
-    );
-
-    return Container(
-      padding: EdgeInsets.all(12),
-      decoration: BoxDecoration(
-        color: Colors.green[100],
-        borderRadius: BorderRadius.circular(8),
-      ),
-      child: Row(
-        mainAxisAlignment: MainAxisAlignment.spaceBetween,
-        children: [
-          Text('概率总和:', style: TextStyle(fontWeight: FontWeight.bold)),
-          Text(
-            '${_toFractionString(total)} = ${total.toDouble().toStringAsFixed(6)}',
-            style: TextStyle(fontWeight: FontWeight.bold, fontFamily: 'Monospace'),
-          ),
-        ],
-      ),
-    );
-  }
-}
+      );
+}

+ 49 - 96
lib/tools/cuo_wei_xiang_jian.dart

@@ -1,108 +1,61 @@
 // tools/cuo_wei_xiang_jian.dart
 import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
-import '../utils/math_utils.dart';
 
+import 'tool_kit.dart';
+
+/// 错位相减法。
+///
+/// 通项 aₙ = (a·n + b)·qⁿ⁺ᵐ 的数列,前 n 项和可以写成
+///     Sₙ = (A·n + B)·qⁿ − B
+/// 其中 A = a·q^(m+1)/(q−1)、B = (b·q^(m+1) − A)/(q−1)。
+///
+/// B 的表达式里把 A 的定义整个代进去,省掉"先算一次、把结果拼回表达式"这种来回,
+/// Symja 各自求值一次就够。
 class CuoWeiXiangJianPage extends StatefulWidget {
+  const CuoWeiXiangJianPage({super.key});
+
   @override
-  _CuoWeiXiangJianPageState createState() => _CuoWeiXiangJianPageState();
+  State<CuoWeiXiangJianPage> createState() => _CuoWeiXiangJianPageState();
 }
 
 class _CuoWeiXiangJianPageState extends State<CuoWeiXiangJianPage> {
-  final TextEditingController aController = TextEditingController();
-  final TextEditingController bController = TextEditingController();
-  final TextEditingController qController = TextEditingController();
-  final TextEditingController mController = TextEditingController();
-
-  String result = '';
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('错位相减法')),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            Text(
-              '通项为: aₙ = (a·n + b) · qⁿ⁺ᵐ',
-              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 20),
-            _buildInputField('a (系数)', aController),
-            _buildInputField('b (常数)', bController),
-            _buildInputField('q (公比)', qController),
-            _buildInputField('m (指数偏移)', mController),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算'),
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 16),
-                  ),
-                ),
-              ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: TextField(
-        controller: controller,
-        keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-        decoration: InputDecoration(
-          labelText: label,
-          border: OutlineInputBorder(),
-          hintText: '输入分数如: 1/2 或小数',
-        ),
-      ),
-    );
-  }
-
-  void _calculate() {
-    try {
-      final a = MathUtils.parseRational(aController.text);
-      final b = MathUtils.parseRational(bController.text);
-      final q = MathUtils.parseRational(qController.text);
-      final m = int.parse(mController.text);
-
-      // 将指数统一为n-1
-      final aAdj = a * q.pow(m + 1);
-      final bAdj = b * q.pow(m + 1);
-
-      // 计算A,B
-      final A = aAdj / (q - Rational.one);
-      final B = (bAdj - A) / (q - Rational.one);
-
-      setState(() {
-        result = '结果: Sₙ = (A·n + B) · qⁿ - B\n'
-      'A = ${MathUtils.rationalToString(A)}\n'
-      'B = ${MathUtils.rationalToString(B)}';
-      });
-    } catch (e) {
-      setState(() {
-        result = '错误: $e';
-      });
-    }
-  }
+  final _a = ToolField('a (系数)', hint: '输入分数如 1/2,或小数');
+  final _b = ToolField('b (常数)', hint: '输入分数如 1/2,或小数');
+  final _q = ToolField('q (公比)', hint: '输入分数如 1/2,或小数');
+  final _m = ToolField('m (指数偏移)', hint: '整数,如 1');
 
   @override
-  void dispose() {
-    aController.dispose();
-    bController.dispose();
-    qController.dispose();
-    mController.dispose();
-    super.dispose();
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '错位相减法',
+        fields: [_a, _b, _q, _m],
+        intro: const Text('通项为: aₙ = (a·n + b) · qⁿ⁺ᵐ'),
+        compute: (session) async {
+          final a = _need(_a);
+          final b = _need(_b);
+          final q = _need(_q);
+          final m = int.tryParse(_need(_m));
+          if (m == null) {
+            throw Exception('「m (指数偏移)」必须是整数');
+          }
+
+          // va/vb/vq/vm 这些前缀名是为了避开 Symja 的内置名(E、D、N 等)。
+          final bind = 'va=$a,vb=$b,vq=$q,vm=$m';
+          final aExpr = 'With[{$bind}, va*vq^(vm+1)/(vq-1)]';
+          // B 里把 A 的定义整个代进去,不依赖上一条结果。
+          final bExpr =
+              'With[{$bind}, (vb*vq^(vm+1)-(va*vq^(vm+1)/(vq-1)))/(vq-1)]';
+
+          return [
+            ...await exactAndApprox(session, 'A', aExpr),
+            ...await exactAndApprox(session, 'B', bExpr),
+          ];
+        },
+      );
+
+  /// 取一个标量输入框:空着或填了多个值都直接报中文错。
+  String _need(ToolField f) {
+    if (f.count == 0) throw Exception('请填写「${f.label}」');
+    if (f.count > 1) throw Exception('「${f.label}」只能填一个数');
+    return f.text;
   }
 }

+ 39 - 0
lib/tools/cycle_coloring.dart

@@ -0,0 +1,39 @@
+import 'package:flutter/material.dart';
+
+import 'tool_kit.dart';
+
+/// n 边形染色:用 k 种颜色给 n 边形的 n 个顶点染色,相邻顶点不同色。
+///
+/// 圈图 Cₙ 的色多项式是 (k−1)ⁿ + (−1)ⁿ(k−1)。
+class CycleColoringPage extends StatefulWidget {
+  const CycleColoringPage({super.key});
+
+  @override
+  State<CycleColoringPage> createState() => _CycleColoringPageState();
+}
+
+class _CycleColoringPageState extends State<CycleColoringPage> {
+  final _n = ToolField('n (边数)', hint: '不小于 3 的整数',
+      keyboard: TextInputType.number);
+  final _k = ToolField('k (颜色数)', hint: '正整数', keyboard: TextInputType.number);
+
+  @override
+  Widget build(BuildContext context) => ToolScaffold(
+        title: 'n 边形染色',
+        fields: [_n, _k],
+        intro: const Text('用 k 种颜色给 n 边形的顶点染色,相邻顶点不同色\n'
+            '方案数 = (k−1)ⁿ + (k−1)·(−1)ⁿ'),
+        compute: (session) async {
+          final n = int.tryParse(_n.text);
+          final k = int.tryParse(_k.text);
+          if (n == null || n < 3) throw Exception('n 要填一个不小于 3 的整数');
+          if (k == null || k < 1) throw Exception('k 要填一个正整数');
+
+          return exactAndApprox(
+            session,
+            '染色方案数',
+            '($k-1)^$n + ($k-1)*(-1)^$n',
+          );
+        },
+      );
+}

+ 99 - 261
lib/tools/function_plotter.dart

@@ -1,16 +1,15 @@
 // tools/function_plotter.dart
+//
+// 函数图像绘制:一个或多个 y = f(x) 原样交给 Symja 采样,再用 [CurvePlot] 画。
+//
+// 采样部分(区间、点数、由 sample 拿点)照旧不变;变的只是外壳 —— 输入框交给
+// [ToolScaffold],绘制按钮就是外壳的「计算」,重置按钮也是外壳自带的。
 import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
 
 import '../curve_plot.dart';
 import '../symja.dart';
+import 'tool_kit.dart';
 
-/// 函数图像绘制:把一个或多个 y = f(x) 交给 Symja 采样,再用项目自己的
-/// [CurvePlot] 画出来。
-///
-/// 为什么改成按钮触发的异步绘制:Symja 的求值都跑在原生侧的专用线程上,只能
-/// 异步拿结果;旧的 `math_expressions` 是同步的,才能每次 build 都重算一遍。
-/// 现在按下「绘制」采样一次,采样期间禁用按钮并显示忙碌状态。
 class FunctionPlotterPage extends StatefulWidget {
   const FunctionPlotterPage({super.key});
 
@@ -19,17 +18,15 @@ class FunctionPlotterPage extends StatefulWidget {
 }
 
 class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
-  /// 固定的采样区间与点数。
-  ///
-  /// 以前这里是用户可以填的 x/y 范围;现在横纵范围由图下面的滑动条控制,
-  /// 输入框就用不上了。点取得密一些,放大到一小段时才有足够的点。
+  /// 固定的采样区间与点数。横纵范围由图下面的滑条控制,点取得密一些,
+  /// 放大到一小段时也还有足够的点。
   static const double _sampleFrom = -10;
   static const double _sampleTo = 10;
   static const int _sampleCount = 800;
 
   static const int _maxCurves = 5;
 
-  /// 每条曲线的固定颜色,下标与输入框一一对应(沿用旧版配色)。
+  /// 每条曲线的固定颜色,下标与输入框一一对应。
   static const List<Color> _curveColors = <Color>[
     Colors.blue,
     Colors.red,
@@ -38,270 +35,111 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
     Colors.purple,
   ];
 
-  final List<TextEditingController> _exprs = <TextEditingController>[
-    TextEditingController(text: 'x^2'),
+  /// 可增减的表达式输入框。列表**就地增删**(对象始终是同一个),
+  /// 外壳靠 `identical(fields)` 判断要不要清空结果,换新 List 会把结果清掉。
+  final List<ToolField> _exprs = <ToolField>[
+    ToolField(
+      '函数 1',
+      hint: '例如 x^2、Sin[x]、Sqrt[x]',
+      keyboard: TextInputType.text,
+    ),
   ];
 
-  List<CurveSeries> _series = const <CurveSeries>[];
-
-  /// 每个输入框的错误信息,与 [_exprs] 下标对齐;null 表示该项正常。
-  List<String?> _errors = const <String?>[];
-
-  /// 坐标范围本身的错误(与某条曲线无关),显示在绘图区。
-
-  bool _busy = false;
-
-  @override
-  void initState() {
-    super.initState();
-    // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
-    WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
-  }
-
-  @override
-  void dispose() {
-    for (final c in _exprs) {
-      c.dispose();
-    }
-    super.dispose();
-  }
-
   void _addCurve() {
     if (_exprs.length >= _maxCurves) return;
-    setState(() => _exprs.add(TextEditingController()));
-  }
-
-  void _removeCurve(int index) {
-    if (_exprs.length <= 1) return;
-    setState(() => _exprs.removeAt(index).dispose());
-  }
-
-  Future<void> _plot() async {
-    if (_busy) return;
-
-    final session = context.read<SymjaSession>();
     setState(() {
-      _busy = true;
-      // 每次重绘都重建,长度与输入框对齐,旧错误自然被清掉。
-      _errors = List<String?>.filled(_exprs.length, null);
+      _exprs.add(ToolField(
+        '函数 ${_exprs.length + 1}',
+        hint: '例如 x^2、Sin[x]、Sqrt[x]',
+        keyboard: TextInputType.text,
+      ));
     });
-
-    final result = <CurveSeries>[];
-    // 逐条采样:一条失败不影响其它条,错误只挂到它自己那一栏。
-    for (var i = 0; i < _exprs.length; i++) {
-      final raw = _exprs[i].text.trim();
-      if (raw.isEmpty) continue;
-      try {
-        // 表达式**原样交给 Symja 解析** —— 不再自己转换,谁解析谁负责,
-        // 免得"工具里算得出来、控制台里又不对"。
-        final expr = raw;
-        // 采样区间固定,不再由输入框决定 —— 横纵范围交给图下面的滑动条。
-        // 点取得密一些(800 个),这样放大到一小段时也还有足够的点。
-        final s = await session.sample(
-          expr,
-          variable: 'x',
-          from: _sampleFrom,
-          to: _sampleTo,
-          count: _sampleCount,
-        );
-        if (!mounted) return;
-        result.add(CurveSeries(
-          label: raw,
-          color: _curveColors[i],
-          // 非有限值(极点处的 ComplexInfinity 等)原样保留,
-          // CurvePlot 会把它们当成抬笔断点,这里不做过滤。
-          points: <Offset>[
-            for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
-          ],
-        ));
-      } on SymjaException catch (e) {
-        if (!mounted) return;
-        _errors[i] = '$e';
-      }
-    }
-
-    if (!mounted) return;
-    setState(() {
-      _series = result;
-      _busy = false;
-    });
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: const Text('函数图像绘制', style: TextStyle(color: Colors.black)),
-        backgroundColor: Colors.white,
-        elevation: 1,
-        actions: [
-          IconButton(
-            icon: const Icon(Icons.refresh),
-            onPressed: _busy ? null : _plot,
-            tooltip: '重新绘制',
-          ),
-        ],
-      ),
-      body: SingleChildScrollView(
-        child: Padding(
-          padding: const EdgeInsets.all(16),
-          child: Column(
-            children: [
-              _buildInputs(),
-              const SizedBox(height: 16),
-              SizedBox(
-                height: MediaQuery.of(context).size.height * 0.5,
-                child: _buildPlotArea(),
-              ),
-            ],
-          ),
-        ),
-      ),
-    );
   }
 
-  Widget _buildInputs() {
-    return Card(
-      elevation: 4,
-      child: Padding(
-        padding: const EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            const Text(
-              '函数表达式 (使用 x 作为变量)',
-              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
-            ),
-            const SizedBox(height: 10),
-            const Text(
-              '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
-              style: TextStyle(fontSize: 12, color: Colors.grey),
-            ),
-            const SizedBox(height: 10),
-            const SizedBox(height: 10),
-            Container(
-              constraints: const BoxConstraints(maxHeight: 240),
-              child: ListView.builder(
-                shrinkWrap: true,
-                itemCount: _exprs.length,
-                itemBuilder: _buildExprRow,
-              ),
-            ),
-            const SizedBox(height: 10),
-            Row(
-              children: [
-                if (_exprs.length < _maxCurves)
-                  OutlinedButton.icon(
-                    onPressed: _busy ? null : _addCurve,
-                    icon: const Icon(Icons.add),
-                    label: const Text('添加函数'),
-                  ),
-                const Spacer(),
-                ElevatedButton.icon(
-                  onPressed: _busy ? null : _plot,
-                  icon: _busy
-                      ? const SizedBox(
-                          width: 16,
-                          height: 16,
-                          child: CircularProgressIndicator(strokeWidth: 2),
-                        )
-                      : const Icon(Icons.show_chart),
-                  label: Text(_busy ? '绘制中…' : '绘制图像'),
-                  style: ElevatedButton.styleFrom(
-                    backgroundColor: Colors.blue,
-                    foregroundColor: Colors.white,
-                  ),
-                ),
-              ],
-            ),
-          ],
-        ),
-      ),
-    );
+  void _removeCurve() {
+    if (_exprs.length <= 1) return;
+    // 不 dispose 被移除的控制器:外壳约定不 dispose 输入框,
+    // 而且这一帧旧的 TextField 可能还引用着它,提前 dispose 会抛异常。
+    setState(() => _exprs.removeLast());
   }
 
-  Widget _buildExprRow(BuildContext context, int index) {
-    return Padding(
-      padding: const EdgeInsets.symmetric(vertical: 5),
-      child: Row(
+  Widget _intro() => Column(
         crossAxisAlignment: CrossAxisAlignment.start,
         children: [
-          Container(
-            width: 20,
-            height: 20,
-            margin: const EdgeInsets.only(top: 16),
-            decoration: BoxDecoration(
-              color: _curveColors[index],
-              shape: BoxShape.circle,
-            ),
+          const Text('函数表达式 (使用 x 作为变量)'),
+          const Text(
+            '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
+            style: TextStyle(fontSize: 12),
           ),
-          const SizedBox(width: 10),
-          Expanded(
-            child: Column(
-              crossAxisAlignment: CrossAxisAlignment.start,
-              children: [
-                TextField(
-                  controller: _exprs[index],
-                  decoration: InputDecoration(
-                    hintText: '例如: x^2, Sin[x], Sqrt[x], Exp[x]',
-                    border: const OutlineInputBorder(),
-                    errorText: index < _errors.length ? _errors[index] : null,
-                  ),
-                ),
-              ],
-            ),
+          const SizedBox(height: 8),
+          Row(
+            children: [
+              OutlinedButton.icon(
+                onPressed: _exprs.length < _maxCurves ? _addCurve : null,
+                icon: const Icon(Icons.add),
+                label: const Text('添加函数'),
+              ),
+              const SizedBox(width: 12),
+              OutlinedButton.icon(
+                onPressed: _exprs.length > 1 ? _removeCurve : null,
+                icon: const Icon(Icons.remove),
+                label: const Text('删除函数'),
+              ),
+            ],
           ),
-          const SizedBox(width: 10),
-          if (_exprs.length > 1)
-            IconButton(
-              icon: const Icon(Icons.remove, color: Colors.red),
-              tooltip: '删除该函数',
-              onPressed: () => _removeCurve(index),
-            ),
         ],
-      ),
-    );
-  }
-
-  Widget _buildPlotArea() {
-    return Card(
-      elevation: 4,
-      child: Padding(
-        padding: const EdgeInsets.all(16),
-        child: Column(
-          children: [
-            const Text(
-              '函数图像',
-              style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
-            ),
-            const SizedBox(height: 10),
-            Expanded(child: _buildPlot()),
-          ],
-        ),
-      ),
-    );
-  }
+      );
 
-  Widget _buildPlot() {
-    if (_busy) {
-      return const Center(child: CircularProgressIndicator());
-    }
-    if (_series.isEmpty) {
-      return Center(
-        child: Column(
-          mainAxisAlignment: MainAxisAlignment.center,
-          children: const [
-            Icon(Icons.functions, size: 50, color: Colors.grey),
-            SizedBox(height: 10),
-            Text('暂无函数图像可显示', style: TextStyle(color: Colors.grey)),
-            Text(
-              '请输入函数表达式后点击「绘制图像」',
-              style: TextStyle(color: Colors.grey, fontSize: 12),
-            ),
-          ],
-        ),
+  @override
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '函数图像绘制',
+        // 默认已经有一个 x^2,打开就该看到图。
+        autoRun: true,
+        fields: _exprs,
+        intro: _intro(),
+        compute: (session) async {
+          final series = <CurveSeries>[];
+          final failed = <String>[];
+          // 逐条采样:一条失败只记下它自己,别的照画。
+          for (var i = 0; i < _exprs.length; i++) {
+            final raw = _exprs[i].text;
+            if (raw.isEmpty) continue;
+            try {
+              // 表达式原样交给 Symja 解析,谁解析谁负责。
+              final s = await session.sample(
+                raw,
+                variable: 'x',
+                from: _sampleFrom,
+                to: _sampleTo,
+                count: _sampleCount,
+              );
+              series.add(CurveSeries(
+                label: raw,
+                color: _curveColors[i % _curveColors.length],
+                // 非有限值(极点处的 ComplexInfinity 等)原样保留,
+                // CurvePlot 会把它们当成抬笔断点。
+                points: <Offset>[
+                  for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
+                ],
+              ));
+            } on SymjaException catch (e) {
+              failed.add('$raw:$e');
+            }
+          }
+          if (series.isEmpty && failed.isEmpty) {
+            throw Exception('请至少填写一个函数表达式');
+          }
+          return <ToolOutput>[
+            if (series.isNotEmpty)
+              ToolChart(
+                '函数图像',
+                // CurvePlot 内部用 Expanded,必须给一个确定高度,
+                // 否则放在外层 Column 里会因为高度无界而报错。
+                SizedBox(height: 360, child: CurvePlot(series: series)),
+              ),
+            if (failed.isNotEmpty)
+              ToolText('未能绘制的函数', failed.join('\n')),
+          ];
+        },
       );
-    }
-    return CurvePlot(series: _series);
-  }
 }

+ 45 - 0
lib/tools/group_allocation.dart

@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+
+import 'tool_kit.dart';
+
+/// 分组分配:n 个人分成 k 个**有区别的**组(组不空着)。
+///
+/// 先把 n 个人分成 k 堆(第二类斯特林数 S(n,k),堆之间不区分),
+/// 再把 k 堆分给 k 个有名字的组(k! 种)—— 所以答案是 k!·S(n,k)。
+class GroupAllocationPage extends StatefulWidget {
+  const GroupAllocationPage({super.key});
+
+  @override
+  State<GroupAllocationPage> createState() => _GroupAllocationPageState();
+}
+
+class _GroupAllocationPageState extends State<GroupAllocationPage> {
+  final _n = ToolField('n (人数)', hint: '正整数', keyboard: TextInputType.number);
+  final _k = ToolField('k (组数)', hint: '正整数', keyboard: TextInputType.number);
+
+  @override
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '分组分配',
+        fields: [_n, _k],
+        intro: const Text('把 n 个人分成 k 个有区别的组(每组至少一人)\n'
+            '方案数 = k! · S(n, k),S 是第二类斯特林数'),
+        compute: (session) async {
+          final n = int.tryParse(_n.text);
+          final k = int.tryParse(_k.text);
+          if (n == null || n < 1) throw Exception('n 要填一个正整数');
+          if (k == null || k < 1) throw Exception('k 要填一个正整数');
+
+          return [
+            ...await exactAndApprox(
+              session,
+              '方案数 k!·S($n,$k)',
+              'Factorial[$k]*StirlingS2[$n, $k]',
+            ),
+            ToolText(
+              '斯特林数 S($n,$k)',
+              (await session.compute('StirlingS2[$n, $k]')).text,
+            ),
+          ];
+        },
+      );
+}

+ 145 - 257
lib/tools/histogram.dart

@@ -1,276 +1,164 @@
 // tools/histogram.dart
-import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
-import '../utils/math_utils.dart';
 import 'package:fl_chart/fl_chart.dart';
+import 'package:flutter/material.dart';
 
+import 'tool_kit.dart';
+
+/// 直方图统计。
+///
+///   组中值 midᵢ = a + i·d + d/2(i 从 0 起)
+///   均值 = Σ midᵢfᵢ
+///   方差 = Σ (midᵢ-均值)²fᵢ
+///   中位数 / 百分位数:累计频率首次超过目标 p 的那一组内插
+///
+/// 频率和、均值、方差、分位数全部交给 Symja 求精确值;图表要 double,
+/// 才另跑一份 `N[...]` 的数值版。
 class HistogramPage extends StatefulWidget {
+  const HistogramPage({super.key});
+
   @override
-  _HistogramPageState createState() => _HistogramPageState();
+  State<HistogramPage> createState() => _HistogramPageState();
 }
 
 class _HistogramPageState extends State<HistogramPage> {
-  final TextEditingController leftEndpointController = TextEditingController();
-  final TextEditingController groupDistanceController = TextEditingController();
-  final TextEditingController frequenciesController = TextEditingController();
-  final TextEditingController percentileController = TextEditingController();
-
-  String result = '';
-  List<BarChartGroupData>? barGroups;
-  List<String>? groupLabels;
-  double? maxFrequencyDensity;
+  final _a = ToolField('左端点 a', hint: '如 0');
+  final _d = ToolField('组距 d', hint: '如 1');
+  final _f = ToolField('各组频率(用空格分隔)', hint: '用空格分隔多个值,和为 1');
+  final _pct = ToolField('百分位数(可选)', hint: '留空则不算,如 3/4 或 0.75');
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('直方图统计')),
-      body: SingleChildScrollView( // 添加滚动视图
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            _buildInputField('左端点', leftEndpointController),
-            _buildInputField('组距', groupDistanceController),
-            _buildInputField('各组频率(用空格分隔)', frequenciesController),
-            _buildInputField('百分位数(可选)', percentileController),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算'),
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 14, fontFamily: 'Monospace'),
-                  ),
-                ),
-              ),
-            if (barGroups != null && barGroups!.isNotEmpty)
-              SizedBox( // 将 Expanded 改为 SizedBox 并指定高度
-                height: 400, // 设置固定高度
-                child: Card(
-                  child: Padding(
-                    padding: EdgeInsets.all(16),
-                    child: Column(
-                      children: [
-                        Text(
-                          '直方图',
-                          style: TextStyle(
-                            fontSize: 18,
-                            fontWeight: FontWeight.bold,
-                          ),
-                        ),
-                        SizedBox(height: 10),
-                        Expanded( // 这个 Expanded 可以保留,因为它是在 Column 内部
-                          child: BarChart(
-                            BarChartData(
-                              barGroups: barGroups,
-                              alignment: BarChartAlignment.spaceBetween,
-                              groupsSpace: 0,
-                              barTouchData: BarTouchData(
-                                enabled: false,
-                              ),
-                              borderData: FlBorderData(show: true),
-                              gridData: FlGridData(show: false),
-                              titlesData: FlTitlesData(
-                                bottomTitles: AxisTitles(
-                                  sideTitles: SideTitles(
-                                    showTitles: true,
-                                    getTitlesWidget: (value, meta) {
-                                      final index = value.toInt();
-                                      if (index >= 0 && index < (groupLabels?.length ?? 0)) {
-                                        return Padding(
-                                          padding: const EdgeInsets.only(top: 8.0),
-                                          child: Text(
-                                            groupLabels![index],
-                                            style: TextStyle(
-                                              fontSize: 10,
-                                              fontWeight: FontWeight.bold,
-                                            ),
-                                            textAlign: TextAlign.center,
-                                          ),
-                                        );
-                                      }
-                                      return Text('');
-                                    },
-                                    reservedSize: 40,
-                                  ),
-                                ),
-                                leftTitles: AxisTitles(
-                                  sideTitles: SideTitles(
-                                    showTitles: true,
-                                    reservedSize: 40,
-                                    getTitlesWidget: (value, meta) {
-                                      return Text(value.toStringAsFixed(2));
-                                    },
-                                  ),
-                                ),
-                                rightTitles: AxisTitles(
-                                  sideTitles: SideTitles(showTitles: false),
-                                ),
-                                topTitles: AxisTitles(
-                                  sideTitles: SideTitles(showTitles: false),
-                                ),
-                              ),
-                              maxY: maxFrequencyDensity != null 
-                                  ? (maxFrequencyDensity! * 1.1)
-                                  : 1.0,
-                            ),
-                          ),
-                        ),
-                      ],
-                    ),
-                  ),
-                ),
-              ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: TextField(
-        controller: controller,
-        keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-        decoration: InputDecoration(
-          labelText: label,
-          border: OutlineInputBorder(),
-          hintText: label.contains('频率') ? '用空格分隔多个值' : '输入分数如: 1/2 或小数',
-        ),
-      ),
-    );
-  }
-
-  void _calculate() {
-    try {
-      final a = MathUtils.parseRational(leftEndpointController.text);
-      final d = MathUtils.parseRational(groupDistanceController.text);
-      final frequencies = frequenciesController.text.split(' ').map((s) => MathUtils.parseRational(s)).toList();
-
-      Rational sumFreq = MathUtils.zero;
-      for (final f in frequencies) {
-        sumFreq += f;
-      }
-
-      if (sumFreq != MathUtils.one) {
-        setState(() {
-          result = '错误: 频率之和必须为1,当前为 ${MathUtils.rationalToString(sumFreq)}';
-          barGroups = null;
-          groupLabels = null;
-        });
-        return;
-      }
-
-      // 计算均值
-      Rational mean = MathUtils.zero;
-      for (int i = 0; i < frequencies.length; i++) {
-        mean += (a + MathUtils.fromInt(i) * d + d / MathUtils.two) * frequencies[i];
-      }
-
-      // 计算方差
-      Rational variance = MathUtils.zero;
-      for (int i = 0; i < frequencies.length; i++) {
-        final midPoint = a + MathUtils.fromInt(i) * d + d / MathUtils.two;
-        variance += (midPoint - mean).pow(2) * frequencies[i];
-      }
-
-      String output = '均值: ${MathUtils.rationalToString(mean)} (近似值: ${mean.toDouble()})\n'
-      '方差: ${MathUtils.rationalToString(variance)} (近似值: ${variance.toDouble()})\n';
-
-      // 计算中位数
-      Rational cumulative = MathUtils.zero;
-      for (int i = 0; i < frequencies.length; i++) {
-        if (cumulative + frequencies[i] > MathUtils.half) {
-          final median = a + MathUtils.fromInt(i) * d - (cumulative - MathUtils.half) / frequencies[i] * d;
-          output += '中位数: ${MathUtils.rationalToString(median)} (近似值: ${median.toDouble()})\n';
-          break;
-        }
-        cumulative += frequencies[i];
-      }
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '直方图统计',
+        fields: [_a, _d, _f, _pct],
+        compute: (session) async {
+          final a = _a.text, d = _d.text;
+          if (a.isEmpty || d.isEmpty) throw Exception('请填写左端点 a 和组距 d');
+          if (_f.count == 0) throw Exception('请至少填一个频率');
+          final f = _f.list;
+
+          // 频率之和必须为 1:交给 Symja 求 Total,原文不是 1 就报错。
+          final sum = await session.compute('Total[$f]');
+          if (sum.text != '1') {
+            throw Exception('各组频率之和必须为 1,当前为 ${sum.text}');
+          }
 
-      // 计算百分位数
-      if (percentileController.text.isNotEmpty) {
-        final p = MathUtils.parseRational(percentileController.text);
-        cumulative = MathUtils.zero;
-        for (int i = 0; i < frequencies.length; i++) {
-          if (cumulative + frequencies[i] > p) {
-            final percentile = a + MathUtils.fromInt(i) * d - (cumulative - p) / frequencies[i] * d;
-            output += '${MathUtils.rationalToString(p)} 百分位数: ${MathUtils.rationalToString(percentile)} (近似值: ${percentile.toDouble()})';
-            break;
+          // mid 在均值和方差两个式子里都要用,用嵌套 With 定义一次,避免重复一大串。
+          final mean = 'With[{a=$a,d=$d,f=$f}, '
+              'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, Total[mid*f]]]';
+          final variance = 'With[{a=$a,d=$d,f=$f}, '
+              'With[{mid=Table[a+i*d+d/2,{i,0,Length[f]-1}]}, '
+              'Total[(mid-Total[mid*f])^2*f]]]';
+
+          final outputs = <ToolOutput>[
+            ...await exactAndApprox(session, '均值', mean),
+            ...await exactAndApprox(session, '方差', variance),
+            ...await exactAndApprox(session, '中位数', _quantile(a, d, f, '1/2')),
+          ];
+
+          if (_pct.text.isNotEmpty) {
+            outputs.addAll(await exactAndApprox(
+              session,
+              '${_pct.text} 百分位数',
+              _quantile(a, d, f, _pct.text),
+            ));
           }
-          cumulative += frequencies[i];
-        }
-      }
 
-      // 生成直方图数据
-      _generateHistogramData(a, d, frequencies);
+          // 图表:横轴标签 [左, 右),左端点/右端点用精确值;柱高是频率密度 f/d,用数值。
+          final lr = parseSymjaList((await session.compute(
+            'With[{a=$a,d=$d,f=$f}, Table[{a+i*d, a+(i+1)*d}, {i,0,Length[f]-1}]]',
+          ))
+              .text);
+          final dens = parseSymjaList((await session.compute(
+            'With[{a=$a,d=$d,f=$f}, N[Table[f[[i+1]]/d, {i,0,Length[f]-1}], 8]]',
+          ))
+              .text)
+              .map(asNumber)
+              .whereType<double>()
+              .toList(growable: false);
+          if (lr.length == dens.length && dens.isNotEmpty) {
+            outputs.add(ToolChart('直方图', _histChart(lr, dens)));
+          }
+          return outputs;
+        },
+      );
 
-      setState(() {
-        result = output;
-      });
-    } catch (e) {
-      setState(() {
-        result = '错误: $e';
-        barGroups = null;
-        groupLabels = null;
-      });
+  /// 累计频率首次超过 p 的那一组内插。中位数把 p 取 1/2。
+  String _quantile(String a, String d, String f, String p) =>
+      'With[{a=$a,d=$d,f=$f,p=$p}, '
+      'Module[{cum=Accumulate[f],k}, '
+      'k=First[FirstPosition[cum, q_ /; q>p]]; '
+      'a+(k-1)*d+(p-If[k==1,0,cum[[k-1]]])/f[[k]]*d]]';
+
+  Widget _histChart(List<Object> lr, List<double> dens) {
+    var maxD = 0.0;
+    for (final v in dens) {
+      if (v > maxD) maxD = v;
     }
-  }
-
-  void _generateHistogramData(Rational a, Rational d, List<Rational> frequencies) {
-    barGroups = [];
-    groupLabels = [];
-    maxFrequencyDensity = 0.0;
-
-    for (int i = 0; i < frequencies.length; i++) {
-      final left = a + MathUtils.fromInt(i) * d;
-      final right = left + d;
-      
-      // 计算频率/组距作为纵坐标
-      final frequencyDensity = (frequencies[i] / d).toDouble();
-      
-      // 更新最大频率密度
-      if (frequencyDensity > maxFrequencyDensity!) {
-        maxFrequencyDensity = frequencyDensity;
-      }
-
-      barGroups!.add(
-        BarChartGroupData(
-          x: i,
-          barRods: [
-            BarChartRodData(
-              toY: frequencyDensity,
-              width: 16,
-              color: Colors.blue,
-              borderRadius: BorderRadius.zero,
+    final labels = <String>[
+      for (final item in lr)
+        if (item is List && item.length == 2)
+          '[${item[0]}, ${item[1]})'
+        else
+          '$item',
+    ];
+
+    return SizedBox(
+      height: 400,
+      child: BarChart(
+        BarChartData(
+          alignment: BarChartAlignment.spaceBetween,
+          groupsSpace: 0,
+          maxY: maxD == 0 ? 1.0 : maxD * 1.1,
+          barTouchData: BarTouchData(enabled: false),
+          borderData: FlBorderData(show: true),
+          gridData: FlGridData(show: false),
+          titlesData: FlTitlesData(
+            bottomTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 40,
+                getTitlesWidget: (value, meta) {
+                  final i = value.toInt();
+                  if (i < 0 || i >= labels.length) return const Text('');
+                  return Padding(
+                    padding: const EdgeInsets.only(top: 8),
+                    child: Text(
+                      labels[i],
+                      style: const TextStyle(
+                          fontSize: 10, fontWeight: FontWeight.bold),
+                      textAlign: TextAlign.center,
+                    ),
+                  );
+                },
+              ),
             ),
+            leftTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 40,
+                getTitlesWidget: (value, meta) =>
+                    Text(value.toStringAsFixed(2)),
+              ),
+            ),
+            topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+            rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+          ),
+          barGroups: [
+            for (var i = 0; i < dens.length; i++)
+              BarChartGroupData(
+                x: i,
+                barRods: [
+                  BarChartRodData(
+                    toY: dens[i],
+                    width: 16,
+                    color: Colors.blue,
+                    borderRadius: BorderRadius.zero,
+                  ),
+                ],
+              ),
           ],
         ),
-      );
-
-      groupLabels!.add(
-        '[${MathUtils.rationalToString(left)}, ${MathUtils.rationalToString(right)})',
-      );
-    }
-
-    // 确保maxFrequencyDensity不为0
-    if (maxFrequencyDensity == 0.0) {
-      maxFrequencyDensity = 1.0;
-    }
-  }
-
-  @override
-  void dispose() {
-    leftEndpointController.dispose();
-    groupDistanceController.dispose();
-    frequenciesController.dispose();
-    percentileController.dispose();
-    super.dispose();
+      ),
+    );
   }
-}
+}

+ 147 - 253
lib/tools/hypergeometric_distribution.dart

@@ -1,269 +1,163 @@
 // tools/hypergeometric_distribution.dart
-import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
 import 'package:fl_chart/fl_chart.dart';
-import '../utils/math_utils.dart';
+import 'package:flutter/material.dart';
 
+import 'tool_kit.dart';
+
+/// 超几何分布。
+///
+///   期望 EX = n·M/N
+///   方差 DX = n·M·(N-M)·(N-n) / (N²·(N-1))
+///   P(X = k) = C(M,k)·C(N-M,n-k) / C(N,n),k = max(0, n-(N-M)) … min(n, M)
+///
+/// Symja 里 `N` 是内置名(数值近似),所以表达式里的变量一律换成 nn/mm/kk。
 class HypergeometricDistributionPage extends StatefulWidget {
+  const HypergeometricDistributionPage({super.key});
+
   @override
-  _HypergeometricDistributionPageState createState() => _HypergeometricDistributionPageState();
+  State<HypergeometricDistributionPage> createState() =>
+      _HypergeometricDistributionPageState();
 }
 
-class _HypergeometricDistributionPageState extends State<HypergeometricDistributionPage> {
-  final TextEditingController nController = TextEditingController(); // 抽取数
-  final TextEditingController NController = TextEditingController(); // 总数
-  final TextEditingController MController = TextEditingController(); // 满足条件数
-
-  String expectation = '';
-  String variance = '';
-  List<String> probabilities = [];
-  List<BarChartGroupData> barGroups = [];
+class _HypergeometricDistributionPageState
+    extends State<HypergeometricDistributionPage> {
+  final _total = ToolField('总数 N', hint: '整数,如 10');
+  final _good = ToolField('满足条件数 M', hint: '整数,如 4');
+  final _draw = ToolField('抽取数 n', hint: '整数,如 3');
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('超几何分布')),
-      body: SingleChildScrollView( // 添加滚动支持
-        child: Padding(
-          padding: EdgeInsets.all(16),
-          child: Column(
-            children: [
-              _buildInputField('总数 N', NController),
-              _buildInputField('满足条件数 M', MController),
-              _buildInputField('抽取数 n', nController),
-              SizedBox(height: 20),
-              ElevatedButton(
-                onPressed: _calculate,
-                child: Text('计算'),
-              ),
-              SizedBox(height: 20),
-              if (expectation.isNotEmpty) ...[
-                _buildResultCard('期望', expectation),
-                _buildResultCard('方差', variance),
-                SizedBox(height: 10),
-                Text('概率分布:', style: TextStyle(fontWeight: FontWeight.bold)),
-                if (barGroups.isNotEmpty) ...[
-                  SizedBox(height: 10),
-                  Container(
-                    height: 300,
-                    child: BarChart(
-                      BarChartData(
-                        barTouchData: BarTouchData(
-                          enabled: false,
-                        ),
-                        titlesData: FlTitlesData(
-                          show: true,
-                          bottomTitles: AxisTitles(
-                            sideTitles: SideTitles(
-                              showTitles: true,
-                              getTitlesWidget: (value, meta) {
-                                return Padding(
-                                  padding: const EdgeInsets.only(top: 8.0),
-                                  child: Text(
-                                    value.toInt().toString(),
-                                    style: TextStyle(fontSize: 10),
-                                  ),
-                                );
-                              },
-                              reservedSize: 30,
-                            ),
-                          ),
-                          leftTitles: AxisTitles(
-                            sideTitles: SideTitles(
-                              showTitles: true,
-                              getTitlesWidget: (value, meta) {
-                                if (value % 0.1 == 0) {
-                                  return Text(
-                                    value.toStringAsFixed(1),
-                                    style: TextStyle(fontSize: 10),
-                                  );
-                                }
-                                return Text('');
-                              },
-                              reservedSize: 40,
-                            ),
-                          ),
-                          topTitles: AxisTitles(
-                            sideTitles: SideTitles(showTitles: false),
-                          ),
-                          rightTitles: AxisTitles(
-                            sideTitles: SideTitles(showTitles: false),
-                          ),
-                        ),
-                        borderData: FlBorderData(show: true),
-                        barGroups: barGroups,
-                        gridData: FlGridData(show: true),
-                        alignment: BarChartAlignment.spaceAround,
-                        maxY: _getMaxY(),
-                      ),
-                    ),
-                  ),
-                  SizedBox(height: 10),
-                ],
-                // 移除 Expanded 和 ListView.builder,改用简单的 Column
-                Column(
-                  children: probabilities.map((prob) => 
-                    Card(
-                      child: ListTile(
-                        title: Text(prob),
-                      ),
-                    )
-                  ).toList(),
-                ),
-              ],
-            ],
-          ),
-        ),
-      ),
-    );
-  }
-
-  double _getMaxY() {
-    if (barGroups.isEmpty) return 1.0;
-    double maxY = 0.0;
-    for (var group in barGroups) {
-      for (var rod in group.barRods) {
-        if (rod.toY > maxY) maxY = rod.toY;
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '超几何分布',
+        fields: [_total, _good, _draw],
+        compute: (session) async {
+          final total = int.tryParse(_total.text);
+          final good = int.tryParse(_good.text);
+          final draw = int.tryParse(_draw.text);
+          if (total == null || good == null || draw == null) {
+            throw Exception('总数 N、满足条件数 M、抽取数 n 都必须是整数');
+          }
+          if (total <= 1) {
+            throw Exception('总数 N 必须大于 1(方差公式要除以 N-1)');
+          }
+          if (good < 0 || good > total) {
+            throw Exception('满足条件数 M 必须在 0 到 N 之间(现在是 $good,N = $total)');
+          }
+          if (draw < 0 || draw > total) {
+            throw Exception('抽取数 n 必须在 0 到 N 之间(现在是 $draw,N = $total)');
+          }
+
+          // nn=N,mm=M,kk=n(避开内置名 N)。
+          final wrap = 'With[{nn=$total,mm=$good,kk=$draw},';
+
+          final outputs = <ToolOutput>[
+            ...await exactAndApprox(session, '期望 EX', '$wrap kk*mm/nn]'),
+            ...await exactAndApprox(
+                session,
+                '方差 DX',
+                '$wrap kk*mm*(nn-mm)*(nn-kk)/(nn^2*(nn-1))]'),
+          ];
+
+          // 表格里的 k 从 max(0, n-(N-M)) 起,是个偏移量。让 Symja 单独算一次,
+          // 拿到起点后才能把第 j 项标成真实的 k。
+          final startRes =
+              await session.compute('$wrap Max[0,kk-(nn-mm)]]');
+          final start = int.tryParse(startRes.text.trim());
+          if (start == null) {
+            throw Exception('无法确定 k 的起始值:${startRes.text}');
+          }
+
+          final exact = await session.compute(
+            '$wrap Table[Binomial[mm,j]*Binomial[nn-mm,kk-j]/Binomial[nn,kk], '
+            '{j, Max[0,kk-(nn-mm)], Min[kk,mm]}]]',
+          );
+          final probs = parseSymjaList(exact.text);
+          for (var i = 0; i < probs.length; i++) {
+            outputs.add(ToolText('P(X = ${start + i})', '${probs[i]}'));
+          }
+
+          final numeric = await session.compute(
+            '$wrap N[Table[Binomial[mm,j]*Binomial[nn-mm,kk-j]/Binomial[nn,kk], '
+            '{j, Max[0,kk-(nn-mm)], Min[kk,mm]}], 8]]',
+          );
+          final values = parseSymjaList(numeric.text)
+              .map(asNumber)
+              .whereType<double>()
+              .toList(growable: false);
+          if (values.length == probs.length) {
+            outputs.add(ToolChart('概率分布', _barChart(start, values)));
+          }
+          return outputs;
+        },
+      );
+
+  /// 柱状图:最高的那一段(可能并排几个)涂红,左边蓝、右边绿 —— 和原来一样的规则。
+  Widget _barChart(int start, List<double> probs) {
+    var maxProb = 0.0;
+    for (final v in probs) {
+      if (v > maxProb) maxProb = v;
+    }
+    var firstMax = -1, lastMax = -1;
+    for (var i = 0; i < probs.length; i++) {
+      if (probs[i] == maxProb) {
+        if (firstMax < 0) firstMax = i;
+        lastMax = i;
       }
     }
-    return maxY * 1.1;
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: TextField(
-        controller: controller,
-        keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-        decoration: InputDecoration(
-          labelText: label,
-          border: OutlineInputBorder(),
-        ),
-      ),
-    );
-  }
-
-  Widget _buildResultCard(String title, String value) {
-    return Card(
-      child: ListTile(
-        title: Text(title),
-        subtitle: Text(value),
-      ),
-    );
-  }
-
-  Rational _combination(int n, int k) {
-    if (k < 0 || k > n) return Rational.zero;
-    if (k == 0 || k == n) return Rational.one;
-
-    BigInt result = BigInt.one;
-    for (int i = 1; i <= k; i++) {
-      result = result * BigInt.from(n - i + 1) ~/ BigInt.from(i);
+    Color colorAt(int i) {
+      if (i >= firstMax && i <= lastMax) return Colors.red;
+      return i < firstMax ? Colors.blue.shade300 : Colors.green.shade300;
     }
-    return Rational(result);
-  }
-
-  void _calculate() {
-    try {
-      final N = int.parse(NController.text);
-      final M = int.parse(MController.text);
-      final n = int.parse(nController.text);
-
-      final m = n - (N - M) > 0 ? n - (N - M) : 0;
-      final r = n < M ? n : M;
-
-      final EX = MathUtils.fromInt(n * M) / MathUtils.fromInt(N);
-      final DX = MathUtils.fromInt(n * M * (N - M) * (N - n)) /
-      (MathUtils.fromInt(N) * MathUtils.fromInt(N) * MathUtils.fromInt(N - 1));
-
-      final probs = <String>[];
-      final barGroupsData = <BarChartGroupData>[];
-
-      // 首先计算所有概率并找到最大值
-      List<double> probValues = [];
-      double maxProb = 0.0;
-      
-      for (int k = m; k <= r; k++) {
-        final combMk = _combination(M, k);
-        final combNM_nk = _combination(N - M, n - k);
-        final combNn = _combination(N, n);
-        final prob = combMk * combNM_nk / combNn;
-        final probDouble = prob.toDouble();
-        
-        probValues.add(probDouble);
-        if (probDouble > maxProb) {
-          maxProb = probDouble;
-        }
-        
-        probs.add('P(X = $k) = ${MathUtils.rationalToString(prob)}');
-      }
 
-      // 找到所有具有最大概率的k值
-      List<int> maxProbIndices = [];
-      for (int i = 0; i < probValues.length; i++) {
-        if (probValues[i] == maxProb) {
-          maxProbIndices.add(m + i);
-        }
-      }
-
-      // 确定最高组的范围
-      int minMaxIndex = maxProbIndices.reduce((a, b) => a < b ? a : b);
-      int maxMaxIndex = maxProbIndices.reduce((a, b) => a > b ? a : b);
-
-      // 构建柱状图数据
-      for (int i = 0; i <= r - m; i++) {
-        int k = m + i;
-        double probDouble = probValues[i];
-        
-        barGroupsData.add(
-          BarChartGroupData(
-            x: k,
-            barRods: [
-              BarChartRodData(
-                toY: probDouble,
-                color: _getBarColor(k, minMaxIndex, maxMaxIndex),
-                width: 16,
-                borderRadius: BorderRadius.circular(4),
+    return SizedBox(
+      height: 300,
+      child: BarChart(
+        BarChartData(
+          barTouchData: BarTouchData(enabled: false),
+          alignment: BarChartAlignment.spaceAround,
+          maxY: maxProb == 0 ? 1.0 : maxProb * 1.1,
+          borderData: FlBorderData(show: true),
+          gridData: FlGridData(show: true),
+          titlesData: FlTitlesData(
+            bottomTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 30,
+                getTitlesWidget: (value, meta) => Padding(
+                  padding: const EdgeInsets.only(top: 8),
+                  child: Text('${value.toInt()}',
+                      style: const TextStyle(fontSize: 10)),
+                ),
               ),
-            ],
+            ),
+            leftTitles: AxisTitles(
+              sideTitles: SideTitles(
+                showTitles: true,
+                reservedSize: 40,
+                getTitlesWidget: (value, meta) => value % 0.1 == 0
+                    ? Text(value.toStringAsFixed(1),
+                        style: const TextStyle(fontSize: 10))
+                    : const Text(''),
+              ),
+            ),
+            topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+            rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
           ),
-        );
-      }
-
-      setState(() {
-        expectation = 'EX = ${MathUtils.rationalToString(EX)}';
-        variance = 'DX = ${MathUtils.rationalToString(DX)}';
-        probabilities = probs;
-        barGroups = barGroupsData;
-      });
-    } catch (e) {
-      setState(() {
-        expectation = '错误: $e';
-        variance = '';
-        probabilities = [];
-        barGroups = [];
-      });
-    }
-  }
-
-  Color _getBarColor(int x, int minMaxIndex, int maxMaxIndex) {
-    if (x >= minMaxIndex && x <= maxMaxIndex) {
-      // 最高组(可能有多组)
-      return Colors.red;
-    } else if (x < minMaxIndex) {
-      // 最高组左边的组
-      return Colors.blue.shade300;
-    } else {
-      // 最高组右边的组
-      return Colors.green.shade300;
-    }
-  }
-
-  @override
-  void dispose() {
-    nController.dispose();
-    NController.dispose();
-    MController.dispose();
-    super.dispose();
+          barGroups: [
+            for (var i = 0; i < probs.length; i++)
+              BarChartGroupData(
+                x: start + i,
+                barRods: [
+                  BarChartRodData(
+                    toY: probs[i],
+                    color: colorAt(i),
+                    width: 16,
+                    borderRadius: BorderRadius.circular(4),
+                  ),
+                ],
+              ),
+          ],
+        ),
+      ),
+    );
   }
-}
+}

+ 39 - 106
lib/tools/mixed_variance.dart

@@ -1,116 +1,49 @@
-// tools/mixed_variance.dart
 import 'package:flutter/material.dart';
-import 'package:rational/rational.dart';
-import '../utils/math_utils.dart';
 
+import 'tool_kit.dart';
+
+/// 混合方差 —— 工具页改造后的样板:输入框交给 [ToolScaffold],算式交给 Symja。
+///
+///   总均值   μ = Σ nᵢmᵢ / Σ nᵢ
+///   混合方差   = Σ nᵢ(vᵢ + (mᵢ-μ)²) / Σ nᵢ
+///
+/// 两个式子都要用到 μ,所以第二个式子把 μ 的定义整个代进去 —— 表达式长一点,
+/// 但省掉了"先算一次、把结果拼回表达式里"这种来回。Symja 求值一次就够。
 class MixedVariancePage extends StatefulWidget {
+  const MixedVariancePage({super.key});
+
   @override
-  _MixedVariancePageState createState() => _MixedVariancePageState();
+  State<MixedVariancePage> createState() => _MixedVariancePageState();
 }
 
 class _MixedVariancePageState extends State<MixedVariancePage> {
-  final TextEditingController sampleSizesController = TextEditingController();
-  final TextEditingController meansController = TextEditingController();
-  final TextEditingController variancesController = TextEditingController();
-
-  String result = '';
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('混合方差')),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            _buildInputField('各层样本量(用空格分隔)', sampleSizesController),
-            _buildInputField('各层均值(用空格分隔)', meansController),
-            _buildInputField('各层方差(用空格分隔)', variancesController),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算'),
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 16),
-                  ),
-                ),
-              ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: TextField(
-        controller: controller,
-        keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-        decoration: InputDecoration(
-          labelText: label,
-          border: OutlineInputBorder(),
-          hintText: label.contains('分隔') ? '用空格分隔多个值' : '输入分数如: 1/2 或小数',
-        ),
-      ),
-    );
-  }
-
-  void _calculate() {
-    try {
-      final sizes = sampleSizesController.text.split(' ').map((s) => MathUtils.parseRational(s)).toList();
-      final means = meansController.text.split(' ').map((s) => MathUtils.parseRational(s)).toList();
-      final variances = variancesController.text.split(' ').map((s) => MathUtils.parseRational(s)).toList();
-
-      if (sizes.length != means.length || sizes.length != variances.length) {
-        setState(() {
-          result = '错误: 输入的数据长度不一致';
-        });
-        return;
-      }
-
-      Rational totalSize = Rational.zero;
-      Rational weightedSum = Rational.zero;
-
-      for (int i = 0; i < sizes.length; i++) {
-        totalSize += sizes[i];
-        weightedSum += sizes[i] * means[i];
-      }
-
-      final overallMean = weightedSum / totalSize;
-
-      Rational mixedVariance = Rational.zero;
-      for (int i = 0; i < sizes.length; i++) {
-        mixedVariance += sizes[i] * (variances[i] + (means[i] - overallMean).pow(2)) / totalSize;
-      }
-
-      // 获取近似值
-      double overallMeanApprox = overallMean.toDouble();
-      double mixedVarianceApprox = mixedVariance.toDouble();
-
-      setState(() {
-        result = '总均值 = ${MathUtils.rationalToString(overallMean)} (≈${overallMeanApprox.toStringAsFixed(4)})\n'
-      '混合方差 = ${MathUtils.rationalToString(mixedVariance)} (≈${mixedVarianceApprox.toStringAsFixed(4)})';
-      });
-    } catch (e) {
-      setState(() {
-        result = '错误: $e';
-      });
-    }
-  }
+  // 字段要在 State 里建:主题一变(E-Ink、键盘弹出)页面会重建,
+  // 建在 build 里的话控制器会被换掉,用户打的字就没了。
+  final _sizes = ToolField('各层样本量 nᵢ', hint: '用空格分隔多个值');
+  final _means = ToolField('各层均值 mᵢ', hint: '用空格分隔多个值');
+  final _variances = ToolField('各层方差 vᵢ', hint: '用空格分隔多个值');
 
   @override
-  void dispose() {
-    sampleSizesController.dispose();
-    meansController.dispose();
-    variancesController.dispose();
-    super.dispose();
-  }
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '混合方差',
+        fields: [_sizes, _means, _variances],
+        compute: (session) async {
+          if (_sizes.count != _means.count ||
+              _sizes.count != _variances.count) {
+            throw Exception(
+              '三组数据的个数必须一致(现在是 ${_sizes.count}、'
+              '${_means.count}、${_variances.count})',
+            );
+          }
+          final n = _sizes.list;
+          final m = _means.list;
+          final v = _variances.list;
+          final mu = 'Total[$n*$m]/Total[$n]';
+          final mixed = 'Total[$n*($v+($m-($mu))^2)]/Total[$n]';
+          return [
+            ...await exactAndApprox(session, '总均值 μ', mu),
+            ...await exactAndApprox(session, '混合方差', mixed),
+          ];
+        },
+      );
 }

+ 142 - 321
lib/tools/parametric_plotter.dart

@@ -1,14 +1,15 @@
 // tools/parametric_plotter.dart
+//
+// 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。
+//
+// 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用
+// 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。
 import 'package:flutter/material.dart';
-import 'package:provider/provider.dart';
 
 import '../curve_plot.dart';
 import '../symja.dart';
+import 'tool_kit.dart';
 
-/// 参数方程绘图:x = f(t), y = g(t),用 Symja 采样后交给 [CurvePlot] 画。
-///
-/// 采样要点:Symja 的 `sample` 一次只能算一个表达式,所以 x(t) 和 y(t) 各调用
-/// 一次,参数区间和点数完全一致,再把两个结果按下标配成 (x, y) 点。
 class ParametricPlotterPage extends StatefulWidget {
   const ParametricPlotterPage({super.key});
 
@@ -17,339 +18,159 @@ class ParametricPlotterPage extends StatefulWidget {
 }
 
 class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
-  final TextEditingController _xCtrl = TextEditingController(text: 'cos(t)');
-  final TextEditingController _yCtrl = TextEditingController(text: 'sin(t)');
-  final TextEditingController _tMinCtrl = TextEditingController(text: '0');
-  final TextEditingController _tMaxCtrl = TextEditingController(text: '6.3');
-  final TextEditingController _stepCtrl = TextEditingController(text: '0.1');
-
-  List<CurveSeries> _series = const <CurveSeries>[];
-  String? _error;
-  bool _busy = false;
+  final ToolField _x =
+      ToolField('x = f(t)', hint: '例如 cos(t)、t^2', keyboard: TextInputType.text);
+  final ToolField _y =
+      ToolField('y = g(t)', hint: '例如 sin(t)、t^3', keyboard: TextInputType.text);
+  final ToolField _tMin = ToolField('t 最小值', hint: '例如 0');
+  final ToolField _tMax = ToolField('t 最大值', hint: '例如 6.3');
+  final ToolField _step = ToolField('步长', hint: '例如 0.1');
+
+  /// 稳定的列表对象:外壳靠 `identical(fields)` 判断要不要清空结果,
+  /// 每次 build 新建 List 会让主题一变结果就被清掉。
+  late final List<ToolField> _fields = [_x, _y, _tMin, _tMax, _step];
+
+  /// 给外壳换 key 用。示例按钮改完输入框后把 key 一换,外壳就会重建并
+  /// autoRun,于是"点了示例就重算"这件事不用外壳额外开放接口。
+  int _runToken = 0;
 
   @override
   void initState() {
     super.initState();
-    // 首帧后再绘制:context.read 需要 element 已经挂载到 Provider 之下。
-    WidgetsBinding.instance.addPostFrameCallback((_) => _plot());
-  }
-
-  @override
-  void dispose() {
-    _xCtrl.dispose();
-    _yCtrl.dispose();
-    _tMinCtrl.dispose();
-    _tMaxCtrl.dispose();
-    _stepCtrl.dispose();
-    super.dispose();
-  }
-
-  Future<void> _plot() async {
-    if (_busy) return;
-
-    final xRaw = _xCtrl.text.trim();
-    final yRaw = _yCtrl.text.trim();
-    if (xRaw.isEmpty || yRaw.isEmpty) {
-      setState(() => _error = '请填写 x(t) 和 y(t) 表达式');
-      return;
-    }
-
-    final tMin = double.tryParse(_tMinCtrl.text.trim());
-    final tMax = double.tryParse(_tMaxCtrl.text.trim());
-    final step = double.tryParse(_stepCtrl.text.trim());
-    if (tMin == null || tMax == null || step == null) {
-      setState(() => _error = '请输入有效的 t 范围与步长');
-      return;
-    }
-    if (tMin >= tMax) {
-      setState(() => _error = 't最小值必须小于t最大值');
-      return;
-    }
-    if (step <= 0) {
-      setState(() => _error = '步长必须大于0');
-      return;
-    }
-
-    // Symja 的 sample 只接受「点数」,这里把用户给的步长换算成点数,
-    // 保留步长输入的含义;再做上下限保护,避免点数过多拖垮求值。
-    final count = ((tMax - tMin) / step).round() + 1;
-    final n = count.clamp(2, 5000).toInt();
-
-    final session = context.read<SymjaSession>();
-    setState(() {
-      _busy = true;
-      _error = null;
-    });
-
-    List<CurveSeries> series = const <CurveSeries>[];
-    try {
-      // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。
-      final xs = await session.sample(
-        xRaw,
-        variable: 't',
-        from: tMin,
-        to: tMax,
-        count: n,
-      );
-      final ys = await session.sample(
-        yRaw,
-        variable: 't',
-        from: tMin,
-        to: tMax,
-        count: n,
-      );
-      if (!mounted) return;
-
-      final m = xs.ys.length < ys.ys.length ? xs.ys.length : ys.ys.length;
-      series = <CurveSeries>[
-        CurveSeries(
-          label: 'x=$xRaw, y=$yRaw',
-          color: Colors.blue,
-          // NaN(极点/无定义点)照传,CurvePlot 会当成断点抬笔。
-          points: <Offset>[
-            for (var i = 0; i < m; i++) Offset(xs.ys[i], ys.ys[i]),
-          ],
-        ),
-      ];
-    } on SymjaException catch (e) {
-      if (!mounted) return;
-      setState(() {
-        _error = '$e';
-        _busy = false;
-      });
-      return;
-    }
-
-    if (!mounted) return;
-    setState(() {
-      _series = series;
-      _busy = false;
-    });
+    _x.controller.text = 'cos(t)';
+    _y.controller.text = 'sin(t)';
+    _tMin.controller.text = '0';
+    _tMax.controller.text = '6.3';
+    _step.controller.text = '0.1';
   }
 
-  void _applyPreset(String xExpr, String yExpr, String tMin, String tMax) {
+  void _applyPreset(String x, String y, String tMin, String tMax) {
     setState(() {
-      _xCtrl.text = xExpr;
-      _yCtrl.text = yExpr;
-      _tMinCtrl.text = tMin;
-      _tMaxCtrl.text = tMax;
-      _stepCtrl.text = '0.1';
+      _x.controller.text = x;
+      _y.controller.text = y;
+      _tMin.controller.text = tMin;
+      _tMax.controller.text = tMax;
+      _step.controller.text = '0.1';
+      _runToken++; // 换 key → 外壳重建 → autoRun 重新采样
     });
-    _plot();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: const Text('参数方程绘图', style: TextStyle(color: Colors.black)),
-        backgroundColor: Colors.white,
-        elevation: 1,
-      ),
-      body: SingleChildScrollView(
-        child: Column(
-          children: [
-            _buildInputPanel(),
-            _buildGraph(),
-            _buildExamples(),
-          ],
-        ),
-      ),
-    );
   }
 
-  Widget _buildInputPanel() {
-    return Card(
-      elevation: 4,
-      margin: const EdgeInsets.all(16),
-      child: Padding(
-        padding: const EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            const Text(
-              '参数方程设置',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            const SizedBox(height: 16),
-            _buildExprField(_xCtrl, 'x = f(t)', '例如: cos(t), t^2, Cos[t]'),
-            const SizedBox(height: 12),
-            _buildExprField(_yCtrl, 'y = g(t)', '例如: sin(t), t^3, Sin[t]'),
-            const SizedBox(height: 16),
-            Row(
-              children: [
-                Expanded(child: _buildNumberField(_tMinCtrl, 't最小值')),
-                const SizedBox(width: 12),
-                Expanded(child: _buildNumberField(_tMaxCtrl, 't最大值')),
-                const SizedBox(width: 12),
-                Expanded(child: _buildNumberField(_stepCtrl, '步长')),
-              ],
-            ),
-            const SizedBox(height: 12),
-            if (_error != null)
-              Container(
-                width: double.infinity,
-                padding: const EdgeInsets.all(12),
-                decoration: BoxDecoration(
-                  color: Colors.red[50],
-                  borderRadius: BorderRadius.circular(8),
-                  border: Border.all(color: Colors.red),
-                ),
-                child: Text(_error!, style: const TextStyle(color: Colors.red)),
+  Widget _intro() => Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Wrap(
+            spacing: 8,
+            runSpacing: 8,
+            children: [
+              ActionChip(
+                label: const Text('椭圆'),
+                onPressed: () => _applyPreset('2*cos(t)', 'sin(t)', '0', '6.3'),
               ),
-            const SizedBox(height: 16),
-            ElevatedButton.icon(
-              onPressed: _busy ? null : _plot,
-              icon: _busy
-                  ? const SizedBox(
-                      width: 16,
-                      height: 16,
-                      child: CircularProgressIndicator(strokeWidth: 2),
-                    )
-                  : const Icon(Icons.show_chart),
-              label: Text(_busy ? '绘制中…' : '绘制图像'),
-              style: ElevatedButton.styleFrom(
-                minimumSize: const Size(double.infinity, 50),
+              ActionChip(
+                label: const Text('螺旋线'),
+                onPressed: () =>
+                    _applyPreset('t*cos(t)', 't*sin(t)', '0', '12.6'),
               ),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  /// 表达式输入框。内容原样交给 Symja 解析,这里不做任何转换。
-  Widget _buildExprField(
-    TextEditingController controller,
-    String label,
-    String hint,
-  ) {
-    return Column(
-      crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        TextField(
-          controller: controller,
-          decoration: InputDecoration(
-            labelText: label,
-            hintText: hint,
-            border: const OutlineInputBorder(),
+              ActionChip(
+                label: const Text('抛物线'),
+                onPressed: () => _applyPreset('t', 't^2', '-2', '2'),
+              ),
+              ActionChip(
+                label: const Text('心形线'),
+                onPressed: () => _applyPreset(
+                    '2*cos(t)-cos(2*t)', '2*sin(t)-sin(2*t)', '0', '6.3'),
+              ),
+            ],
           ),
-        ),
-      ],
-    );
-  }
+        ],
+      );
 
-  Widget _buildNumberField(TextEditingController controller, String label) {
-    return TextField(
-      controller: controller,
-      decoration: InputDecoration(
-        labelText: label,
-        border: const OutlineInputBorder(),
-      ),
-      keyboardType: TextInputType.numberWithOptions(
-        signed: true,
-        decimal: true,
-      ),
-    );
-  }
+  @override
+  Widget build(BuildContext context) => ToolScaffold(
+        // key 一换,外壳整体重建并重新 autoRun —— 示例按钮靠它重算。
+        key: ValueKey<int>(_runToken),
+        title: '参数方程绘图',
+        autoRun: true,
+        fields: _fields,
+        intro: _intro(),
+        compute: (session) async {
+          final xRaw = _x.text;
+          final yRaw = _y.text;
+          final tMinRaw = _tMin.text;
+          final tMaxRaw = _tMax.text;
+          final stepRaw = _step.text;
+
+          // 校验:都是格式检查,Dart 里做即可。
+          if (xRaw.isEmpty || yRaw.isEmpty) {
+            throw Exception('请填写 x(t) 和 y(t) 表达式');
+          }
+          final tMin = double.tryParse(tMinRaw);
+          final tMax = double.tryParse(tMaxRaw);
+          final step = double.tryParse(stepRaw);
+          if (tMin == null || tMax == null || step == null) {
+            throw Exception('请输入有效的 t 范围与步长');
+          }
+          if (!tMin.isFinite || !tMax.isFinite || !step.isFinite) {
+            throw Exception('t 范围与步长必须是有限数');
+          }
+          if (tMin >= tMax) {
+            throw Exception('t 最小值必须小于 t 最大值');
+          }
+          if (step <= 0) {
+            throw Exception('步长必须大于 0');
+          }
+
+          // 把用户给的步长换算成点数,再做上下限保护,避免点数过多拖垮求值。
+          final n = (((tMax - tMin) / step).round() + 1).clamp(2, 5000).toInt();
+
+          final List<CurveSeries> series;
+          try {
+            // 同一个 t 区间、同一点数分别采样两条曲线——Symja 一次只能算一个表达式。
+            final xs = await session.sample(
+              xRaw,
+              variable: 't',
+              from: tMin,
+              to: tMax,
+              count: n,
+            );
+            final ys = await session.sample(
+              yRaw,
+              variable: 't',
+              from: tMin,
+              to: tMax,
+              count: n,
+            );
+            final m = xs.ys.length < ys.ys.length ? xs.ys.length : ys.ys.length;
+            series = <CurveSeries>[
+              CurveSeries(
+                label: 'x=$xRaw, y=$yRaw',
+                color: Colors.blue,
+                // NaN(极点/无定义点)照传,CurvePlot 会当成断点抬笔。
+                points: <Offset>[
+                  for (var i = 0; i < m; i++) Offset(xs.ys[i], ys.ys[i]),
+                ],
+              ),
+            ];
+          } on SymjaException catch (e) {
+            throw Exception('采样失败:$e');
+          }
 
-  Widget _buildGraph() {
-    return Card(
-      elevation: 4,
-      margin: const EdgeInsets.all(16),
-      child: Padding(
-        padding: const EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            const Text(
+          return [
+            ToolChart(
               '参数方程图像',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            const SizedBox(height: 16),
-            // 用 AspectRatio 强制正方形,参数曲线的 x/y 比例才不会被拉伸。
-            AspectRatio(
-              aspectRatio: 1,
-              child: Container(
-                decoration: BoxDecoration(
-                  border: Border.all(color: Colors.grey[300]!),
-                  borderRadius: BorderRadius.circular(8),
-                ),
-                child: _busy
-                    ? const Center(child: CircularProgressIndicator())
-                    : (_series.isEmpty
-                        ? const Center(
-                            child: Text(
-                              '暂无数据',
-                              style: TextStyle(fontSize: 16, color: Colors.grey),
-                            ),
-                          )
-                        // 参数方程常常是圆/椭圆/螺线,必须等比例
-                        : CurvePlot(series: _series, equalAspect: true)),
+              // 内部用 Expanded,必须给确定高度;参数曲线常常是圆/椭圆,
+              // 必须等比例(equalAspect: true)。
+              SizedBox(
+                height: 360,
+                child: CurvePlot(series: series, equalAspect: true),
               ),
             ),
-            const SizedBox(height: 16),
-            Text(
-              '方程: x = ${_xCtrl.text}, y = ${_yCtrl.text}',
-              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
-            ),
-            Text(
-              '参数范围: t ∈ [${_tMinCtrl.text}, ${_tMaxCtrl.text}], 步长: ${_stepCtrl.text}',
-              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildExamples() {
-    return Card(
-      elevation: 4,
-      margin: const EdgeInsets.all(16),
-      child: Padding(
-        padding: const EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            const Text(
-              '示例方程',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+            ToolText(
+              '方程',
+              'x = $xRaw,y = $yRaw;t ∈ [$tMinRaw, $tMaxRaw],'
+              '步长 $stepRaw(共 $n 个采样点)',
             ),
-            const SizedBox(height: 12),
-            Wrap(
-              spacing: 8,
-              runSpacing: 8,
-              children: [
-                _buildExampleChip('椭圆', '2*cos(t)', 'sin(t)', '0', '6.3'),
-                _buildExampleChip('螺旋线', 't*cos(t)', 't*sin(t)', '0', '12.6'),
-                _buildExampleChip('抛物线', 't', 't^2', '-2', '2'),
-                _buildExampleChip(
-                  '心形线',
-                  '2*cos(t)-cos(2*t)',
-                  '2*sin(t)-sin(2*t)',
-                  '0',
-                  '6.3',
-                ),
-              ],
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildExampleChip(
-    String title,
-    String xExpr,
-    String yExpr,
-    String tMin,
-    String tMax,
-  ) {
-    return ActionChip(
-      label: Text(title),
-      onPressed: () => _applyPreset(xExpr, yExpr, tMin, tMax),
-      backgroundColor: Colors.blue[50],
-    );
-  }
+          ];
+        },
+      );
 }

+ 152 - 219
lib/tools/regression.dart

@@ -1,238 +1,171 @@
 // tools/regression.dart
 import 'package:flutter/material.dart';
-import 'dart:math';
 import 'package:fl_chart/fl_chart.dart';
 
+import 'tool_kit.dart';
+
+/// 线性回归 —— 十个统计量、散点和回归直线全部由 Symja 算。
+///
+/// Dart 只做两件不涉及计算的事:数个数(校验 x、y 长度一致)和排版。回归
+/// 系数、相关系数、残差等一次 `N[With[...], 10]` 全部算好;画图要的双精度
+/// 散点由 `{N[x], N[y]}` 给出,回归直线两端点再单独问一次。图上**始终**画
+/// 回归直线,原来的「显示回归直线」复选框已去掉。
 class RegressionPage extends StatefulWidget {
+  const RegressionPage({super.key});
+
   @override
-  _RegressionPageState createState() => _RegressionPageState();
+  State<RegressionPage> createState() => _RegressionPageState();
 }
 
 class _RegressionPageState extends State<RegressionPage> {
-  final TextEditingController xController = TextEditingController();
-  final TextEditingController yController = TextEditingController();
-
-  String result = '';
-  List<FlSpot> scatterSpots = [];
-  List<FlSpot> regressionLineSpots = [];
-  bool showRegressionLine = false;
+  final _x = ToolField('x值', hint: '用空格分隔多个值');
+  final _y = ToolField('y值', hint: '用空格分隔多个值');
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('线性回归')),
-      body: SingleChildScrollView( // 添加可滚动容器
-        padding: EdgeInsets.all(16), // 将padding移到这里
-        child: Column(
-          children: [
-            _buildInputField('x值(用空格分隔)', xController),
-            _buildInputField('y值(用空格分隔)', yController),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算'),
-            ),
-            SizedBox(height: 20),
-            // 回归直线显示选项
-            Row(
-              children: [
-                Checkbox(
-                  value: showRegressionLine,
-                  onChanged: (value) {
-                    setState(() {
-                      showRegressionLine = value!;
-                    });
-                  },
-                ),
-                Text('显示回归直线'),
-              ],
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 14, fontFamily: 'Monospace'),
-                  ),
-                ),
-              ),
-            SizedBox(height: 20),
-            // 散点图
-            if (scatterSpots.isNotEmpty) _buildScatterChart(),
-          ],
-        ),
-      ),
-    );
-  }
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '线性回归',
+        fields: [_x, _y],
+        compute: (session) async {
+          if (_x.count == 0 || _y.count == 0) {
+            throw Exception('请输入 x 和 y 的数据');
+          }
+          if (_x.count != _y.count) {
+            throw Exception('x 和 y 的个数必须一致(现在是 ${_x.count} 和 ${_y.count})');
+          }
+          final x = _x.list;
+          final y = _y.list;
+
+          // 十个量一条表达式算齐(隐式乘号一律写出来)。
+          final statsExpr = 'N[With[{x=$x, y=$y}, '
+              'Module[{xb, yb, sxx, syy, sxy, b, aa, se2}, '
+              'xb=Mean[x]; yb=Mean[y]; '
+              'sxx=Total[(x-xb)^2]; syy=Total[(y-yb)^2]; '
+              'sxy=Total[(x-xb)*(y-yb)]; '
+              'b=sxy/sxx; aa=yb-b*xb; se2=Total[(y-(aa+b*x))^2]; '
+              '{xb, yb, sxx, syy, sxy, sxy/Sqrt[sxx*syy], b, aa, se2, 1-se2/syy}]], 10]';
+          final stats = parseSymjaList((await session.compute(statsExpr)).text)
+              .map((e) => _clean('$e'))
+              .toList();
+          if (stats.length < 10) throw Exception('回归计算没有返回预期结果');
+
+          // 守卫:Σ(x−x̄)² 为 0 说明 x 全相同,拟合无意义。
+          if (asNumber(stats[2]) == 0) {
+            throw Exception('x 的取值不能全部相同(Σ(x−x̄)² = 0,无法拟合)');
+          }
+
+          const labels = <String>[
+            'x 均值 x̄',
+            'y 均值 ȳ',
+            'Σ(x−x̄)²',
+            'Σ(y−ȳ)²',
+            'Σ(x−x̄)(y−ȳ)',
+            '相关系数 r',
+            '斜率 b',
+            '截距 a',
+            '残差平方和',
+            '决定系数 R²',
+          ];
+
+          // 散点坐标:N[x]、N[y](一次调用拿两组)。
+          final pts = parseSymjaList((await session.compute('{N[$x], N[$y]}')).text);
+          final scatter = <FlSpot>[];
+          if (pts.length >= 2 && pts[0] is List && pts[1] is List) {
+            final xs = pts[0] as List;
+            final ys = pts[1] as List;
+            for (var i = 0; i < xs.length && i < ys.length; i++) {
+              final xv = asNumber(xs[i]);
+              final yv = asNumber(ys[i]);
+              if (xv != null && yv != null) scatter.add(FlSpot(xv, yv));
+            }
+          }
+
+          // 回归直线两端点(在 x 的最小/最大值处取值)。
+          final endsExpr = 'N[With[{x=$x, y=$y}, '
+              'Module[{xb, yb, sxy, sxx, b, aa}, '
+              'xb=Mean[x]; yb=Mean[y]; sxx=Total[(x-xb)^2]; '
+              'sxy=Total[(x-xb)*(y-yb)]; b=sxy/sxx; aa=yb-b*xb; '
+              '{Min[x], Max[x], aa+b*Min[x], aa+b*Max[x]}]], 10]';
+          final ends = parseSymjaList((await session.compute(endsExpr)).text)
+              .map(asNumber)
+              .toList();
+          final line = <FlSpot>[];
+          if (ends.length >= 4 &&
+              ends[0] != null &&
+              ends[1] != null &&
+              ends[2] != null &&
+              ends[3] != null) {
+            line.add(FlSpot(ends[0]!, ends[2]!));
+            line.add(FlSpot(ends[1]!, ends[3]!));
+          }
+
+          return <ToolOutput>[
+            for (var i = 0; i < 10; i++) ToolText(labels[i], stats[i]),
+            ToolChart('散点与回归直线', _scatterWithLine(scatter, line)),
+          ];
+        },
+      );
+}
 
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return Padding(
-      padding: EdgeInsets.symmetric(vertical: 8),
-      child: TextField(
-        controller: controller,
-        keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-        decoration: InputDecoration(
-          labelText: label,
-          border: OutlineInputBorder(),
-          hintText: '用空格分隔多个值',
-        ),
-      ),
+/// 去掉 Symja 近似值尾巴上的精度标记(形如 `0.9943767127`` 或 `0.9943767127`10.`)。
+String _clean(String s) => s.split('`').first.trim();
+
+/// 散点 + 回归直线(图不参与复制,所以没有 tex/文本)。
+Widget _scatterWithLine(List<FlSpot> scatter, List<FlSpot> line) {
+  if (scatter.isEmpty && line.isEmpty) {
+    return const SizedBox(
+      height: 200,
+      child: Center(child: Text('没有可绘制的数据')),
     );
   }
 
-  Widget _buildScatterChart() {
-    return Container(
-      height: 300,
-      padding: EdgeInsets.all(16),
-      child: Card(
-        elevation: 4,
-        child: Padding(
-          padding: EdgeInsets.all(16),
-          child: LineChart(
-            LineChartData(
-              gridData: FlGridData(show: true),
-              titlesData: FlTitlesData(
-                bottomTitles: AxisTitles(
-                  sideTitles: SideTitles(showTitles: true),
-                ),
-                leftTitles: AxisTitles(
-                  sideTitles: SideTitles(showTitles: true),
+  // 坐标范围只是给图留边距,不是工具的结果(原来的实现也这么做)。
+  final xs = <double>[...scatter.map((s) => s.x), ...line.map((s) => s.x)];
+  final ys = <double>[...scatter.map((s) => s.y), ...line.map((s) => s.y)];
+  double lo(Iterable<double> v) => v.reduce((a, b) => a < b ? a : b);
+  double hi(Iterable<double> v) => v.reduce((a, b) => a > b ? a : b);
+
+  return SizedBox(
+    height: 280,
+    child: LineChart(
+      LineChartData(
+        gridData: FlGridData(show: true),
+        titlesData: FlTitlesData(
+          topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+          rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
+        ),
+        borderData: FlBorderData(show: true),
+        minX: lo(xs) - 1,
+        maxX: hi(xs) + 1,
+        minY: lo(ys) - 1,
+        maxY: hi(ys) + 1,
+        lineBarsData: [
+          if (scatter.isNotEmpty)
+            LineChartBarData(
+              spots: scatter,
+              isCurved: false,
+              barWidth: 0,
+              color: Colors.transparent, // 只显示点,不连折线
+              dotData: FlDotData(
+                show: true,
+                getDotPainter: (spot, percent, bar, index) =>
+                    FlDotCirclePainter(
+                  radius: 4,
+                  color: Colors.blue,
+                  strokeWidth: 2,
+                  strokeColor: Colors.blue,
                 ),
-                topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
-                rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
               ),
-              borderData: FlBorderData(show: true),
-              minX: scatterSpots.map((spot) => spot.x).reduce(min) - 1,
-              maxX: scatterSpots.map((spot) => spot.x).reduce(max) + 1,
-              minY: scatterSpots.map((spot) => spot.y).reduce(min) - 1,
-              maxY: scatterSpots.map((spot) => spot.y).reduce(max) + 1,
-              lineBarsData: [
-                // 散点图
-                LineChartBarData(
-                  spots: scatterSpots,
-                  isCurved: false,
-                  dotData: FlDotData(
-                    show: true,
-                    getDotPainter: (spot, percent, barData, index) {
-                      return FlDotCirclePainter(
-                        radius: 4,
-                        color: Colors.blue,
-                        strokeWidth: 2,
-                        strokeColor: Colors.blue,
-                      );
-                    },
-                  ),
-                  color: Colors.transparent, // 隐藏连接线
-                  belowBarData: BarAreaData(show: false),
-                ),
-                // 回归直线(可选)
-                if (showRegressionLine && regressionLineSpots.isNotEmpty)
-                  LineChartBarData(
-                    spots: regressionLineSpots,
-                    isCurved: false,
-                    dotData: FlDotData(show: false),
-                    color: Colors.red,
-                    barWidth: 2,
-                    belowBarData: BarAreaData(show: false),
-                  ),
-              ],
             ),
-          ),
-        ),
+          if (line.isNotEmpty)
+            LineChartBarData(
+              spots: line,
+              isCurved: false,
+              barWidth: 2,
+              color: Colors.red,
+              dotData: FlDotData(show: false),
+            ),
+        ],
       ),
-    );
-  }
-
-  void _calculate() {
-    try {
-      final xList = xController.text.split(' ').where((s) => s.isNotEmpty).map((s) => double.parse(s)).toList();
-      final yList = yController.text.split(' ').where((s) => s.isNotEmpty).map((s) => double.parse(s)).toList();
-
-      if (xList.isEmpty || yList.isEmpty) {
-        setState(() {
-          result = '错误: 请输入数据';
-        });
-        return;
-      }
-
-      if (xList.length != yList.length) {
-        setState(() {
-          result = '错误: x和y的数据长度不一致';
-        });
-        return;
-      }
-
-      final n = xList.length;
-      double sx = 0, sy = 0, sx2 = 0, sy2 = 0, sxy = 0;
-
-      for (int i = 0; i < n; i++) {
-        sx += xList[i];
-        sy += yList[i];
-        sx2 += xList[i] * xList[i];
-        sy2 += yList[i] * yList[i];
-        sxy += xList[i] * yList[i];
-      }
-
-      final xBar = sx / n;
-      final yBar = sy / n;
-      final sigmaX = sx2 - n * xBar * xBar;
-      final sigmaY = sy2 - n * yBar * yBar;
-      final sigmaXY = sxy - n * xBar * yBar;
-
-      final r = sigmaXY / sqrt(sigmaX * sigmaY);
-      final b = sigmaXY / sigmaX;
-      final a = yBar - b * xBar;
-
-      double se2 = 0;
-      for (int i = 0; i < n; i++) {
-        se2 += pow(yList[i] - b * xList[i] - a, 2);
-      }
-      final R2 = 1 - se2 / sigmaY;
-
-      // 准备散点数据
-      scatterSpots = [];
-      for (int i = 0; i < n; i++) {
-        scatterSpots.add(FlSpot(xList[i], yList[i]));
-      }
-
-      // 准备回归直线数据(两个端点)
-      regressionLineSpots = [];
-      if (xList.isNotEmpty) {
-        double minX = xList.reduce(min);
-        double maxX = xList.reduce(max);
-        regressionLineSpots.add(FlSpot(minX, a + b * minX));
-        regressionLineSpots.add(FlSpot(maxX, a + b * maxX));
-      }
-
-      setState(() {
-        result = '''
-      x_bar = ${xBar.toStringAsFixed(4)}
-      y_bar = ${yBar.toStringAsFixed(4)}
-      Σ(x-x̄)² = ${sigmaX.toStringAsFixed(4)}
-      Σ(y-ȳ)² = ${sigmaY.toStringAsFixed(4)}
-      Σ(x-x̄)(y-ȳ) = ${sigmaXY.toStringAsFixed(4)}
-      相关系数 r = ${r.toStringAsFixed(4)}
-      斜率 b = ${b.toStringAsFixed(4)}
-      截距 a = ${a.toStringAsFixed(4)}
-      残差平方和 = ${se2.toStringAsFixed(4)}
-      决定系数 R² = ${R2.toStringAsFixed(4)}
-      ''';
-      });
-    } catch (e) {
-      setState(() {
-        result = '错误: $e';
-        scatterSpots = [];
-        regressionLineSpots = [];
-      });
-    }
-  }
-
-  @override
-  void dispose() {
-    xController.dispose();
-    yController.dispose();
-    super.dispose();
-  }
-}
+    ),
+  );
+}

+ 307 - 633
lib/tools/statistical_charts.dart

@@ -1,692 +1,366 @@
 // tools/statistical_charts.dart
+//
+// 统计图表:条形图 / 折线图 / 饼图 / 雷达图。
+//
+// 这一页没有数学计算,只是把用户输入变成坐标,所以**不接 Symja**;
+// 数值用 double.tryParse 解析。包进 [ToolScaffold] 后,绘制由「计算」按钮触发,
+// 重置按钮也和其它工具一致。图表类型、曲线类型、增删数据行这些操作会换 key
+// 让外壳重建并重新计算,这样"点一下切换类型"仍然立刻重画。
 import 'package:flutter/material.dart';
 import 'package:fl_chart/fl_chart.dart';
 
+import 'tool_kit.dart';
+
 class StatisticalChartsPage extends StatefulWidget {
+  const StatisticalChartsPage({super.key});
+
   @override
-  _StatisticalChartsPageState createState() => _StatisticalChartsPageState();
+  State<StatisticalChartsPage> createState() => _StatisticalChartsPageState();
 }
 
 class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
-  int _currentChartIndex = 0;
-  final List<String> _chartTypes = ['条形图', '折线图', '饼图', '雷达图'];
-  
-  // 数据控制器
-  final List<TextEditingController> _dataControllers = [];
-  final List<TextEditingController> _labelControllers = [];
-  
-  // 图表数据
-  List<double> _dataValues = [];
-  List<String> _dataLabels = [];
-  
+  static const List<String> _chartTypes = <String>['条形图', '折线图', '饼图', '雷达图'];
+
+  int _type = 0;
+
+  /// 折线图是否用光滑曲线(false = 直线)。
+  bool _curved = true;
+
+  /// 输入行:每两格是一行(标签、数值)。整条列表**就地增删**,
+  /// 对象始终是同一个 —— 外壳靠 `identical(fields)` 判断要不要清空结果。
+  final List<ToolField> _fields = [];
+
+  /// 图表类型 / 曲线类型 / 增删数据行后换 key,让外壳重建并 autoRun 立即重画。
+  int _runToken = 0;
+
   @override
   void initState() {
     super.initState();
-    // 初始化默认数据
-    _initializeDefaultData();
-  }
-  
-  void _initializeDefaultData() {
-    // 清空现有控制器
-    _dataControllers.clear();
-    _labelControllers.clear();
-    
-    // 添加默认数据
-    for (int i = 0; i < 5; i++) {
-      _dataControllers.add(TextEditingController(text: '${(i + 1) * 10}'));
-      _labelControllers.add(TextEditingController(text: '数据${i + 1}'));
+    for (var i = 0; i < 5; i++) {
+      _addRow(initialLabel: '数据${i + 1}', initialValue: '${(i + 1) * 10}');
     }
-    
-    _updateChartData();
   }
-  
-  void _updateChartData() {
+
+  int get _rowCount => _fields.length ~/ 2;
+
+  ToolField _labelAt(int i) => _fields[2 * i];
+
+  ToolField _valueAt(int i) => _fields[2 * i + 1];
+
+  void _addRow({String initialLabel = '新数据', String initialValue = '0'}) {
+    final n = _rowCount + 1;
+    final label = ToolField('第 $n 项标签', hint: '数据名', keyboard: TextInputType.text);
+    final value = ToolField('第 $n 项数值', hint: '例如 10');
+    label.controller.text = initialLabel;
+    value.controller.text = initialValue;
+    _fields.addAll([label, value]);
+  }
+
+  void _onAddRow() => setState(() {
+        _addRow();
+        _runToken++;
+      });
+
+  void _onRemoveRow() {
+    if (_rowCount <= 1) return;
     setState(() {
-      _dataValues = [];
-      _dataLabels = [];
-      
-      for (int i = 0; i < _dataControllers.length; i++) {
-        if (_dataControllers[i].text.isNotEmpty && 
-            _labelControllers[i].text.isNotEmpty) {
-          try {
-            double value = double.parse(_dataControllers[i].text);
-            _dataValues.add(value);
-            _dataLabels.add(_labelControllers[i].text);
-          } catch (e) {
-            // 忽略无效数据
-          }
-        }
-      }
+      _fields.removeRange(_fields.length - 2, _fields.length);
+      _runToken++;
     });
   }
-  
-  void _addDataField() {
+
+  void _selectType(int i) {
+    if (_type == i) return;
     setState(() {
-      _dataControllers.add(TextEditingController(text: '0'));
-      _labelControllers.add(TextEditingController(text: '新数据'));
+      _type = i;
+      _runToken++;
     });
   }
-  
-  void _removeDataField(int index) {
-    if (_dataControllers.length > 1) {
-      setState(() {
-        _dataControllers.removeAt(index);
-        _labelControllers.removeAt(index);
-        _updateChartData();
-      });
-    }
+
+  void _setCurved(bool value) {
+    if (_curved == value) return;
+    setState(() {
+      _curved = value;
+      _runToken++;
+    });
   }
-  
-  Widget _buildDataInputPanel() {
-    return Card(
-      elevation: 4,
-      margin: EdgeInsets.all(16),
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '数据输入',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 16),
-            Row(
-              children: [
-                Expanded(
-                  flex: 2,
-                  child: Text(
-                    '标签',
-                    style: TextStyle(fontWeight: FontWeight.bold),
-                    textAlign: TextAlign.center,
-                  ),
-                ),
-                SizedBox(width: 16),
-                Expanded(
-                  flex: 2,
-                  child: Text(
-                    '数值',
-                    style: TextStyle(fontWeight: FontWeight.bold),
-                    textAlign: TextAlign.center,
-                  ),
-                ),
-                SizedBox(width: 16),
-                Expanded(
-                  flex: 1,
-                  child: Text(
-                    '操作',
-                    style: TextStyle(fontWeight: FontWeight.bold),
-                    textAlign: TextAlign.center,
-                  ),
-                ),
-              ],
-            ),
-            SizedBox(height: 8),
-            ...List.generate(_dataControllers.length, (index) {
-              return Padding(
-                padding: EdgeInsets.symmetric(vertical: 8),
-                child: Row(
-                  children: [
-                    Expanded(
-                      flex: 2,
-                      child: TextField(
-                        controller: _labelControllers[index],
-                        decoration: InputDecoration(
-                          hintText: '输入标签',
-                          border: OutlineInputBorder(),
-                          contentPadding: EdgeInsets.symmetric(horizontal: 12),
-                        ),
-                        onChanged: (value) => _updateChartData(),
-                      ),
-                    ),
-                    SizedBox(width: 16),
-                    Expanded(
-                      flex: 2,
-                      child: TextField(
-                        controller: _dataControllers[index],
-                        decoration: InputDecoration(
-                          hintText: '输入数值',
-                          border: OutlineInputBorder(),
-                          contentPadding: EdgeInsets.symmetric(horizontal: 12),
-                        ),
-                        keyboardType: TextInputType.number,
-                        onChanged: (value) => _updateChartData(),
-                      ),
-                    ),
-                    SizedBox(width: 16),
-                    Expanded(
-                      flex: 1,
-                      child: IconButton(
-                        icon: Icon(Icons.delete, color: Colors.red),
-                        onPressed: () => _removeDataField(index),
-                      ),
-                    ),
-                  ],
+
+  Widget _intro() => Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          const Text('图表类型'),
+          const SizedBox(height: 8),
+          Wrap(
+            spacing: 8,
+            runSpacing: 8,
+            children: [
+              for (var i = 0; i < _chartTypes.length; i++)
+                ChoiceChip(
+                  label: Text(_chartTypes[i]),
+                  selected: _type == i,
+                  onSelected: (_) => _selectType(i),
                 ),
-              );
-            }),
-            SizedBox(height: 16),
+            ],
+          ),
+          if (_type == 1) ...[
+            const SizedBox(height: 12),
             Row(
               children: [
-                ElevatedButton(
-                  onPressed: _addDataField,
-                  child: Text('添加数据'),
-                  style: ElevatedButton.styleFrom(
-                    backgroundColor: Colors.blue,
-                    foregroundColor: Colors.white,
-                  ),
+                const Text('曲线类型:'),
+                const SizedBox(width: 8),
+                ChoiceChip(
+                  label: const Text('直线'),
+                  selected: !_curved,
+                  onSelected: (_) => _setCurved(false),
                 ),
-                SizedBox(width: 16),
-                ElevatedButton(
-                  onPressed: _initializeDefaultData,
-                  child: Text('重置数据'),
-                  style: ElevatedButton.styleFrom(
-                    backgroundColor: Colors.grey,
-                    foregroundColor: Colors.white,
-                  ),
+                const SizedBox(width: 8),
+                ChoiceChip(
+                  label: const Text('光滑曲线'),
+                  selected: _curved,
+                  onSelected: (_) => _setCurved(true),
                 ),
               ],
             ),
           ],
-        ),
-      ),
-    );
-  }
-  
-  Widget _buildChartTypeSelector() {
-    return Card(
-      elevation: 4,
-      margin: EdgeInsets.symmetric(horizontal: 16),
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '图表类型',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 12),
-            Wrap(
-              spacing: 8,
-              runSpacing: 8,
-              children: List.generate(_chartTypes.length, (index) {
-                return ChoiceChip(
-                  label: Text(_chartTypes[index]),
-                  selected: _currentChartIndex == index,
-                  onSelected: (selected) {
-                    setState(() {
-                      _currentChartIndex = index;
-                    });
-                  },
-                  selectedColor: Colors.blue,
-                  labelStyle: TextStyle(
-                    color: _currentChartIndex == index ? Colors.white : Colors.black,
-                  ),
-                );
-              }),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-  
-  Widget _buildChart() {
-    if (_dataValues.isEmpty) {
-      return Container(
-        height: 300,
-        child: Center(
-          child: Text(
-            '请输入有效数据',
-            style: TextStyle(fontSize: 18, color: Colors.grey),
+          const SizedBox(height: 12),
+          Row(
+            children: [
+              OutlinedButton.icon(
+                onPressed: _onAddRow,
+                icon: const Icon(Icons.add),
+                label: const Text('添加数据'),
+              ),
+              const SizedBox(width: 12),
+              OutlinedButton.icon(
+                onPressed: _rowCount > 1 ? _onRemoveRow : null,
+                icon: const Icon(Icons.remove),
+                label: const Text('删除数据'),
+              ),
+            ],
           ),
-        ),
+        ],
       );
-    }
-    
-    switch (_currentChartIndex) {
-      case 0: // 条形图
-        return _buildBarChart();
-      case 1: // 折线图
-        return _buildLineChart();
-      case 2: // 饼图
-        return _buildPieChart();
-      case 3: // 雷达图
-        return _buildRadarChart();
-      default:
-        return _buildBarChart();
-    }
-  }
-  
-  Widget _buildBarChart() {
-    return Card(
-      elevation: 4,
-      margin: EdgeInsets.all(16),
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            Text(
-              '条形图',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 16),
-            Container(
-              height: 300,
-              child: BarChart(
-                BarChartData(
-                  alignment: BarChartAlignment.spaceAround,
-                  maxY: _dataValues.isEmpty ? 100 : _dataValues.reduce((a, b) => a > b ? a : b) * 1.2,
-                  barTouchData: BarTouchData(
-                    enabled: true,
-                    // 简化 tooltip 配置
-                    touchTooltipData: BarTouchTooltipData(),
-                  ),
-                  titlesData: FlTitlesData(
-                    show: true,
-                    bottomTitles: AxisTitles(
-                      sideTitles: SideTitles(
-                        showTitles: true,
-                        getTitlesWidget: (value, meta) {
-                          int index = value.toInt();
-                          if (index >= 0 && index < _dataLabels.length) {
-                            return Padding(
-                              padding: const EdgeInsets.only(top: 8.0),
-                              child: Text(
-                                _dataLabels[index],
-                                style: TextStyle(fontSize: 10),
-                                maxLines: 2,
-                                overflow: TextOverflow.ellipsis,
-                              ),
-                            );
-                          }
-                          return Text('');
-                        },
-                      ),
-                    ),
-                    leftTitles: AxisTitles(
-                      sideTitles: SideTitles(
-                        showTitles: true,
-                        reservedSize: 40,
-                      ),
-                    ),
-                  ),
-                  gridData: FlGridData(show: true),
-                  borderData: FlBorderData(show: true),
-                  barGroups: List.generate(_dataValues.length, (index) {
-                    return BarChartGroupData(
-                      x: index,
-                      barRods: [
-                        BarChartRodData(
-                          toY: _dataValues[index],
-                          color: Colors.primaries[index % Colors.primaries.length],
-                          width: 16,
-                        ),
-                      ],
-                    );
-                  }),
-                ),
-              ),
+
+  @override
+  Widget build(BuildContext context) => ToolScaffold(
+        key: ValueKey<int>(_runToken),
+        title: '统计图表绘制',
+        autoRun: true,
+        fields: _fields,
+        intro: _intro(),
+        compute: (session) async {
+          // 把输入行读成 (标签, 数值)。这里没有数学计算,只是把输入变成坐标,
+          // 所以解析留在 Dart 侧;非数字就抛中文异常。
+          final labels = <String>[];
+          final values = <double>[];
+          for (var i = 0; i < _rowCount; i++) {
+            final label = _labelAt(i).text;
+            final raw = _valueAt(i).text;
+            if (label.isEmpty && raw.isEmpty) continue;
+            if (raw.isEmpty) throw Exception('第 ${i + 1} 行的数值不能为空');
+            final v = double.tryParse(raw);
+            if (v == null || !v.isFinite) {
+              throw Exception('第 ${i + 1} 行的「$raw」不是有效数字');
+            }
+            values.add(v);
+            labels.add(label.isEmpty ? '第${i + 1}项' : label);
+          }
+          if (values.isEmpty) {
+            throw Exception('请至少填写一行有效数据');
+          }
+
+          final chart = switch (_type) {
+            1 => _lineChart(values, labels),
+            2 => _pieChart(values),
+            3 => _radarChart(values, labels),
+            _ => _barChart(values, labels),
+          };
+
+          return <ToolOutput>[
+            ToolChart(
+              _chartTypes[_type],
+              // fl_chart 需要一个确定高度,否则在无界的高度里会报错。
+              SizedBox(height: 320, child: chart),
             ),
-          ],
-        ),
-      ),
-    );
+          ];
+        },
+      );
+
+  /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。
+  static double _niceMax(List<double> values) {
+    var maxV = values.first;
+    for (final v in values) {
+      if (v > maxV) maxV = v;
+    }
+    return maxV <= 0 ? 1 : maxV * 1.2;
   }
-  
- // 在类顶部添加曲线类型状态变量
-bool _isLineCurved = true;
 
-Widget _buildLineChart() {
-  return Card(
-    elevation: 4,
-    margin: EdgeInsets.all(16),
-    child: Padding(
-      padding: EdgeInsets.all(16),
-      child: Column(
-        children: [
-          Text(
-            '折线图',
-            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-          ),
-          SizedBox(height: 16),
-          
-          // 添加曲线类型选择器
-          Container(
-            padding: EdgeInsets.symmetric(horizontal: 16),
-            child: Row(
-              mainAxisAlignment: MainAxisAlignment.spaceBetween,
-              children: [
-                Text(
-                  '曲线类型:',
-                  style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
-                ),
-                Row(
-                  children: [
-                    ChoiceChip(
-                      label: Text('直线'),
-                      selected: !_isLineCurved,
-                      onSelected: (selected) {
-                        setState(() {
-                          _isLineCurved = false;
-                        });
-                      },
-                      selectedColor: Colors.blue,
-                      labelStyle: TextStyle(
-                        color: !_isLineCurved ? Colors.white : Colors.black,
-                      ),
-                    ),
-                    SizedBox(width: 8),
-                    ChoiceChip(
-                      label: Text('光滑曲线'),
-                      selected: _isLineCurved,
-                      onSelected: (selected) {
-                        setState(() {
-                          _isLineCurved = true;
-                        });
-                      },
-                      selectedColor: Colors.blue,
-                      labelStyle: TextStyle(
-                        color: _isLineCurved ? Colors.white : Colors.black,
-                      ),
-                    ),
-                  ],
-                ),
-              ],
+  static SideTitles _bottomTitles(List<String> labels) => SideTitles(
+        showTitles: true,
+        getTitlesWidget: (value, meta) {
+          final index = value.toInt();
+          if (index < 0 || index >= labels.length) {
+            return const SizedBox.shrink();
+          }
+          return Padding(
+            padding: const EdgeInsets.only(top: 8),
+            child: Text(
+              labels[index],
+              style: const TextStyle(fontSize: 10),
+              maxLines: 2,
+              overflow: TextOverflow.ellipsis,
             ),
+          );
+        },
+      );
+
+  Widget _barChart(List<double> values, List<String> labels) => BarChart(
+        BarChartData(
+          alignment: BarChartAlignment.spaceAround,
+          maxY: _niceMax(values),
+          barTouchData: BarTouchData(
+            enabled: true,
+            touchTooltipData: const BarTouchTooltipData(),
           ),
-          SizedBox(height: 16),
-          
-          Container(
-            height: 300,
-            child: LineChart(
-              LineChartData(
-                lineTouchData: LineTouchData(enabled: true),
-                gridData: FlGridData(show: true),
-                titlesData: FlTitlesData(
-                  show: true,
-                  bottomTitles: AxisTitles(
-                    sideTitles: SideTitles(
-                      showTitles: true,
-                      getTitlesWidget: (value, meta) {
-                        int index = value.toInt();
-                        if (index >= 0 && index < _dataLabels.length) {
-                          return Padding(
-                            padding: const EdgeInsets.only(top: 8.0),
-                            child: Text(
-                              _dataLabels[index],
-                              style: TextStyle(fontSize: 10),
-                            ),
-                          );
-                        }
-                        return Text('');
-                      },
-                    ),
-                  ),
-                  leftTitles: AxisTitles(
-                    sideTitles: SideTitles(
-                      showTitles: true,
-                      reservedSize: 40,
-                    ),
-                  ),
-                ),
-                borderData: FlBorderData(show: true),
-                minX: 0,
-                maxX: _dataValues.length > 0 ? (_dataValues.length - 1).toDouble() : 1,
-                minY: 0,
-                maxY: _dataValues.isEmpty ? 100 : _dataValues.reduce((a, b) => a > b ? a : b) * 1.2,
-                lineBarsData: [
-                  LineChartBarData(
-                    spots: List.generate(_dataValues.length, (index) {
-                      return FlSpot(index.toDouble(), _dataValues[index]);
-                    }),
-                    isCurved: _isLineCurved,
-                    color: Colors.blue,
-                    barWidth: 4,
-                    belowBarData: BarAreaData(show: false),
-                    dotData: FlDotData(
-                      show: true,
-                      getDotPainter: (spot, percent, barData, index) {
-                        return FlDotCirclePainter(
-                          radius: 4,
-                          color: Colors.blue,
-                          strokeWidth: 2,
-                          strokeColor: Colors.white,
-                        );
-                      },
-                    ),
-                  ),
-                ],
-              ),
+          titlesData: FlTitlesData(
+            show: true,
+            bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
+            leftTitles: const AxisTitles(
+              sideTitles: SideTitles(showTitles: true, reservedSize: 40),
             ),
           ),
-        ],
-      ),
-    ),
-  );
-}
-  
-  Widget _buildPieChart() {
-    double total = _dataValues.fold(0, (a, b) => a + b);
-    
-    return Card(
-      elevation: 4,
-      margin: EdgeInsets.all(16),
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            Text(
-              '饼图',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 16),
-            Container(
-              height: 300,
-              child: Row(
-                children: [
-                  Expanded(
-                    flex: 2,
-                    child: PieChart(
-                      PieChartData(
-                        sectionsSpace: 4,
-                        centerSpaceRadius: 40,
-                        sections: List.generate(_dataValues.length, (index) {
-                          return PieChartSectionData(
-                            color: Colors.primaries[index % Colors.primaries.length],
-                            value: _dataValues[index],
-                            title: '${(_dataValues[index] / total * 100).toStringAsFixed(1)}%',
-                            radius: 50,
-                            titleStyle: TextStyle(
-                              fontSize: 12,
-                              fontWeight: FontWeight.bold,
-                              color: Colors.white,
-                            ),
-                          );
-                        }),
-                      ),
-                    ),
-                  ),
-                  Expanded(
-                    flex: 1,
-                    child: Column(
-                      mainAxisAlignment: MainAxisAlignment.center,
-                      crossAxisAlignment: CrossAxisAlignment.start,
-                      children: List.generate(_dataValues.length, (index) {
-                        return Padding(
-                          padding: EdgeInsets.symmetric(vertical: 4),
-                          child: Row(
-                            children: [
-                              Container(
-                                width: 12,
-                                height: 12,
-                                color: Colors.primaries[index % Colors.primaries.length],
-                              ),
-                              SizedBox(width: 8),
-                              Flexible(
-                                child: Text(
-                                  _dataLabels[index],
-                                  style: TextStyle(fontSize: 12),
-                                  overflow: TextOverflow.ellipsis,
-                                ),
-                              ),
-                            ],
-                          ),
-                        );
-                      }),
-                    ),
+          gridData: const FlGridData(show: true),
+          borderData: FlBorderData(show: true),
+          barGroups: <BarChartGroupData>[
+            for (var i = 0; i < values.length; i++)
+              BarChartGroupData(
+                x: i,
+                barRods: [
+                  BarChartRodData(
+                    toY: values[i],
+                    color: Colors.primaries[i % Colors.primaries.length],
+                    width: 16,
                   ),
                 ],
               ),
-            ),
           ],
         ),
-      ),
-    );
-  }
-  
-  Widget _buildRadarChart() {
-  if (_dataValues.length < 3) {
-    return Card(
-      elevation: 4,
-      margin: EdgeInsets.all(16),
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            Text(
-              '雷达图',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
+      );
+
+  Widget _lineChart(List<double> values, List<String> labels) => LineChart(
+        LineChartData(
+          lineTouchData: const LineTouchData(enabled: true),
+          gridData: const FlGridData(show: true),
+          titlesData: FlTitlesData(
+            show: true,
+            bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
+            leftTitles: const AxisTitles(
+              sideTitles: SideTitles(showTitles: true, reservedSize: 40),
             ),
-            SizedBox(height: 16),
-            Container(
-              height: 300,
-              child: Center(
-                child: Text(
-                  '雷达图需要至少3个数据点\n请添加更多数据',
-                  textAlign: TextAlign.center,
-                  style: TextStyle(fontSize: 16, color: Colors.grey),
+          ),
+          borderData: FlBorderData(show: true),
+          minX: 0,
+          maxX: values.length > 1 ? (values.length - 1).toDouble() : 1,
+          minY: 0,
+          maxY: _niceMax(values),
+          lineBarsData: <LineChartBarData>[
+            LineChartBarData(
+              spots: <FlSpot>[
+                for (var i = 0; i < values.length; i++)
+                  FlSpot(i.toDouble(), values[i]),
+              ],
+              isCurved: _curved,
+              color: Colors.blue,
+              barWidth: 4,
+              belowBarData: BarAreaData(show: false),
+              dotData: FlDotData(
+                show: true,
+                getDotPainter: (spot, percent, barData, index) =>
+                    FlDotCirclePainter(
+                  radius: 4,
+                  color: Colors.blue,
+                  strokeWidth: 2,
+                  strokeColor: Colors.white,
                 ),
               ),
             ),
           ],
         ),
+      );
+
+  Widget _pieChart(List<double> values) {
+    var total = 0.0;
+    for (final v in values) {
+      total += v;
+    }
+    return PieChart(
+      PieChartData(
+        sectionsSpace: 4,
+        centerSpaceRadius: 40,
+        sections: <PieChartSectionData>[
+          for (var i = 0; i < values.length; i++)
+            PieChartSectionData(
+              color: Colors.primaries[i % Colors.primaries.length],
+              value: values[i],
+              // 百分比:数据本身没问题时才算,全零就留空。
+              title: total == 0
+                  ? ''
+                  : '${(values[i] / total * 100).toStringAsFixed(1)}%',
+              radius: 50,
+              titleStyle: const TextStyle(
+                fontSize: 12,
+                fontWeight: FontWeight.bold,
+                color: Colors.white,
+              ),
+            ),
+        ],
       ),
     );
   }
 
-  return Card(
-    elevation: 4,
-    margin: EdgeInsets.all(16),
-    child: Padding(
-      padding: EdgeInsets.all(16),
-      child: Column(
-        children: [
-          Text(
-            '雷达图',
-            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-          ),
-          SizedBox(height: 16),
-          Container(
-            height: 300,
-            child: RadarChart(
-              RadarChartData(
-                dataSets: [
-                  RadarDataSet(
-                    dataEntries: List.generate(_dataValues.length, (index) {
-                      return RadarEntry(
-                        value: _dataValues[index],
-                      );
-                    }),
-                    fillColor: Colors.blue.withOpacity(0.3),
-                    borderColor: Colors.blue,
-                    borderWidth: 2,
-                  ),
-                ],
-                radarBackgroundColor: Colors.transparent,
-                radarBorderData: BorderSide(color: Colors.grey, width: 1),
-                tickBorderData: BorderSide(color: Colors.grey, width: 1),
-                gridBorderData: BorderSide(color: Colors.grey, width: 1),
-                ticksTextStyle: TextStyle(color: Colors.black, fontSize: 10),
-                tickCount: 5,
-                getTitle: (index, angle) {
-                  if (index >= 0 && index < _dataLabels.length) {
-                    return RadarChartTitle(
-                      text: _dataLabels[index],
-                      angle: angle,
-                    );
-                  }
-                  return RadarChartTitle(text: '');
-                },
-                titleTextStyle: TextStyle(
-                  color: Colors.black,
-                  fontSize: 12,
-                  fontWeight: FontWeight.bold,
-                ),
-                titlePositionPercentageOffset: 0.15,
-              ),
-              swapAnimationDuration: Duration(milliseconds: 500),
-              swapAnimationCurve: Curves.easeInOut,
-            ),
-          ),
-          SizedBox(height: 16),
-          Container(
-            height: 20,
-            child: Row(
-              mainAxisAlignment: MainAxisAlignment.center,
-              children: [
-                Container(
-                  width: 12,
-                  height: 12,
-                  decoration: BoxDecoration(
-                    color: Colors.blue.withOpacity(0.3),
-                    border: Border.all(color: Colors.blue, width: 2),
-                  ),
-                ),
-                SizedBox(width: 8),
-                Text(
-                  '数据分布',
-                  style: TextStyle(fontSize: 12, color: Colors.grey),
-                ),
-              ],
-            ),
+  Widget _radarChart(List<double> values, List<String> labels) {
+    if (values.length < 3) {
+      return const Center(
+        child: Text(
+          '雷达图需要至少 3 个数据点\n请添加更多数据',
+          textAlign: TextAlign.center,
+          style: TextStyle(fontSize: 16, color: Colors.grey),
+        ),
+      );
+    }
+    return RadarChart(
+      RadarChartData(
+        dataSets: <RadarDataSet>[
+          RadarDataSet(
+            dataEntries: <RadarEntry>[
+              for (final v in values) RadarEntry(value: v),
+            ],
+            fillColor: Colors.blue.withValues(alpha: 0.3),
+            borderColor: Colors.blue,
+            borderWidth: 2,
           ),
         ],
-      ),
-    ),
-  );
-}
-  
-  @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: Text('统计图表绘制', style: TextStyle(color: Colors.black)),
-        backgroundColor: Colors.white,
-        elevation: 1,
-      ),
-      body: SingleChildScrollView(
-        child: Column(
-          children: [
-            _buildChartTypeSelector(),
-            _buildDataInputPanel(),
-            _buildChart(),
-          ],
+        radarBackgroundColor: Colors.transparent,
+        radarBorderData: const BorderSide(color: Colors.grey, width: 1),
+        tickBorderData: const BorderSide(color: Colors.grey, width: 1),
+        gridBorderData: const BorderSide(color: Colors.grey, width: 1),
+        ticksTextStyle: const TextStyle(color: Colors.black, fontSize: 10),
+        tickCount: 5,
+        getTitle: (index, angle) {
+          if (index >= 0 && index < labels.length) {
+            return RadarChartTitle(text: labels[index], angle: angle);
+          }
+          return const RadarChartTitle(text: '');
+        },
+        titleTextStyle: const TextStyle(
+          color: Colors.black,
+          fontSize: 12,
+          fontWeight: FontWeight.bold,
         ),
+        titlePositionPercentageOffset: 0.15,
       ),
+      swapAnimationDuration: const Duration(milliseconds: 500),
+      swapAnimationCurve: Curves.easeInOut,
     );
   }
-  
-  @override
-  void dispose() {
-    // 清理控制器
-    for (var controller in _dataControllers) {
-      controller.dispose();
-    }
-    for (var controller in _labelControllers) {
-      controller.dispose();
-    }
-    super.dispose();
-  }
-}
+}

+ 106 - 250
lib/tools/symmetry.dart

@@ -1,268 +1,124 @@
 // tools/symmetry.dart
 import 'package:flutter/material.dart';
-import '../utils/math_utils.dart';
 
+import 'tool_kit.dart';
+
+/// 对称变换 —— 三种模式共用一个页面,靠下拉框切换。
+///
+/// 输入框按模式分别建好、缓存在 [_fields] 里:切回来时原来填的数还在,
+/// 而外壳看到的是不同的 list 实例,会自动把上一次的结果清掉。
 class SymmetryPage extends StatefulWidget {
+  const SymmetryPage({super.key});
+
   @override
-  _SymmetryPageState createState() => _SymmetryPageState();
+  State<SymmetryPage> createState() => _SymmetryPageState();
 }
 
 class _SymmetryPageState extends State<SymmetryPage> {
-  int _selectedMode = 0; // 0: 点关于直线对称, 1: 直线关于点对称, 2: 直线关于直线对称
-
-  // 点关于直线对称
-  final TextEditingController pointXController = TextEditingController();
-  final TextEditingController pointYController = TextEditingController();
-  final TextEditingController lineAController = TextEditingController();
-  final TextEditingController lineBController = TextEditingController();
-  final TextEditingController lineCController = TextEditingController();
-
-  // 直线关于点对称
-  final TextEditingController line2AController = TextEditingController();
-  final TextEditingController line2BController = TextEditingController();
-  final TextEditingController line2CController = TextEditingController();
-  final TextEditingController point2XController = TextEditingController();
-  final TextEditingController point2YController = TextEditingController();
-
-  // 直线关于直线对称
-  final TextEditingController line3AController = TextEditingController();
-  final TextEditingController line3BController = TextEditingController();
-  final TextEditingController line3CController = TextEditingController();
-  final TextEditingController axisAController = TextEditingController();
-  final TextEditingController axisBController = TextEditingController();
-  final TextEditingController axisCController = TextEditingController();
-
-  String result = '';
+  static const _modes = ['点关于直线对称', '直线关于点对称', '直线关于直线对称'];
+
+  int _mode = 0;
+
+  // 每种模式一套输入框,建一次、一直留着重用。
+  final Map<int, List<ToolField>> _fields = {
+    0: [
+      ToolField('点 x', hint: '分数如 1/2,或小数'),
+      ToolField('点 y', hint: '分数如 1/2,或小数'),
+      ToolField('直线 A', hint: 'Ax + By + C = 0 的 A'),
+      ToolField('直线 B', hint: 'Ax + By + C = 0 的 B'),
+      ToolField('直线 C', hint: 'Ax + By + C = 0 的 C'),
+    ],
+    1: [
+      ToolField('直线 A', hint: 'Ax + By + C = 0 的 A'),
+      ToolField('直线 B', hint: 'Ax + By + C = 0 的 B'),
+      ToolField('直线 C', hint: 'Ax + By + C = 0 的 C'),
+      ToolField('点 x', hint: '分数如 1/2,或小数'),
+      ToolField('点 y', hint: '分数如 1/2,或小数'),
+    ],
+    2: [
+      ToolField('原直线 A', hint: 'Px + Qy + R = 0 的 P'),
+      ToolField('原直线 B', hint: 'Px + Qy + R = 0 的 Q'),
+      ToolField('原直线 C', hint: 'Px + Qy + R = 0 的 R'),
+      ToolField('对称轴 A', hint: 'Ax + By + C = 0 的 A'),
+      ToolField('对称轴 B', hint: 'Ax + By + C = 0 的 B'),
+      ToolField('对称轴 C', hint: 'Ax + By + C = 0 的 C'),
+    ],
+  };
 
   @override
   Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(title: Text('对称变换')),
-      body: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          children: [
-            DropdownButton<int>(
-              value: _selectedMode,
-              items: [
-                DropdownMenuItem(value: 0, child: Text('点关于直线对称')),
-                DropdownMenuItem(value: 1, child: Text('直线关于点对称')),
-                DropdownMenuItem(value: 2, child: Text('直线关于直线对称')),
-              ],
-              onChanged: (value) {
-                setState(() {
-                  _selectedMode = value!;
-                  result = '';
-                });
-              },
-            ),
-            SizedBox(height: 20),
-            _buildInputs(),
-            SizedBox(height: 20),
-            ElevatedButton(
-              onPressed: _calculate,
-              child: Text('计算'),
-            ),
-            SizedBox(height: 20),
-            if (result.isNotEmpty)
-              Card(
-                child: Padding(
-                  padding: EdgeInsets.all(16),
-                  child: Text(
-                    result,
-                    style: TextStyle(fontSize: 16),
-                  ),
-                ),
-              ),
-          ],
-        ),
+    final fields = _fields[_mode]!;
+    return ToolScaffold(
+      title: '对称变换',
+      fields: fields,
+      intro: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          DropdownButton<int>(
+            value: _mode,
+            isExpanded: true,
+            items: [
+              for (var i = 0; i < _modes.length; i++)
+                DropdownMenuItem(value: i, child: Text(_modes[i])),
+            ],
+            onChanged: (v) {
+              if (v != null) setState(() => _mode = v);
+            },
+          ),
+        ],
       ),
+      compute: (session) async {
+        final f = _fields[_mode]!;
+
+        if (_mode == 0) {
+          // 点 (x, y) 关于直线 Ax + By + C = 0 的对称点
+          final x = _need(f[0]);
+          final y = _need(f[1]);
+          final a = _need(f[2]);
+          final b = _need(f[3]);
+          final c = _need(f[4]);
+          final expr = 'With[{A=$a,B=$b,C=$c,x=$x,y=$y}, '
+              'Module[{t=(A*x+B*y+C)/(A^2+B^2)}, {x-2*A*t, y-2*B*t}]]';
+          final r = parseSymjaList((await session.compute(expr)).text);
+          return [ToolText('对称点', '(${r[0]}, ${r[1]})')];
+        }
+
+        if (_mode == 1) {
+          // 直线 Ax + By + C = 0 关于点 (x, y):A、B 不变,C' = -2Ax - 2By - C
+          final a = _need(f[0]);
+          final b = _need(f[1]);
+          final c = _need(f[2]);
+          final x = _need(f[3]);
+          final y = _need(f[4]);
+          final expr = 'With[{A=$a,B=$b,C=$c,x=$x,y=$y}, '
+              '{A, B, -2*A*x-2*B*y-C}]';
+          final r = parseSymjaList((await session.compute(expr)).text);
+          return [_line(r)];
+        }
+
+        // 原直线 Px + Qy + R = 0 关于对称轴 Ax + By + C = 0
+        final p = _need(f[0]);
+        final q = _need(f[1]);
+        final rr = _need(f[2]);
+        final a = _need(f[3]);
+        final b = _need(f[4]);
+        final c = _need(f[5]);
+        final expr = 'With[{P=$p,Q=$q,R=$rr,A=$a,B=$b,C=$c}, '
+            'Module[{k=2*(P*A+Q*B)/(A^2+B^2)}, {P-A*k, Q-B*k, R-C*k}]]';
+        final r = parseSymjaList((await session.compute(expr)).text);
+        return [_line(r)];
+      },
     );
   }
 
-  Widget _buildInputs() {
-    switch (_selectedMode) {
-      case 0:
-        return Column(
-          children: [
-            Text('点坐标:'),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('x', pointXController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('y', pointYController)),
-              ],
-            ),
-            Text('直线方程: Ax + By + C = 0'),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('A', lineAController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('B', lineBController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('C', lineCController)),
-              ],
-            ),
-          ],
-        );
-      case 1:
-        return Column(
-          children: [
-            Text('直线方程: Ax + By + C = 0'),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('A', line2AController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('B', line2BController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('C', line2CController)),
-              ],
-            ),
-            Text('点坐标:'),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('x', point2XController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('y', point2YController)),
-              ],
-            ),
-          ],
-        );
-      case 2:
-        return Column(
-          children: [
-            Text('原直线: Ax + By + C = 0'),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('A', line3AController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('B', line3BController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('C', line3CController)),
-              ],
-            ),
-            Text('对称轴: Ax + By + C = 0'),
-            Row(
-              children: [
-                Expanded(child: _buildInputField('A', axisAController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('B', axisBController)),
-                SizedBox(width: 10),
-                Expanded(child: _buildInputField('C', axisCController)),
-              ],
-            ),
-          ],
-        );
-      default:
-        return Container();
-    }
-  }
-
-  Widget _buildInputField(String label, TextEditingController controller) {
-    return TextField(
-      controller: controller,
-      keyboardType: TextInputType.numberWithOptions(signed: true, decimal: true),
-      decoration: InputDecoration(
-        labelText: label,
-        border: OutlineInputBorder(),
-      ),
-    );
-  }
+  /// 把 Symja 回来的一串 {A, B, C} 拼成直线方程文本。
+  ToolOutput _line(List<Object> r) =>
+      ToolText('对称直线方程', '${r[0]}·x + ${r[1]}·y + ${r[2]} = 0');
 
-  void _calculate() {
-    try {
-      switch (_selectedMode) {
-        case 0:
-          _calculatePointLineSymmetry();
-          break;
-        case 1:
-          _calculateLinePointSymmetry();
-          break;
-        case 2:
-          _calculateLineLineSymmetry();
-          break;
-      }
-    } catch (e) {
-      setState(() {
-        result = '错误: $e';
-      });
-    }
+  /// 取一个标量输入框:空着或填了多个值都直接报中文错。
+  String _need(ToolField f) {
+    if (f.count == 0) throw Exception('请填写「${f.label}」');
+    if (f.count > 1) throw Exception('「${f.label}」只能填一个数');
+    return f.text;
   }
-
-// 在 symmetry.dart 中修复相关代码
-void _calculatePointLineSymmetry() {
-  final x = MathUtils.parseRational(pointXController.text);
-  final y = MathUtils.parseRational(pointYController.text);
-  final A = MathUtils.parseRational(lineAController.text);
-  final B = MathUtils.parseRational(lineBController.text);
-  final C = MathUtils.parseRational(lineCController.text);
-
-  final solutions = MathUtils.solveLinear2(
-    A, B, - (A * x + B * y + MathUtils.two * C),
-    B, -A, B * x - A * y,
-  );
-
-  setState(() {
-    result = '对称点: (${MathUtils.rationalToString(solutions[0])}, '
-                     '${MathUtils.rationalToString(solutions[1])})';
-  });
-}
-
-void _calculateLinePointSymmetry() {
-  final A = MathUtils.parseRational(line2AController.text);
-  final B = MathUtils.parseRational(line2BController.text);
-  final C = MathUtils.parseRational(line2CController.text);
-  final x = MathUtils.parseRational(point2XController.text);
-  final y = MathUtils.parseRational(point2YController.text);
-
-  final newC = -MathUtils.two * A * x - MathUtils.two * B * y - C;
-
-  setState(() {
-    result = '对称直线方程: ${MathUtils.rationalToString(A)}x + '
-                     '${MathUtils.rationalToString(B)}y + '
-                     '${MathUtils.rationalToString(newC)} = 0';
-  });
-}
-
-void _calculateLineLineSymmetry() {
-  final P = MathUtils.parseRational(line3AController.text);
-  final Q = MathUtils.parseRational(line3BController.text);
-  final R = MathUtils.parseRational(line3CController.text);
-  final A = MathUtils.parseRational(axisAController.text);
-  final B = MathUtils.parseRational(axisBController.text);
-  final C = MathUtils.parseRational(axisCController.text);
-
-  final k = MathUtils.two * (P * A + Q * B) / (A * A + B * B);
-
-  final newA = P - A * k;
-  final newB = Q - B * k;
-  final newC = R - C * k;
-
-  setState(() {
-    result = '对称直线方程: ${MathUtils.rationalToString(newA)}x + '
-                     '${MathUtils.rationalToString(newB)}y + '
-                     '${MathUtils.rationalToString(newC)} = 0';
-  });
-}
-// 在 symmetry.dart 中修复 dispose 方法
-@override
-void dispose() {
-  pointXController.dispose();
-  pointYController.dispose();
-  lineAController.dispose();
-  lineBController.dispose();
-  lineCController.dispose();
-  line2AController.dispose();
-  line2BController.dispose();
-  line2CController.dispose();
-  point2XController.dispose();
-  point2YController.dispose();
-  line3AController.dispose();
-  line3BController.dispose();
-  line3CController.dispose();
-  axisAController.dispose();
-  axisBController.dispose();
-  axisCController.dispose();
-  super.dispose();
 }
-}

+ 418 - 0
lib/tools/tool_kit.dart

@@ -0,0 +1,418 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_math_fork/flutter_math.dart';
+import 'package:provider/provider.dart';
+
+import '../symja.dart';
+
+/// 工具页的公共外壳。
+///
+/// 十五个工具原先各写各的:各自的 Scaffold、各自拼字符串、各自显示一个
+/// `Text`。这套外壳把**输入 → 计算 → 输出**固定下来,好处有三个:
+///  1. 计算全部走 [Symja],Dart 只负责排版和取输入(绘图除外,见 [ToolChart]);
+///  2. 每条输出**单独成块、点一下就复制**,不再是一大坨拼在一起的文本;
+///  3. 重置按钮和错误提示不用每个工具再写一遍。
+///
+/// 刻意不用「抽象基类 + 子类」那套写法:工具之间的差异只有"有哪些输入框"和
+/// "算什么表达式"两件事,交给两个参数就够了,继承反而要每个工具建一个类。
+class ToolScaffold extends StatefulWidget {
+  const ToolScaffold({
+    super.key,
+    required this.title,
+    required this.fields,
+    required this.compute,
+    this.intro,
+    this.autoRun = false,
+  });
+
+  final String title;
+
+  /// 打开就自动算一次。绘图和二分法这类**输入框里已经有默认值**的工具用得上:
+  /// 一进来就该看到图/解,而不是先按一下按钮。
+  final bool autoRun;
+
+  /// 输入框。顺序即显示顺序。
+  ///
+  /// 由**页面**持有(建在 State 里,别建在 build 里,否则主题一变控制器就被换掉、
+  /// 用户打的字会丢)。外壳不 dispose 它们:对称变换那种一个页面里换几组输入框的
+  /// 工具,控制器要跨模式活着。
+  final List<ToolField> fields;
+
+  /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。
+  final Widget? intro;
+
+  /// 计算。自己从 [fields] 里取文本、拼 Symja 表达式、调 [SymjaSession.compute]。
+  ///
+  /// 返回若干条输出:文字的可点复制,图表的不复制(图没什么可复制的)。
+  final Future<List<ToolOutput>> Function(SymjaSession) compute;
+
+  @override
+  State<ToolScaffold> createState() => _ToolScaffoldState();
+}
+
+class _ToolScaffoldState extends State<ToolScaffold> {
+  final List<ToolOutput> _outputs = [];
+  String? _error;
+  bool _busy = false;
+
+  /// 换了一组输入框(对称变换那种切模式的情况)就把上一次的结果清掉 ——
+  /// 留着上一模式的答案是误导。
+  @override
+  void didUpdateWidget(ToolScaffold old) {
+    super.didUpdateWidget(old);
+    if (!identical(old.fields, widget.fields)) {
+      _outputs.clear();
+      _error = null;
+    }
+  }
+
+  @override
+  void initState() {
+    super.initState();
+    if (widget.autoRun) {
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        if (mounted) _run();
+      });
+    }
+  }
+
+  Future<void> _run() async {
+    final session = context.read<SymjaSession>();
+    setState(() {
+      _busy = true;
+      _error = null;
+      _outputs.clear();
+    });
+    try {
+      final out = await widget.compute(session);
+      if (!mounted) return;
+      setState(() => _outputs.addAll(out));
+    } on SymjaException catch (e) {
+      if (mounted) setState(() => _error = '$e');
+    } catch (e) {
+      // 输入格式不对(少填了、写了非数字)在这里兜住,别把异常弹到 framework
+      if (mounted) setState(() => _error = '$e');
+    } finally {
+      if (mounted) setState(() => _busy = false);
+    }
+  }
+
+  void _reset() {
+    for (final f in widget.fields) {
+      f.controller.clear();
+    }
+    setState(() {
+      _outputs.clear();
+      _error = null;
+    });
+  }
+
+  /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。
+  Widget _busyStrip(ColorScheme scheme) => Padding(
+        padding: const EdgeInsets.symmetric(vertical: 10),
+        child: Row(
+          children: [
+            const SizedBox(
+              width: 14,
+              height: 14,
+              child: CircularProgressIndicator(strokeWidth: 2),
+            ),
+            const SizedBox(width: 10),
+            Text('计算中…', style: TextStyle(color: scheme.outline)),
+          ],
+        ),
+      );
+
+  @override
+  Widget build(BuildContext context) {
+    final scheme = Theme.of(context).colorScheme;
+
+    return Scaffold(
+      appBar: AppBar(title: Text(widget.title)),
+      body: ListView(
+        padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
+        children: [
+          if (widget.intro != null) ...[
+            widget.intro!,
+            const SizedBox(height: 12),
+          ],
+          for (final f in widget.fields) f.build(context),
+          const SizedBox(height: 12),
+          Row(
+            children: [
+              Expanded(
+                child: FilledButton.icon(
+                  onPressed: _busy ? null : _run,
+                  icon: const Icon(Icons.calculate_outlined),
+                  label: const Text('计算'),
+                ),
+              ),
+              const SizedBox(width: 12),
+              OutlinedButton.icon(
+                onPressed: _busy ? null : _reset,
+                icon: const Icon(Icons.refresh),
+                label: const Text('重置'),
+              ),
+            ],
+          ),
+          if (_busy) _busyStrip(scheme),
+          if (_error != null)
+            Padding(
+              padding: const EdgeInsets.only(top: 16),
+              child: Card(
+                color: scheme.errorContainer,
+                child: Padding(
+                  padding: const EdgeInsets.all(14),
+                  child: Row(
+                    crossAxisAlignment: CrossAxisAlignment.start,
+                    children: [
+                      Icon(Icons.error_outline,
+                          size: 20, color: scheme.onErrorContainer),
+                      const SizedBox(width: 10),
+                      Expanded(
+                        child: Text(
+                          _error!,
+                          style: TextStyle(color: scheme.onErrorContainer),
+                        ),
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+          for (final o in _outputs)
+            Padding(
+              padding: const EdgeInsets.only(top: 12),
+              child: _OutputCard(output: o),
+            ),
+        ],
+      ),
+    );
+  }
+}
+
+/// 一个输入框。label 同时是发送给 Symja 时的变量含义提示。
+class ToolField {
+  ToolField(
+    this.label, {
+    String? hint,
+    this.keyboard = const TextInputType.numberWithOptions(
+      signed: true,
+      decimal: true,
+    ),
+  })  : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
+        controller = TextEditingController();
+
+  final String label;
+  final String hint;
+  final TextInputType keyboard;
+  final TextEditingController controller;
+
+  String get text => controller.text.trim();
+
+  /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 ——
+  /// 数个数是记账,不是计算,留在 Dart 侧。
+  int get count => _tokens.length;
+
+  List<String> get _tokens =>
+      text.split(RegExp(r'[\s,,]+')).where((s) => s.isNotEmpty).toList();
+
+  /// 空格/逗号分隔的多个值 → Symja 的列表 `{1, 2, 3}`。
+  ///
+  /// 工具页允许用户直接写 `1/2` 这样的分数和 `Pi` 这样的常量 —— 它们交给 Symja
+  /// 解析,Dart 侧不做数字转换(转换反而会把 `1/2` 变成 0.5 这种近似值)。
+  String get list => '{${_tokens.join(', ')}}';
+
+  Widget build(BuildContext context) => Padding(
+        padding: const EdgeInsets.symmetric(vertical: 6),
+        child: TextField(
+          controller: controller,
+          keyboardType: keyboard,
+          decoration: InputDecoration(
+            labelText: label,
+            hintText: hint,
+            border: const OutlineInputBorder(),
+            isDense: true,
+          ),
+        ),
+      );
+}
+
+/// 一条输出。[ToolText] 可点复制,[ToolChart] 不行 —— 图没有可复制的文本形态。
+sealed class ToolOutput {
+  const ToolOutput(this.label);
+  final String label;
+}
+
+/// 一条文字输出。[tex] 不为空时渲染成数学排版,点复制复制的仍是纯文本。
+class ToolText extends ToolOutput {
+  const ToolText(super.label, this.text, {this.tex});
+  final String text;
+  final String? tex;
+}
+
+/// 一条图表输出。**图表不参与复制**(任务里明确要求的)。
+class ToolChart extends ToolOutput {
+  const ToolChart(super.label, this.chart);
+  final Widget chart;
+}
+
+class _OutputCard extends StatelessWidget {
+  const _OutputCard({required this.output});
+
+  final ToolOutput output;
+
+  Future<void> _copy(BuildContext context, String text) async {
+    await Clipboard.setData(ClipboardData(text: text));
+    if (!context.mounted) return;
+    ScaffoldMessenger.of(context)
+      ..clearSnackBars()
+      ..showSnackBar(
+        SnackBar(
+          content: Text('已复制:${text.length > 30 ? '${text.substring(0, 30)}…' : text}'),
+          duration: const Duration(milliseconds: 1200),
+        ),
+      );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final scheme = Theme.of(context).colorScheme;
+    final o = output;
+
+    if (o is ToolChart) {
+      return Card(
+        child: Padding(
+          padding: const EdgeInsets.all(12),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Text(o.label, style: Theme.of(context).textTheme.labelLarge),
+              const SizedBox(height: 8),
+              o.chart,
+            ],
+          ),
+        ),
+      );
+    }
+
+    final t = o as ToolText;
+    return Card(
+      clipBehavior: Clip.antiAlias,
+      child: InkWell(
+        onTap: () => _copy(context, t.text),
+        child: Padding(
+          padding: const EdgeInsets.all(14),
+          child: Row(
+            crossAxisAlignment: CrossAxisAlignment.center,
+            children: [
+              Expanded(
+                child: Column(
+                  crossAxisAlignment: CrossAxisAlignment.start,
+                  children: [
+                    Text(
+                      t.label,
+                      style: TextStyle(fontSize: 12, color: scheme.outline),
+                    ),
+                    const SizedBox(height: 6),
+                    // 有 TeX 就用数学排版显示;太长或没生成就退回纯文本
+                    if (t.tex != null)
+                      Math.tex(
+                        t.tex!,
+                        mathStyle: MathStyle.text,
+                        textStyle: TextStyle(
+                          fontSize: 20,
+                          color: scheme.onSurface,
+                        ),
+                        onErrorFallback: (_) => _plain(t.text),
+                      )
+                    else
+                      _plain(t.text),
+                  ],
+                ),
+              ),
+              const SizedBox(width: 8),
+              Icon(Icons.copy_outlined, size: 18, color: scheme.outline),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+
+  /// 纯文本渲染。刻意用 `Text` 而不是 `SelectableText` —— 后者会把点击吃掉拿去
+  /// 做选区,整张卡片的"点一下复制"就时灵时不灵了。
+  Widget _plain(String text) => Text(
+        text,
+        style: const TextStyle(fontFamily: 'monospace', fontSize: 16),
+      );
+}
+
+/// 精确值 + 近似值两行输出。
+///
+/// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。现在精确值由 Symja
+/// 原样给出,近似值再问一次 `N[...]`。**两者相等时只留一行** —— 否则每个整数
+/// 结果都会多出一条一模一样的"≈"。
+Future<List<ToolOutput>> exactAndApprox(
+  SymjaSession session,
+  String label,
+  String expr,
+) async {
+  final exact = await session.compute(expr, applyMode: false);
+  final approx = await session.compute('N[$expr, 10]', applyMode: false);
+  final same = approx.text == exact.text;
+  return [
+    ToolText(label, exact.text, tex: exact.tex),
+    if (!same) ToolText('$label(近似)', approx.text, tex: approx.tex),
+  ];
+}
+
+/// 纯数值输出:只给近似值(用户要的本来就是个数,不是分数)。
+Future<ToolOutput> numeric(
+  SymjaSession session,
+  String label,
+  String expr,
+) async {
+  final r = await session.compute('N[$expr, 12]', applyMode: false);
+  return ToolText(label, r.text, tex: null);
+}
+
+/// 把 Symja 输出的列表文本拆成嵌套的字符串列表。
+///
+/// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示,
+/// 而通道里回来的是 `{{0, 1/6}, {1, 1/3}}` 这样一个字符串。刻意**不**做数字解析:
+/// 拆出来的还是 Symja 的原文(`1/6` 就是 `1/6`),要算数再交回给 Symja。
+List<Object> parseSymjaList(String s) {
+  final t = s.trim();
+  if (!t.startsWith('{') || !t.endsWith('}')) return [t];
+  final out = <Object>[];
+  final buf = StringBuffer();
+  var depth = 0;
+  for (var i = 1; i < t.length - 1; i++) {
+    final c = t[i];
+    if (c == '{' || c == '[' || c == '(') depth++;
+    if (c == '}' || c == ']' || c == ')') depth--;
+    if (c == ',' && depth == 0) {
+      out.add(_item(buf.toString()));
+      buf.clear();
+      continue;
+    }
+    buf.write(c);
+  }
+  if (buf.isNotEmpty) out.add(_item(buf.toString()));
+  return out;
+}
+
+Object _item(String raw) {
+  final t = raw.trim();
+  return t.startsWith('{') ? parseSymjaList(t) : t;
+}
+
+/// 取出列表里的数字(Symja 的近似值带一个 `` ` `` 精度标记,要去掉)。
+///
+/// 只给**画图**用 —— 图表要的是 double,而"精确值"那条路始终走 Symja 原文。
+double? asNumber(Object? item) {
+  if (item is! String) return null;
+  return double.tryParse(item.replaceAll('`', '').trim());
+}
+

+ 52 - 291
lib/tools/vector_normal.dart

@@ -1,309 +1,70 @@
 // tools/vector_normal.dart
 //
-// 计算部分走 Symja(符号计算库),不再用自研的 rationalsqrt2.dart。
-// 那个自研的 √n 代数只能表示"有理数 × 一层根号 + 若干项之和",遇到
-// 坐标里出现两层根号、分数套根号、或者三角函数就得放弃;交给 CAS 之后
-// 输入什么就能精确算什么。
+// 三点法向量:AB × AC 全部交给 Symja 做符号运算。
+//
+// 坐标按**用户原文**拼进表达式(不转成小数)—— 用户写 `2/3`、`Sqrt[2]`、`Pi`,
+// 算出来的法向量就是精确值,带根号和分数,而不是 0.3333 这种近似。
 import 'package:flutter/material.dart';
-import 'package:flutter_math_fork/flutter_math.dart';
-import 'package:provider/provider.dart';
 
-import '../symja.dart';
+import 'tool_kit.dart';
 
 class VectorNormalPage extends StatefulWidget {
+  const VectorNormalPage({super.key});
+
   @override
-  _VectorNormalPageState createState() => _VectorNormalPageState();
+  State<VectorNormalPage> createState() => _VectorNormalPageState();
 }
 
 class _VectorNormalPageState extends State<VectorNormalPage> {
-  final List<TextEditingController> _controllers = List.generate(9, (_) => TextEditingController());
-  String _result = '';
-  String? _resultTex;
-  String? _error;
-  bool _isCalculating = false;
-
-  @override
-  void dispose() {
-    for (var controller in _controllers) {
-      controller.dispose();
-    }
-    super.dispose();
-  }
-
-  // 计算法向量:AB × AC,全部交给 Symja 做符号运算
-  Future<void> _calculateNormal() async {
-    final session = context.read<SymjaSession>();
-    setState(() {
-      _isCalculating = true;
-      _result = '';
-      _resultTex = null;
-      _error = null;
-    });
-
-    // 每个坐标先过一遍语法转换,这样用户沿用旧的 `2√3` 写法也能用
-    String coord(int point, int axis) {
-      final raw = _controllers[point * 3 + axis].text.trim();
-      // 原样交给 Symja
-      return raw.isEmpty ? '0' : raw;
-    }
-
-    // 边向量:to - from
-    String vec(int from, int to) => '{'
-        '(${coord(to, 0)} - ${coord(from, 0)}), '
-        '(${coord(to, 1)} - ${coord(from, 1)}), '
-        '(${coord(to, 2)} - ${coord(from, 2)})}';
-
-    final expr = 'Cross[${vec(0, 1)}, ${vec(0, 2)}]';
-
-    try {
-      final r = await session.compute(expr);
-      if (!mounted) return;
-      setState(() {
-        _result = r.text;
-        _resultTex = r.tex;
-      });
-    } on SymjaException catch (e) {
-      if (!mounted) return;
-      setState(() => _error = '$e');
-    } finally {
-      if (mounted) setState(() => _isCalculating = false);
-    }
+  // 9 个坐标框建在 State 里:主题一变页面重建时控制器不会跟着换掉、用户打的字会丢。
+  // 顺序:A.x A.y A.z B.x B.y B.z C.x C.y C.z。
+  final List<ToolField> _coords = [
+    ToolField('点 A 的 x', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 A 的 y', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 A 的 z', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 B 的 x', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 B 的 y', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 B 的 z', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 C 的 x', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 C 的 y', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+    ToolField('点 C 的 z', hint: '例如 2/3、Sqrt[2]、Pi', keyboard: TextInputType.text),
+  ];
+
+  /// 取第 [point] 个点(0=A、1=B、2=C)的第 [axis] 个坐标(0=x、1=y、2=z)。
+  /// 空着当 0;其余**原样**返回,交给 Symja 解析(Dart 侧不做数字转换)。
+  String _coord(int point, int axis) {
+    final raw = _coords[point * 3 + axis].text;
+    return raw.isEmpty ? '0' : raw;
   }
 
-  // 清除所有输入
-  void _clearAll() {
-    for (var controller in _controllers) {
-      controller.clear();
-    }
-    setState(() {
-      _result = '';
-      _resultTex = null;
-      _error = null;
-    });
-  }
+  /// 边向量 to - from,写成 Symja 的列表。每个坐标都加括号,
+  /// 免得用户写的 `1 + Sqrt[3]` 和后面的减号黏在一起算错。
+  String _edge(int from, int to) => '{'
+      '(${_coord(to, 0)}) - (${_coord(from, 0)}), '
+      '(${_coord(to, 1)}) - (${_coord(from, 1)}), '
+      '(${_coord(to, 2)}) - (${_coord(from, 2)})}';
 
   @override
-  Widget build(BuildContext context) {
-    return Scaffold(
-      appBar: AppBar(
-        title: Text('三点法向量计算'),
-        backgroundColor: Colors.white,
-        elevation: 1,
-      ),
-      body: SingleChildScrollView(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            _buildInputSection('点A', 0),
-            SizedBox(height: 16),
-            _buildInputSection('点B', 3),
-            SizedBox(height: 16),
-            _buildInputSection('点C', 6),
-            SizedBox(height: 24),
-            _buildControlButtons(),
-            SizedBox(height: 24),
-            _buildResultSection(),
-            SizedBox(height: 24),
-            _buildInstructions(),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInputSection(String title, int startIndex) {
-    return Card(
-      elevation: 2,
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              title,
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 12),
-            Row(
-              children: [
-                _buildCoordinateInput('x', _controllers[startIndex]),
-                SizedBox(width: 12),
-                _buildCoordinateInput('y', _controllers[startIndex + 1]),
-                SizedBox(width: 12),
-                _buildCoordinateInput('z', _controllers[startIndex + 2]),
-              ],
-            ),
-          ],
+  Widget build(BuildContext context) => ToolScaffold(
+        title: '三点法向量计算',
+        fields: _coords,
+        intro: const Text(
+          '给三个点 A、B、C,算法向量 AB × AC。\n'
+          '坐标按 Symja 语法输入:2/3(分数)、Sqrt[2](根号)、Pi,空着当 0。\n'
+          '结果是精确值,根号、分数原样保留,不会转成小数。\n'
+          '如果已知两向量坐标,把一个点设为原点即可。',
         ),
-      ),
-    );
-  }
-
-  Widget _buildCoordinateInput(String label, TextEditingController controller) {
-    return Expanded(
-      child: Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
-        children: [
-          Text(
-            label,
-            style: TextStyle(fontSize: 14, color: Colors.grey[600]),
-          ),
-          SizedBox(height: 4),
-          TextField(
-            controller: controller,
-            decoration: InputDecoration(
-              hintText: '0',
-              border: OutlineInputBorder(),
-              contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
+        compute: (session) async {
+          final ab = _edge(0, 1); // B - A
+          final ac = _edge(0, 2); // C - A
+          final r = await session.compute('Cross[$ab, $ac]');
+          return [
+            ToolText('法向量 AB × AC', r.text, tex: r.tex),
+            const ToolText(
+              '说明',
+              '法向量由向量 AB 与向量 AC 的叉积计算得到。',
             ),
-            keyboardType: TextInputType.number,
-          ),
-        ],
-      ),
-    );
-  }
-
-  Widget _buildControlButtons() {
-    return Row(
-      children: [
-        Expanded(
-          flex: 3,
-          child: ElevatedButton(
-            onPressed: _isCalculating ? null : _calculateNormal,
-            style: ElevatedButton.styleFrom(
-              backgroundColor: Colors.blue,
-              padding: EdgeInsets.symmetric(vertical: 12),
-            ),
-            child: _isCalculating
-            ? SizedBox(
-              width: 20,
-              height: 20,
-              child: CircularProgressIndicator(
-                strokeWidth: 2,
-                valueColor: AlwaysStoppedAnimation(Colors.white),
-              ),
-            )
-            : Text(
-              '计算法向量',
-              style: TextStyle(fontSize: 16, color: Colors.white),
-            ),
-          ),
-        ),
-        SizedBox(width: 12),
-        Expanded(
-          flex: 2,
-          child: OutlinedButton(
-            onPressed: _clearAll,
-            style: OutlinedButton.styleFrom(
-              padding: EdgeInsets.symmetric(vertical: 12),
-            ),
-            child: Text('清除', style: TextStyle(fontSize: 16)),
-          ),
-        ),
-      ],
-    );
-  }
-
-  Widget _buildResultSection() {
-    if (_error != null) {
-      return Card(
-        color: Colors.red[50],
-        elevation: 2,
-        child: Padding(
-          padding: EdgeInsets.all(16),
-          child: Row(
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: [
-              Icon(Icons.error_outline, color: Colors.red[700], size: 20),
-              SizedBox(width: 8),
-              Expanded(
-                child: SelectableText(
-                  '计算错误:$_error',
-                  style: TextStyle(color: Colors.red[700], fontSize: 14),
-                ),
-              ),
-            ],
-          ),
-        ),
+          ];
+        },
       );
-    }
-    if (_result.isEmpty) return SizedBox();
-
-    return Card(
-      color: Colors.grey[50],
-      elevation: 2,
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '计算结果',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 12),
-            // 法向量是精确的符号结果(可能带根号、分数),用 LaTeX 排出来
-            // 比纯文本好读得多。
-            if (_resultTex != null)
-              Math.tex(
-                _resultTex!,
-                mathStyle: MathStyle.display,
-                textStyle: TextStyle(fontSize: 20, color: Colors.blue[800]),
-                onErrorFallback: (_) => Text(
-                  _result,
-                  style: TextStyle(fontSize: 16, color: Colors.blue[700]),
-                ),
-              )
-            else
-              Text(
-                _result,
-                style: TextStyle(fontSize: 16, color: Colors.blue[700]),
-              ),
-            SizedBox(height: 8),
-            // 纯文本那份留着,方便复制到别处
-            SelectableText(
-              _result,
-              style: TextStyle(fontSize: 12, color: Colors.grey[700]),
-            ),
-            SizedBox(height: 8),
-            Text(
-              '说明:法向量是通过向量AB和向量AC的叉积计算得到',
-              style: TextStyle(fontSize: 12, color: Colors.grey[600]),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
-
-  Widget _buildInstructions() {
-    return Card(
-      color: Colors.grey[50],
-      elevation: 2,
-      child: Padding(
-        padding: EdgeInsets.all(16),
-        child: Column(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            Text(
-              '使用说明',
-              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
-            ),
-            SizedBox(height: 12),
-            Text(
-              '坐标按 Symja 语法输入,例如:\n'
-              '  2/3      分数\n'
-              '  Sqrt[2]  根号(也可写 √2)\n'
-              '  Sqrt[2]/2、1 + Sqrt[3]、Pi\n'
-              '\n'
-              '结果是精确值:带根号、分数会原样保留,不会转成小数。\n'
-              '如果已知两向量坐标,将一个点设为原点即可。',
-              style: TextStyle(fontSize: 14, color: Colors.grey[700]),
-            ),
-          ],
-        ),
-      ),
-    );
-  }
 }

+ 4 - 2
lib/tools_page.dart

@@ -4,8 +4,10 @@ import 'plugins.dart';
 
 /// 工具网格:可搜索的插件入口。
 ///
-/// 工具页面的实现全部在 lib/tools/ 下,是从 mathtool2 直接搬过来的
-/// (依赖 rational / math_expressions / fl_chart,全是纯 Dart)。
+/// 工具页面的实现全部在 lib/tools/ 下(从 mathtool2 搬过来的那批)。
+/// **计算一律交给 Symja**,走 lib/tools/tool_kit.dart 那套外壳;Dart 侧只有
+/// 绘图采样和排版。原来那批纯 Dart 的算法(rational / math_expressions)
+/// 已经全部撤掉 —— 同一个算式不该有 Dart 和 Symja 两份实现。
 class ToolsPage extends StatefulWidget {
   const ToolsPage({super.key});
 

+ 0 - 129
lib/utils/math_utils.dart

@@ -1,129 +0,0 @@
-// utils/math_utils.dart
-import 'package:rational/rational.dart';
-
-class MathUtils {
-  // 添加常量
-  static final Rational zero = Rational.zero;
-  static final Rational one = Rational.one;
-  static final Rational two = Rational.fromInt(2);
-  static final Rational half = Rational.fromInt(1) / Rational.fromInt(2);
-
-  static Rational parseRational(String input) {
-    try {
-      // 去除空格
-      input = input.trim();
-      
-      if (input.contains('/')) {
-        List<String> parts = input.split('/');
-        if (parts.length == 2) {
-          String numerator = parts[0].trim();
-          String denominator = parts[1].trim();
-          
-          // 修复:直接使用 Rational 构造函数而不是 parse
-          BigInt num = BigInt.parse(numerator);
-          BigInt den = BigInt.parse(denominator);
-          
-          if (den == BigInt.zero) {
-            throw FormatException('分母不能为零');
-          }
-          
-          return Rational(num, den);
-        }
-      }
-      
-      // 处理小数
-      if (input.contains('.')) {
-        return _decimalToRational(input);
-      }
-      
-      // 处理整数
-      return Rational.parse(input);
-    } catch (e) {
-      throw FormatException('无效的分数格式: $input');
-    }
-  }
-
-  static Rational _decimalToRational(String decimalStr) {
-    // 更精确的小数转分数实现
-    if (!decimalStr.contains('.')) {
-      return Rational.parse(decimalStr);
-    }
-    
-    List<String> parts = decimalStr.split('.');
-    String integerPart = parts[0];
-    String decimalPart = parts[1];
-    
-    // 处理负号
-    bool isNegative = integerPart.startsWith('-');
-    if (isNegative) {
-      integerPart = integerPart.substring(1);
-    }
-    
-    // 如果整数部分是空的,设为0
-    if (integerPart.isEmpty) {
-      integerPart = '0';
-    }
-    
-    // 计算分母:10的decimalPart长度次方
-    BigInt denominator = BigInt.from(10).pow(decimalPart.length);
-    // 计算分子:整数部分 * 分母 + 小数部分
-    BigInt numerator = 
-        BigInt.parse(integerPart) * denominator + BigInt.parse(decimalPart);
-    
-    if (isNegative) {
-      numerator = -numerator;
-    }
-    
-    return Rational(numerator, denominator);
-  }
-
-  static String rationalToString(Rational rational) {
-    // 简化分数显示
-    if (rational.denominator == BigInt.one) {
-      return rational.numerator.toString();
-    }
-    
-    // 尝试约分
-    Rational simplified = rational;
-    
-    return '${simplified.numerator}/${simplified.denominator}';
-  }
-
-  static Rational det2(Rational a, Rational b, Rational c, Rational d) {
-    return a * d - b * c;
-  }
-
-  static List<Rational> solveLinear2(
-    Rational a1, Rational b1, Rational c1,
-    Rational a2, Rational b2, Rational c2,
-  ) {
-    final det = det2(a1, b1, a2, b2);
-    if (det == zero) {
-      throw Exception('方程组无解或有无穷多解');
-    }
-    final x = det2(c1, b1, c2, b2) / det;
-    final y = det2(a1, c1, a2, c2) / det;
-    return [x, y];
-  }
-
-  // 创建 Rational 从 BigInt
-  static Rational fromBigInt(BigInt value) {
-    return Rational(value);
-  }
-
-  // 创建 Rational 从 int
-  static Rational fromInt(int value) {
-    return Rational.fromInt(value);
-  }
-  
-  // 计算最大公约数
-  // ignore: unused_element  —— mathtool2 遗留的私有工具函数,全量搬迁时保留原样
-  static BigInt _gcd(BigInt a, BigInt b) {
-    while (b != BigInt.zero) {
-      final temp = b;
-      b = a % b;
-      a = temp;
-    }
-    return a;
-  }
-}

+ 0 - 769
lib/utils/rationalsqrt2.dart

@@ -1,769 +0,0 @@
-// utils/rationalsqrt2.dart
-
-/* 此文件实现了有理数与二次根式形成的数域
- * 需要在pubspec.yaml中添加依赖rational: ^2.2.3
- * 使用方法请看最后的测试用例
- * Ksqrt类表示有理数乘以根号整数
- * RationalExtend类表示多个Ksqrt类根式相加
- * Author:nonvioce
- * Time: 2025.10.26
-*/
-import 'package:rational/rational.dart';
-import 'dart:math';
-
-class Ksqrt {
-  Rational k; // 有理数系数
-  int q;      // 底数(整数)
-
-  Ksqrt(this.k, this.q) {
-    if (q < 0) throw ArgumentError("底数q不能为负数");
-  }
-
-  // 从整数创建(系数为整数,底数为1)
-  factory Ksqrt.fromInt(int value) {
-    return Ksqrt(Rational.fromInt(value), 1);
-  }
-
-  // 从Rational创建(系数为有理数,底数为1)
-  factory Ksqrt.fromRational(Rational rational) {
-    return Ksqrt(rational, 1);
-  }
-
-  // 增强的字符串解析 - 增加小数支持
-  factory Ksqrt.parse(String str) {
-    str = str.trim();
-
-    // 处理纯数字(整数或小数)
-    if (RegExp(r'^-?\d+(\.\d+)?$').hasMatch(str)) {
-      return Ksqrt.fromRational(_parseRational(str));
-    }
-
-    // 处理分数(不包含根号)
-    if (str.contains('/') && !str.contains('√')) {
-      try {
-        var parts = str.split('/');
-        if (parts.length != 2) throw FormatException("无效的分数格式: $str");
-
-        var numerator = _parseRational(parts[0]);
-        var denominator = _parseRational(parts[1]);
-
-        if (denominator == Rational.zero) throw FormatException("分母不能为零: $str");
-
-        return Ksqrt.fromRational(numerator / denominator);
-      } catch (e) {
-        throw FormatException("无效的分数格式: $str, 错误: $e");
-      }
-    }
-
-    // 处理根式
-    if (str.contains('√')) {
-      var sqrtIndex = str.indexOf('√');
-      var coefficientStr = str.substring(0, sqrtIndex);
-      var radicandStr = str.substring(sqrtIndex + 1);
-
-      // 处理括号内的底数
-      if (radicandStr.startsWith('(') && radicandStr.endsWith(')')) {
-        radicandStr = radicandStr.substring(1, radicandStr.length - 1);
-      }
-
-      Rational k;
-
-      // 解析系数
-      if (coefficientStr.isEmpty || coefficientStr == "+") {
-        k = Rational.one;
-      } else if (coefficientStr == "-") {
-        k = -Rational.one;
-      } else {
-        coefficientStr = coefficientStr.replaceAll('*', '');
-        try {
-          if (coefficientStr.contains('/')) {
-            var coeffParts = coefficientStr.split('/');
-            if (coeffParts.length != 2) throw FormatException("无效的系数格式: $coefficientStr");
-
-            var num = _parseRational(coeffParts[0]);
-            var den = _parseRational(coeffParts[1]);
-            if (den == Rational.zero) throw FormatException("分母不能为零: $coefficientStr");
-
-            k = num / den;
-          } else {
-            k = _parseRational(coefficientStr);
-          }
-        } catch (e) {
-          throw FormatException("无效的系数格式: $coefficientStr");
-        }
-      }
-
-      // 解析底数
-      int q;
-      if (radicandStr.contains('/')) {
-        throw FormatException("分数底数暂不支持: $str");
-      } else {
-        try {
-          // 底数必须是整数,所以使用int.parse而不是_parseRational
-          q = int.parse(radicandStr);
-        } catch (e) {
-          throw FormatException("无效的底数格式: $radicandStr");
-        }
-      }
-
-      return Ksqrt(k, q)..simplify();
-    }
-
-    // 尝试解析为有理数(包括小数)
-    try {
-      return Ksqrt.fromRational(_parseRational(str));
-    } catch (e) {
-      throw FormatException("无法解析的字符串: $str");
-    }
-  }
-
-  // 小数转有理数的辅助函数
-  static Rational _parseRational(String input) {
-    try {
-      input = input.trim();
-
-      if (input.contains('/')) {
-        List<String> parts = input.split('/');
-        if (parts.length == 2) {
-          String numerator = parts[0].trim();
-          String denominator = parts[1].trim();
-
-          // 修复:直接使用 Rational 构造函数而不是 parse
-          BigInt num = BigInt.parse(numerator);
-          BigInt den = BigInt.parse(denominator);
-
-          if (den == BigInt.zero) {
-            throw FormatException('分母不能为零');
-          }
-
-          return Rational(num, den);
-        }
-      }
-
-      // 处理小数
-      if (input.contains('.')) {
-        return _decimalToRational(input);
-      }
-
-      // 处理整数
-      return Rational.parse(input);
-    } catch (e) {
-      throw FormatException('无效的分数格式: $input');
-    }
-  }
-
-  // 小数转有理数的实现
-  static Rational _decimalToRational(String decimalStr) {
-    if (!decimalStr.contains('.')) {
-      return Rational.parse(decimalStr);
-    }
-
-    List<String> parts = decimalStr.split('.');
-    String integerPart = parts[0];
-    String decimalPart = parts[1];
-
-    // 处理负号
-    bool isNegative = integerPart.startsWith('-');
-    if (isNegative) {
-      integerPart = integerPart.substring(1);
-    }
-
-    // 如果整数部分是空的,设为0
-    if (integerPart.isEmpty) {
-      integerPart = '0';
-    }
-
-    // 计算分母:10的decimalPart长度次方
-    BigInt denominator = BigInt.from(10).pow(decimalPart.length);
-    // 计算分子:整数部分 * 分母 + 小数部分
-    BigInt numerator =
-    BigInt.parse(integerPart) * denominator + BigInt.parse(decimalPart);
-
-    if (isNegative) {
-      numerator = -numerator;
-    }
-
-    return Rational(numerator, denominator);
-  }
-
-  // 取反运算
-  Ksqrt operator -() {
-    return Ksqrt(-k, q);
-  }
-
-  // 修复的化简方法
-  void simplify() {
-    if (k == Rational.zero) {
-      q = 1;
-      return;
-    }
-
-    if (q == 0 || q == 1) return;
-
-    // 提取平方因子
-    int squareFactor = 1;
-    int temp = q;
-
-    for (int i = 2; i * i <= temp; i++) {
-      while (temp % (i * i) == 0) {
-        squareFactor *= i;
-        temp ~/= (i * i);
-      }
-    }
-
-    // 更新系数和底数
-    if (squareFactor > 1) {
-      k *= Rational.fromInt(squareFactor);
-      q = temp;
-    }
-  }
-
-  // 判断两个Ksqrt是否同类项
-  bool isLikeTerm(Ksqrt other) {
-    Ksqrt thisCopy = Ksqrt(k, q);
-    Ksqrt otherCopy = Ksqrt(other.k, other.q);
-    thisCopy.simplify();
-    otherCopy.simplify();
-    return thisCopy.q == otherCopy.q;
-  }
-
-  // 加法运算
-  Ksqrt operator +(Ksqrt other) {
-    if (!isLikeTerm(other)) {
-      throw ArgumentError("底数不同无法相加: ${this.q} 和 ${other.q}");
-    }
-
-    Ksqrt thisCopy = Ksqrt(k, q);
-    Ksqrt otherCopy = Ksqrt(other.k, other.q);
-    thisCopy.simplify();
-    otherCopy.simplify();
-
-    return Ksqrt(thisCopy.k + otherCopy.k, thisCopy.q);
-  }
-
-  // 减法运算
-  Ksqrt operator -(Ksqrt other) {
-    if (!isLikeTerm(other)) {
-      throw ArgumentError("底数不同无法相减: ${this.q} 和 ${other.q}");
-    }
-
-    Ksqrt thisCopy = Ksqrt(k, q);
-    Ksqrt otherCopy = Ksqrt(other.k, other.q);
-    thisCopy.simplify();
-    otherCopy.simplify();
-
-    return Ksqrt(thisCopy.k - otherCopy.k, thisCopy.q);
-  }
-
-  // 乘法运算
-  Ksqrt operator *(Ksqrt other) {
-    Rational newK = k * other.k;
-    int newQ = q * other.q;
-
-    Ksqrt result = Ksqrt(newK, newQ);
-    result.simplify();
-    return result;
-  }
-
-  // 修复的除法运算
-  Ksqrt operator /(Ksqrt other) {
-    if (other.k == Rational.zero) {
-      throw ArgumentError("除数不能为零");
-    }
-
-    // 分子分母同乘以分母的根号部分进行有理化
-    Rational newK = k * other.k;
-    int newQ = q * other.q;
-
-    // 除以分母系数的平方(因为分母是k√q,有理化后分母变为k²q)
-    Rational denominator = other.k * other.k * Rational.fromInt(other.q);
-
-    Ksqrt result = Ksqrt(newK / denominator, newQ);
-    result.simplify();
-    return result;
-  }
-
-  @override
-  bool operator ==(Object other) {
-    if (identical(this, other)) return true;
-    if (other is! Ksqrt) return false;
-
-    Ksqrt thisSimplified = Ksqrt(k, q)..simplify();
-    Ksqrt otherSimplified = Ksqrt(other.k, other.q)..simplify();
-
-    return thisSimplified.k == otherSimplified.k &&
-    thisSimplified.q == otherSimplified.q;
-  }
-
-  @override
-  int get hashCode {
-    Ksqrt simplified = Ksqrt(k, q)..simplify();
-    return Object.hash(simplified.k, simplified.q);
-  }
-
-  // 计算表达式的近似浮点数值
-  double toDouble() {
-    if (k == Rational.zero) return 0.0;
-
-    double sqrtQ = sqrt(q.toDouble());
-    double kValue = k.toDouble();
-
-    return kValue * sqrtQ;
-  }
-
-  @override
-  String toString() {
-    if (k == Rational.zero) return '0';
-    if (q == 1) return k.toString();
-
-    String kStr;
-    if (k == Rational.one) {
-      kStr = '';
-    } else if (k == -Rational.one) {
-      kStr = '-';
-    } else {
-      if (k.isInteger) {
-        kStr = k.toDouble().toInt().toString();
-      } else {
-        kStr = '($k)';
-      }
-      kStr += '*';
-    }
-
-    return '${kStr}√$q';
-  }
-}
-
-class RationalExtend {
-  List<Ksqrt> terms;
-
-  RationalExtend(this.terms);
-
-  factory RationalExtend.fromInt(int value) {
-    return RationalExtend([Ksqrt.fromInt(value)]);
-  }
-
-  factory RationalExtend.fromRational(Rational rational) {
-    return RationalExtend([Ksqrt.fromRational(rational)]);
-  }
-
-  // 改进的字符串解析 - 现在支持小数
-  factory RationalExtend.parse(String str) {
-    str = str.replaceAll(' ', '');
-
-    if (str.isEmpty) return RationalExtend([]);
-
-    // 处理单个项的情况
-    if (!str.contains('+') && !str.contains('-')) {
-      return RationalExtend([Ksqrt.parse(str)]);
-    }
-
-    // 处理以负号开头的情况
-    if (str.startsWith('-')) {
-      var rest = str.substring(1);
-      if (!rest.contains('+') && !rest.contains('-')) {
-        return RationalExtend([Ksqrt.parse(str)]);
-      }
-    }
-
-    List<Ksqrt> terms = [];
-    List<String> termStrs = [];
-
-    // 改进的分割逻辑:正确处理符号
-    int start = 0;
-    for (int i = 1; i < str.length; i++) {
-      if ((str[i] == '+' || str[i] == '-') &&
-        str[i-1] != '*' && str[i-1] != '/' && str[i-1] != '^') {
-        termStrs.add(str.substring(start, i));
-      start = i;
-        }
-    }
-    termStrs.add(str.substring(start));
-
-    // 解析各项
-    for (var termStr in termStrs) {
-      if (termStr.isEmpty) continue;
-      if (termStr == '+') continue;
-
-      try {
-        terms.add(Ksqrt.parse(termStr));
-      } catch (e) {
-        throw FormatException("无法解析项: $termStr, 错误: $e");
-      }
-    }
-
-    return RationalExtend(terms)..simplify();
-  }
-
-  void addTerm(Ksqrt term) {
-    terms.add(term);
-  }
-
-  // 化简方法:合并同类项
-  void simplify() {
-    if (terms.isEmpty) return;
-
-    // 首先对每个项进行化简
-    for (var term in terms) {
-      term.simplify();
-    }
-
-    // 合并同类项
-    Map<int, Rational> termMap = {};
-
-    for (var term in terms) {
-      if (termMap.containsKey(term.q)) {
-        termMap[term.q] = termMap[term.q]! + term.k;
-      } else {
-        termMap[term.q] = term.k;
-      }
-    }
-
-    // 重建terms列表
-    terms.clear();
-    termMap.forEach((q, k) {
-      if (k != Rational.zero) {
-        terms.add(Ksqrt(k, q));
-      }
-    });
-
-    // 按底数排序
-    terms.sort((a, b) => a.q.compareTo(b.q));
-
-    if (terms.isEmpty) {
-      terms.add(Ksqrt.fromInt(0));
-    }
-  }
-
-  // 获取共轭表达式
-  RationalExtend get conjugate {
-    if (terms.length == 1) {
-      return RationalExtend([terms[0]]);
-    } else if (terms.length == 2) {
-      return RationalExtend([
-        terms[0],
-        Ksqrt(-terms[1].k, terms[1].q)
-      ]);
-    } else {
-      List<Ksqrt> conjugateTerms = [terms[0]];
-      for (int i = 1; i < terms.length; i++) {
-        conjugateTerms.add(Ksqrt(-terms[i].k, terms[i].q));
-      }
-      return RationalExtend(conjugateTerms);
-    }
-  }
-
-  // 检查是否是有理数
-  bool get isRational {
-    for (var term in terms) {
-      if (term.q != 1) return false;
-    }
-    return true;
-  }
-
-  // 获取有理数部分的值
-  Rational get rationalValue {
-    if (!isRational) {
-      throw StateError("表达式包含根号,不是纯有理数");
-    }
-
-    Rational sum = Rational.zero;
-    for (var term in terms) {
-      sum += term.k;
-    }
-    return sum;
-  }
-
-  // 加法运算
-  RationalExtend operator +(RationalExtend other) {
-    List<Ksqrt> newTerms = List.from(terms);
-    newTerms.addAll(other.terms);
-
-    RationalExtend result = RationalExtend(newTerms);
-    result.simplify();
-    return result;
-  }
-
-  // 减法运算
-  RationalExtend operator -(RationalExtend other) {
-    List<Ksqrt> newTerms = List.from(terms);
-    for (var term in other.terms) {
-      newTerms.add(Ksqrt(-term.k, term.q));
-    }
-
-    RationalExtend result = RationalExtend(newTerms);
-    result.simplify();
-    return result;
-  }
-
-  // 乘法运算
-  RationalExtend operator *(RationalExtend other) {
-    List<Ksqrt> productTerms = [];
-
-    for (var term1 in terms) {
-      for (var term2 in other.terms) {
-        productTerms.add(term1 * term2);
-      }
-    }
-
-    RationalExtend result = RationalExtend(productTerms);
-    result.simplify();
-    return result;
-  }
-
-  // 修复的除法运算
-  RationalExtend operator /(RationalExtend other) {
-    if (other.terms.isEmpty) {
-      throw ArgumentError("除数不能为空");
-    }
-
-    // 特殊情况:除数是单项式
-    if (other.terms.length == 1) {
-      List<Ksqrt> newTerms = [];
-      for (var term in terms) {
-        newTerms.add(term / other.terms[0]);
-      }
-      return RationalExtend(newTerms)..simplify();
-    }
-
-    // 一般情况:使用分母有理化
-    RationalExtend conjugate = other.conjugate;
-    RationalExtend numerator = this * conjugate;
-    RationalExtend denominator = other * conjugate;
-
-    if (!denominator.isRational) {
-      throw StateError("分母有理化失败,结果仍包含根号");
-    }
-
-    Rational denomValue = denominator.rationalValue;
-
-    if (denomValue == Rational.zero) {
-      throw ArgumentError("分母有理化后为0,无法进行除法");
-    }
-
-    // 将分子中的每一项除以分母的有理数值
-    List<Ksqrt> resultTerms = [];
-    for (var term in numerator.terms) {
-      resultTerms.add(Ksqrt(term.k / denomValue, term.q));
-    }
-
-    return RationalExtend(resultTerms)..simplify();
-  }
-
-  @override
-  bool operator ==(Object other) {
-    if (identical(this, other)) return true;
-    if (other is! RationalExtend) return false;
-
-    RationalExtend thisSimplified = RationalExtend(List.from(terms))..simplify();
-    RationalExtend otherSimplified = RationalExtend(List.from(other.terms))..simplify();
-
-    if (thisSimplified.terms.length != otherSimplified.terms.length) return false;
-
-    for (int i = 0; i < thisSimplified.terms.length; i++) {
-      if (thisSimplified.terms[i] != otherSimplified.terms[i]) {
-        return false;
-      }
-    }
-
-    return true;
-  }
-
-  @override
-  int get hashCode {
-    RationalExtend simplified = RationalExtend(List.from(terms))..simplify();
-    return simplified.terms.fold(0, (hash, term) => hash ^ term.hashCode);
-  }
-
-  // 计算表达式的近似浮点数值
-  double toDouble() {
-    double sum = 0.0;
-    for (var term in terms) {
-      sum += term.toDouble();
-    }
-    return sum;
-  }
-
-  @override
-  String toString() {
-    if (terms.isEmpty) return "0";
-
-    RationalExtend simplified = RationalExtend(List.from(terms))..simplify();
-
-    if (simplified.terms.isEmpty) return "0";
-
-    List<String> termStrings = [];
-    for (var term in simplified.terms) {
-      String termStr = term.toString();
-      if (termStr.startsWith('-')) {
-        termStrings.add(termStr);
-      } else if (termStrings.isNotEmpty) {
-        termStrings.add('+$termStr');
-      } else {
-        termStrings.add(termStr);
-      }
-    }
-
-    String result = termStrings.join('');
-    if (result.startsWith('+')) {
-      result = result.substring(1);
-    }
-
-    return result;
-  }
-}
-
-extension RationalExtendMath on RationalExtend {
-  RationalExtend square() {
-    return this * this;
-  }
-
-  RationalExtend abs() {
-    double value = this.toDouble();
-    if (value < 0) {
-      return RationalExtend(terms.map((term) => Ksqrt(-term.k, term.q)).toList());
-    }
-    return this;
-  }
-
-  bool get isZero {
-    RationalExtend simplified = RationalExtend(List.from(terms))..simplify();
-    return simplified.terms.length == 1 &&
-    simplified.terms[0].k == Rational.zero;
-  }
-
-  Rational get rationalPart {
-    Rational sum = Rational.zero;
-    for (var term in terms) {
-      if (term.q == 1) {
-        sum += term.k;
-      }
-    }
-    return sum;
-  }
-
-  RationalExtend get irrationalPart {
-    List<Ksqrt> irrationalTerms = [];
-    for (var term in terms) {
-      if (term.q != 1) {
-        irrationalTerms.add(term);
-      }
-    }
-    return RationalExtend(irrationalTerms)..simplify();
-  }
-}
-/*测试用例
-// 测试函数 - 增加小数测试
-void main() {
-  print('=== Ksqrt 类测试 ===');
-
-  // 基本创建和化简
-  var sqrt8 = Ksqrt(Rational.one, 8);
-  sqrt8.simplify();
-  print('√8 = $sqrt8'); // 应该输出 2√2
-
-  // 算术运算
-  var a = Ksqrt.parse('√2');
-  var b = Ksqrt.parse('3√2');
-  var sum = a + b;
-  print('√2 + 3√2 = $sum'); // 应该输出 4√2
-
-  var product = a * b;
-  print('√2 * 3√2 = $product'); // 应该输出 6
-
-  // 除法测试 - 修复后
-  var c = Ksqrt.parse('√6');
-  var d = Ksqrt.parse('√3');
-  var quotient = c / d;
-  print('√6 / √3 = $quotient'); // 应该输出 √2
-
-  print('\n=== RationalExtend 类测试 ===');
-
-  // 多项式创建和运算
-  var expr1 = RationalExtend.parse('√2 + 2√3');
-  var expr2 = RationalExtend.parse('√2 - √3');
-  print('表达式1: $expr1');
-  print('表达式2: $expr2');
-
-  var polySum = expr1 + expr2;
-  print('相加: $polySum'); // 应该输出 2√2 + √3
-
-  var polyProduct = expr1 * expr2;
-  print('相乘: $polyProduct'); // 应该输出 2 + √6 - 2√3 - 2*3 = -4 + √6 - 2√3
-
-  // 除法测试(分母有理化)- 修复后
-  try {
-    var polyQuotient = expr1 / expr2;
-    print('相除: $polyQuotient'); // 应该输出有理化后的结果
-  } catch (e) {
-    print('除法错误: $e');
-  }
-
-  // 复杂表达式测试
-  print('\n=== 复杂表达式测试 ===');
-  try {
-    var complexExpr = RationalExtend.parse('2/3');
-    print('分数解析: $complexExpr');
-
-    var complexExpr2 = RationalExtend.parse('2/3+√2');
-    print('分数+根号解析: $complexExpr2');
-
-    var complexExpr3 = RationalExtend.parse('1+√2');
-    var mul = complexExpr2 * complexExpr3;
-    print('复杂表达式1: $complexExpr2');
-    print('复杂表达式2: $complexExpr3');
-    print('乘积: $mul');
-    print('有理数部分: ${complexExpr2.rationalPart}');
-    print('无理数部分: ${complexExpr2.irrationalPart}');
-    print('近似值: ${complexExpr2.toDouble()}');
-  } catch (e) {
-    print('解析错误: $e');
-  }
-
-  // 更多测试
-  print('\n=== 更多测试 ===');
-  try {
-    var test1 = Ksqrt.parse('1/2');
-    print('分数 1/2: $test1');
-
-    var test2 = Ksqrt.parse('3/4*√2');
-    print('分数系数 3/4*√2: $test2');
-
-    var test3 = RationalExtend.parse('1/2 + 1/3*√2');
-    print('混合表达式: $test3');
-  } catch (e) {
-    print('测试错误: $e');
-  }
-
-  // 小数测试
-  print('\n=== 小数支持测试 ===');
-  try {
-    var decimal1 = Ksqrt.parse('0.5');
-    print('小数 0.5: $decimal1');
-
-    var decimal2 = Ksqrt.parse('0.25√2');
-    print('小数系数 0.25√2: $decimal2');
-
-    var decimal3 = Ksqrt.parse('1.5√3');
-    print('小数系数 1.5√3: $decimal3');
-
-    var decimal4 = RationalExtend.parse('0.5 + 3/2√2');
-    print('混合小数表达式: $decimal4');
-
-    var decimal5 = RationalExtend.parse('1/3 + 0.666√2');
-    print('混合小数表达式: $decimal5');
-
-    // 小数运算测试
-    var decimalExpr1 = RationalExtend.parse('0.5√2');
-    var decimalExpr2 = RationalExtend.parse('0.25√2');
-    var decimalSum = decimalExpr1 + decimalExpr2;
-    print('0.5√2 + 0.25√2 = $decimalSum'); // 应该输出 0.75√2
-
-    var decimalProduct = decimalExpr1 * decimalExpr2;
-    print('0.5√2 * 0.25√2 = $decimalProduct'); // 应该输出 0.25
-
-  } catch (e) {
-    print('小数测试错误: $e');
-  }
-}
-*/

+ 0 - 16
pubspec.lock

@@ -243,14 +243,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "0.13.0"
-  math_expressions:
-    dependency: "direct main"
-    description:
-      name: math_expressions
-      sha256: "22aca3a24e445be5f42ecf186cbded1f9951bf9ec5eb48fcddbd55e102bb11f5"
-      url: "https://pub.dev"
-    source: hosted
-    version: "3.2.0"
   meta:
     dependency: "direct main"
     description:
@@ -387,14 +379,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "2.2.1"
-  rational:
-    dependency: "direct main"
-    description:
-      name: rational
-      sha256: cb808fb6f1a839e6fc5f7d8cb3b0a10e1db48b3be102de73938c627f0b636336
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.2.3"
   record_use:
     dependency: transitive
     description:

+ 0 - 2
pubspec.yaml

@@ -37,9 +37,7 @@ dependencies:
   flutter_math_fork: ^0.7.4
   provider: ^6.1.5+1
   path_provider: ^2.1.6
-  rational: ^2.2.3
   fl_chart: ^1.2.0
-  math_expressions: ^3.2.0
   meta: ^1.19.0
 
 dev_dependencies:

+ 153 - 0
test/widget_test.dart

@@ -8,6 +8,8 @@ import 'package:cal/calculator_page.dart';
 import 'package:cal/console_page.dart';
 import 'package:cal/expr_editor.dart';
 import 'package:cal/main.dart';
+import 'package:cal/tools/cycle_coloring.dart';
+import 'package:cal/tools/group_allocation.dart';
 import 'package:cal/tools_page.dart';
 
 void main() {
@@ -107,6 +109,40 @@ void main() {
     await tester.pumpAndSettle();
   }
 
+  /// 打开工具页里的某个工具(会连带把 App 起起来)。
+  ///
+  /// 先用搜索框把它筛出来:网格是懒加载的,靠后的卡片在测试视口外根本不在树里。
+  /// 卡片用 `widgetWithText(InkWell, ...)` 定位 —— 搜索框里也有同样的字。
+  Future<void> openTool(WidgetTester tester, String name) async {
+    await pumpApp(tester);
+    await goTo(tester, '工具');
+    await tester.enterText(inPage(ToolsPage, find.byType(TextField)), name);
+    await tester.pumpAndSettle();
+    await tester.tap(find.widgetWithText(InkWell, name).last);
+    await tester.pumpAndSettle();
+  }
+
+  /// 给某个工具的输入框依次填值(按界面上的先后顺序)。
+  Future<void> fillTool(
+    WidgetTester tester,
+    Type page,
+    List<String> values,
+  ) async {
+    final fields = find.descendant(
+      of: find.byType(page),
+      matching: find.byType(TextField),
+    );
+    for (var i = 0; i < values.length; i++) {
+      await tester.enterText(fields.at(i), values[i]);
+    }
+    await tester.pumpAndSettle();
+  }
+
+  Future<void> runTool(WidgetTester tester) async {
+    await tester.tap(find.widgetWithText(FilledButton, '计算'));
+    await tester.pumpAndSettle();
+  }
+
   /// 等防抖走完,取出真正送到通道的那条表达式。
   ///
   /// 显示区那行"原文"是按需出现的(没信息量就不显示),所以断言文档内容
@@ -314,6 +350,18 @@ void main() {
       expect(await lastExpr(tester), 'Det[{{1,2}}]');
     });
 
+    testWidgets('函数面板能按中文说明搜(不用记得住 Variance)', (tester) async {
+      await pumpApp(tester);
+      await tester.tap(find.byIcon(Icons.functions));
+      await tester.pumpAndSettle();
+
+      await tester.enterText(find.byType(TextField).last, '方差');
+      await tester.pumpAndSettle();
+
+      expect(find.widgetWithText(ListTile, 'Variance'), findsOneWidget);
+      expect(find.widgetWithText(ListTile, 'Sin'), findsNothing);
+    });
+
     testWidgets('赋值:x = 1', (tester) async {
       await pumpApp(tester);
       await press(tester, 'x');
@@ -584,5 +632,110 @@ void main() {
         reason: '搜索框钻到状态栏底下了',
       );
     });
+
+    testWidgets('输出点一下就复制(图表除外)', (tester) async {
+      final copied = <String>[];
+      TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
+          .setMockMethodCallHandler(SystemChannels.platform, (call) async {
+        if (call.method == 'Clipboard.setData') {
+          copied.add('${(call.arguments as Map)['text']}');
+        }
+        return null;
+      });
+
+      await openTool(tester, '分组分配');
+      await fillTool(tester, GroupAllocationPage, ['6', '4']);
+      await runTool(tester);
+      // 点结果的**标签**(值本身渲染成数学排版,不一定有同名的 Text)
+      await tester.tap(find.text('方案数 k!·S(6,4)'));
+      await tester.pumpAndSettle();
+      expect(copied, ['x^3/3']); // 复制的是 mock 给的纯文本
+    });
+
+    testWidgets('重置按钮清空输入和结果', (tester) async {
+      await openTool(tester, '分组分配');
+      await fillTool(tester, GroupAllocationPage, ['6', '4']);
+      await runTool(tester);
+      expect(find.text('方案数 k!·S(6,4)'), findsOneWidget);
+
+      await tester.tap(find.widgetWithText(OutlinedButton, '重置'));
+      await tester.pumpAndSettle();
+
+      expect(find.text('方案数 k!·S(6,4)'), findsNothing);
+      final fields = find.descendant(
+        of: find.byType(GroupAllocationPage),
+        matching: find.byType(TextField),
+      );
+      expect(tester.widget<TextField>(fields.at(0)).controller!.text, '');
+    });
+
+    testWidgets('分组分配:k!·S(n,k) 是 Symja 算的', (tester) async {
+      await openTool(tester, '分组分配');
+      await fillTool(tester, GroupAllocationPage, ['6', '4']);
+      await runTool(tester);
+
+      expect(
+        calls
+            .where((c) => c.method == 'evaluate')
+            .map((c) => '${c.arguments['expr']}'),
+        contains('Factorial[4]*StirlingS2[6, 4]'),
+      );
+    });
+
+    testWidgets('n 边形染色:(k−1)ⁿ+(k−1)(−1)ⁿ 是 Symja 算的', (tester) async {
+      await openTool(tester, 'n 边形染色');
+      await fillTool(tester, CycleColoringPage, ['5', '3']);
+      await runTool(tester);
+
+      expect(
+        calls
+            .where((c) => c.method == 'evaluate')
+            .map((c) => '${c.arguments['expr']}'),
+        contains('(3-1)^5 + (3-1)*(-1)^5'),
+      );
+    });
+  });
+
+  group('设置', () {
+    testWidgets('齿轮打开设置面板;E-Ink 打开后主题换灰阶、动画关掉', (tester) async {
+      await pumpApp(tester);
+      await goTo(tester, '控制台');
+      await tester.tap(find.byIcon(Icons.settings_outlined));
+      await tester.pumpAndSettle();
+      expect(find.text('MCP 服务器端口'), findsOneWidget);
+
+      await tester.tap(find.byType(SwitchListTile));
+      await tester.pumpAndSettle();
+      expect(tester.widget<SwitchListTile>(find.byType(SwitchListTile)).value, isTrue);
+
+      // 动画关掉:无障碍开关 + 主题里的水波纹
+      final ctx = tester.element(find.byType(ShellPage));
+      expect(MediaQuery.of(ctx).disableAnimations, isTrue);
+      final app = tester.widget<MaterialApp>(find.byType(MaterialApp));
+      expect(app.theme!.splashFactory, NoSplash.splashFactory);
+    });
+
+    testWidgets('设置的 MCP 端口会传给原生侧', (tester) async {
+      await pumpApp(tester);
+      await goTo(tester, '控制台');
+      await tester.tap(find.byIcon(Icons.settings_outlined));
+      await tester.pumpAndSettle();
+
+      await tester.enterText(find.byType(TextField).last, '9100');
+      await tester.testTextInput.receiveAction(TextInputAction.done);
+      await tester.pumpAndSettle();
+      // 关掉设置面板(点面板外面的遮罩)
+      await tester.tapAt(const Offset(10, 10));
+      await tester.pumpAndSettle();
+
+      // 换个入口启动 MCP,应当带上刚设的端口
+      await tester.tap(find.byIcon(Icons.cloud_outlined));
+      await tester.pumpAndSettle();
+      await tester.tap(find.widgetWithText(FilledButton, '启动'));
+      await tester.pumpAndSettle();
+
+      final start = calls.lastWhere((c) => c.method == 'start');
+      expect(start.arguments['port'], 9100);
+    });
   });
 }