Kaynağa Gözat

工具页:精确+近似都要、去掉多余的说明文字、E-Ink 改白底黑字

- 精确值走 Rationalize:输入写成小数时 Symja 会一路按小数算,均值显示成
  `3.6` 就不再是精确值了,现在还原成分数 `18/5`,旁边配 `≈ 3.6`。
  去重也比**数值**而不是字符串(`N[5]` 打印成 `5.0`,比字符串会多出没用的行)
- 补齐近似值:圆的方程(D/E/F、圆心、半径平方)、对称变换、二项/超几何分布
  的每一个 P(X=k)、卷积的每一项与概率总和
- 去掉剩下的说明文字:法向量、二分法、函数图像、圆的方程、卡方检验、
  错位相减法、卷积的分组标题、统计图表的"图表类型"(只留控件)
- E-Ink 从"灰阶滤镜"升级成"白底黑字":配色整体压成白底黑字黑线,卡片改用
  描边而不是阴影,键盘按键改描边区分(白底白键会糊成一片),工具卡片白底黑框

Co-Authored-By: Claude Code <noreply@anthropic.com>
nonvioce 3 hafta önce
ebeveyn
işleme
f1f0155c0c

+ 25 - 7
lib/calculator_page.dart

@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
 import 'expr_editor.dart';
 import 'expr_node.dart';
 import 'expr_parse.dart';
+import 'settings.dart';
 import 'symja.dart';
 import 'symja_functions.dart';
 
@@ -550,19 +551,36 @@ class _CalculatorPageState extends State<CalculatorPage> {
 
   Widget _keyButton(_Key k) {
     final scheme = Theme.of(context).colorScheme;
-    final bg = k.primary
-        ? scheme.primary
-        : k.tint
-            ? scheme.surfaceContainerHighest
-            : scheme.surfaceContainerLow;
-    final fg = k.primary ? scheme.onPrimary : scheme.onSurface;
+    // E-Ink 下按键的底色是白的(配色压成了白底黑字),跟背景一个颜色就看不见了,
+    // 所以改成**描边**区分:功能键/提交键一圈黑,数字键一圈浅灰。
+    final eInk = context.watch<AppSettings>().eInk;
+    final bg = eInk
+        ? (k.primary ? Colors.black : Colors.white)
+        : k.primary
+            ? scheme.primary
+            : k.tint
+                ? scheme.surfaceContainerHighest
+                : scheme.surfaceContainerLow;
+    final fg = eInk
+        ? (k.primary ? Colors.white : Colors.black)
+        : (k.primary ? scheme.onPrimary : scheme.onSurface);
 
     return Material(
       // 加 key 是为了让测试能精确定位按键 —— 编辑器里渲染出的字面量(比如 'x')
       // 和键盘上同名的按键都符合 find.text,会撞车。
       key: ValueKey('key-${k.label}'),
       color: bg,
-      borderRadius: BorderRadius.circular(10),
+      shape: eInk
+          ? RoundedRectangleBorder(
+              borderRadius: BorderRadius.circular(10),
+              side: BorderSide(
+                color: k.tint || k.primary
+                    ? Colors.black
+                    : const Color(0xFF9E9E9E),
+              ),
+            )
+          : null,
+      borderRadius: eInk ? null : BorderRadius.circular(10),
       child: InkWell(
         borderRadius: BorderRadius.circular(10),
         onTap: () => _press(k),

+ 56 - 0
lib/main.dart

@@ -58,7 +58,63 @@ class _App extends StatelessWidget {
   ThemeData _theme(bool eInk) {
     final base = ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true);
     if (!eInk) return base;
+
+    // 白底黑字。只靠灰阶滤镜不够 —— 配色本来就有浅灰底、浅灰卡片,
+    // 灰阶之后还是灰,看着发闷(而且键盘的浅色按键会跟浅色底糊在一起)。
+    // 这里把整个配色压成"白底、黑字、黑线",再让下面的控件自己描边。
+    final cs = base.colorScheme.copyWith(
+      primary: Colors.black,
+      onPrimary: Colors.white,
+      primaryContainer: Colors.white,
+      onPrimaryContainer: Colors.black,
+      secondary: Colors.black,
+      onSecondary: Colors.white,
+      secondaryContainer: const Color(0xFFE0E0E0),
+      onSecondaryContainer: Colors.black,
+      surface: Colors.white,
+      onSurface: Colors.black,
+      surfaceContainerLowest: Colors.white,
+      surfaceContainerLow: Colors.white,
+      surfaceContainer: Colors.white,
+      surfaceContainerHigh: Colors.white,
+      surfaceContainerHighest: Colors.white,
+      onSurfaceVariant: Colors.black,
+      outline: Colors.black,
+      outlineVariant: const Color(0xFF9E9E9E),
+      inverseSurface: Colors.black,
+      onInverseSurface: Colors.white,
+      error: Colors.black,
+      onError: Colors.white,
+      errorContainer: Colors.white,
+      onErrorContainer: Colors.black,
+      shadow: Colors.black,
+      scrim: Colors.black,
+    );
+
     return base.copyWith(
+      colorScheme: cs,
+      scaffoldBackgroundColor: Colors.white,
+      canvasColor: Colors.white,
+      appBarTheme: const AppBarTheme(
+        backgroundColor: Colors.white,
+        foregroundColor: Colors.black,
+        elevation: 0,
+        scrolledUnderElevation: 0,
+      ),
+      navigationBarTheme: const NavigationBarThemeData(
+        backgroundColor: Colors.white,
+        indicatorColor: Color(0xFFE0E0E0),
+      ),
+      // 卡片全靠描边分辨,不靠阴影(墨水屏上阴影就是一团脏灰)
+      cardTheme: CardThemeData(
+        color: Colors.white,
+        elevation: 0,
+        shape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(12),
+          side: const BorderSide(color: Color(0xFF9E9E9E)),
+        ),
+      ),
+      dividerTheme: const DividerThemeData(color: Color(0xFF9E9E9E)),
       splashFactory: NoSplash.splashFactory,
       pageTransitionsTheme: const PageTransitionsTheme(
         builders: <TargetPlatform, PageTransitionsBuilder>{

+ 56 - 43
lib/tools/binomial_distribution.dart

@@ -26,48 +26,57 @@ class _BinomialDistributionPageState extends State<BinomialDistributionPage> {
 
   @override
   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)]'),
-          ];
+    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');
 
-          // 每个 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]}'));
-          }
+      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)]',
+        ),
+      ];
 
-          // 图表:柱高是概率。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;
-        },
+      // 每个 i 的概率:整列一次算出来,再拆成一条一条,这样每条都能单独复制。
+      // 精确列走 Rationalize(p 填小数时也能给成分数),数值列给图表用。
+      final exact = await session.compute(
+        'Rationalize[With[{n=$n,p=$p}, '
+        'Table[Binomial[n,i]*p^i*(1-p)^(n-i), {i,0,n}]]]',
       );
+      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 probs = parseSymjaList(exact.text);
+      final nums = parseSymjaList(numeric.text);
+      for (var i = 0; i < probs.length; i++) {
+        outputs.add(
+          ToolText(
+            'P(X = $i)',
+            withApprox('${probs[i]}', i < nums.length ? '${nums[i]}' : ''),
+          ),
+        );
+      }
+
+      final values = nums
+          .map(asNumber)
+          .whereType<double>()
+          .toList(growable: false);
+      if (values.length == probs.length) {
+        outputs.add(ToolChart('概率分布', _barChart(values)));
+      }
+      return outputs;
+    },
+  );
 
   /// 柱状图:最高的那一段(可能并排几个)涂红,左边蓝、右边绿 —— 保留原来的配色规则。
   Widget _barChart(List<double> probs) {
@@ -103,8 +112,10 @@ class _BinomialDistributionPageState extends State<BinomialDistributionPage> {
                 reservedSize: 30,
                 getTitlesWidget: (value, meta) => Padding(
                   padding: const EdgeInsets.only(top: 8),
-                  child: Text('${value.toInt()}',
-                      style: const TextStyle(fontSize: 10)),
+                  child: Text(
+                    '${value.toInt()}',
+                    style: const TextStyle(fontSize: 10),
+                  ),
                 ),
               ),
             ),
@@ -113,8 +124,10 @@ class _BinomialDistributionPageState extends State<BinomialDistributionPage> {
                 showTitles: true,
                 reservedSize: 40,
                 getTitlesWidget: (value, meta) => value % 0.1 == 0
-                    ? Text(value.toStringAsFixed(1),
-                        style: const TextStyle(fontSize: 10))
+                    ? Text(
+                        value.toStringAsFixed(1),
+                        style: const TextStyle(fontSize: 10),
+                      )
                     : const Text(''),
               ),
             ),

+ 79 - 86
lib/tools/bisection_method.dart

@@ -43,97 +43,90 @@ class _BisectionMethodPageState extends State<BisectionMethodPage> {
 
   @override
   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('最大迭代次数必须是正整数');
-          }
+    title: '二分法求根',
+    // 有默认值,一打开就该看到解,不用先按一下按钮。
+    autoRun: true,
+    fields: [_function, _a, _b, _tolerance, _maxIterations],
+    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 走实数运算 ——
+      // 否则中点保持精确分数,过程表里会显示 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 算出的 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]}';
+      // 整个二分过程在 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])}');
-            }
-          }
+      // 过程表拼成**一条** 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'),
-            ),
-          ];
-        },
-      );
+      return <ToolOutput>[
+        ToolText('近似解', _fmt6(cText)),
+        ToolText('迭代次数', iterText),
+        ToolText('最终误差', _fmt6(errText)),
+        ToolText(
+          '迭代过程',
+          lines.isEmpty ? '(区间长度一开始就不大于精度,未迭代)' : lines.join('\n'),
+        ),
+      ];
+    },
+  );
 }
 
 /// 纯整数字面量补 `.`,让它成为实数:`1` → `1.`、`-2` → `-2.`;

+ 0 - 1
lib/tools/chi_square.dart

@@ -28,7 +28,6 @@ class _ChiSquarePageState extends State<ChiSquarePage> {
   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);

+ 22 - 6
lib/tools/circle_equation.dart

@@ -30,7 +30,6 @@ class _CircleEquationPageState extends State<CircleEquationPage> {
   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)},'
@@ -57,16 +56,33 @@ class _CircleEquationPageState extends State<CircleEquationPage> {
           '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 defsN = parseSymjaList(
+        (await session.compute('N[$defExpr, 10]')).text,
+      );
       final center = parseSymjaList((await session.compute(centerExpr)).text);
+      final centerN = parseSymjaList(
+        (await session.compute('N[$centerExpr, 10]')).text,
+      );
+
+      String at(List<Object> l, int i) => i < l.length ? '${l[i]}' : '';
 
       final out = <ToolOutput>[
-        ToolText('D', '${defs[0]}'),
-        ToolText('E', '${defs[1]}'),
-        ToolText('F', '${defs[2]}'),
+        ToolText('D', withApprox('${defs[0]}', at(defsN, 0))),
+        ToolText('E', withApprox('${defs[1]}', at(defsN, 1))),
+        ToolText('F', withApprox('${defs[2]}', at(defsN, 2))),
       ];
       if (center.length == 3) {
-        out.add(ToolText('圆心', '(${center[0]}, ${center[1]})'));
-        out.add(ToolText('半径平方', '${center[2]}'));
+        // 圆心是坐标,两个分量各自配自己的近似值
+        out.add(
+          ToolText(
+            '圆心',
+            '(${withApprox('${center[0]}', at(centerN, 0))}, '
+                '${withApprox('${center[1]}', at(centerN, 1))})',
+          ),
+        );
+        out.add(ToolText('半径平方', withApprox('${center[2]}', at(centerN, 2))));
       }
       return out;
     },

+ 16 - 13
lib/tools/convolution_calculator.dart

@@ -97,10 +97,7 @@ class _ConvolutionCalculatorPageState extends State<ConvolutionCalculatorPage> {
     fields: _fields,
     intro: Column(
       crossAxisAlignment: CrossAxisAlignment.start,
-      children: [
-        _sectionTitle('X 的分布列', 'X', _x),
-        _sectionTitle('Y 的分布列', 'Y', _y),
-      ],
+      children: [_rowButtons('X', _x), _rowButtons('Y', _y)],
     ),
     compute: (session) async {
       _validate('X', _x);
@@ -153,26 +150,32 @@ class _ConvolutionCalculatorPageState extends State<ConvolutionCalculatorPage> {
         final ps = groups[key];
         if (ps == null) continue;
         final total = await session.compute('Total[{${ps.join(', ')}}]');
+        final totalN = await session.compute(
+          'N[Total[{${ps.join(', ')}}], 10]',
+        );
         groupSums.add(total.text);
-        outputs.add(ToolText('X + Y = $key', total.text));
+        // 精确分数 + 小数:概率这种"看一眼量级"的值,两者都有用
+        outputs.add(
+          ToolText('X + Y = $key', withApprox(total.text, totalN.text)),
+        );
       }
 
       final grand = await session.compute('Total[{${groupSums.join(', ')}}]');
-      outputs.add(ToolText('概率总和', grand.text));
+      final grandN = await session.compute(
+        'N[Total[{${groupSums.join(', ')}}], 10]',
+      );
+      outputs.add(ToolText('概率总和', withApprox(grand.text, grandN.text)));
       return outputs;
     },
   );
 
-  Widget _sectionTitle(String title, String name, List<_Row> rows) => Padding(
+  /// 一行「删除一行 / 添加一行」—— 输入框自己的标签已经写明了是 X 还是 Y,
+  /// 不用再来一行标题重复。
+  Widget _rowButtons(String name, List<_Row> rows) => Padding(
     padding: const EdgeInsets.only(top: 8),
     child: Row(
+      mainAxisAlignment: MainAxisAlignment.end,
       children: [
-        Expanded(
-          child: Text(
-            title,
-            style: const TextStyle(fontWeight: FontWeight.bold),
-          ),
-        ),
         TextButton.icon(
           onPressed: () => _removeRow(rows),
           icon: const Icon(Icons.remove, size: 18),

+ 49 - 49
lib/tools/cuo_wei_xiang_jian.dart

@@ -26,48 +26,47 @@ class _CuoWeiXiangJianPageState extends State<CuoWeiXiangJianPage> {
 
   @override
   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)]';
-
-          final aRes = await session.compute(aExpr);
-          final bRes = await session.compute(bExpr);
-          // q 也回炉一次:用户可能填 `0.5`,Symja 会规范成 `1/2`,渲染才好看
-          final qv = (await session.compute('With[{$bind}, vq]')).text;
-          final av = aRes.text;
-          final bv = bRes.text;
-
-          // 结果那条:Sₙ = (A·n + B)·qⁿ − B,把算出来的 A、B、q 代进去显示。
-          // **值全是 Symja 算的**,这里只负责把数字摆进公式、分数写成 \frac。
-          final s = sFormula(a: av, b: bv, q: qv);
-
-          final aApprox = await session.compute('N[$aExpr, 10]');
-          final bApprox = await session.compute('N[$bExpr, 10]');
-
-          return <ToolOutput>[
-            ToolText('结果 Sₙ = (A·n + B)·qⁿ − B', s.plain, tex: s.tex),
-            ToolText('A', av, tex: aRes.tex),
-            if (aApprox.text != av) ToolText('A(近似)', aApprox.text),
-            ToolText('B', bv, tex: bRes.tex),
-            if (bApprox.text != bv) ToolText('B(近似)', bApprox.text),
-          ];
-        },
-      );
+    title: '错位相减法',
+    fields: [_a, _b, _q, _m],
+    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)]';
+
+      final aRes = await session.compute(aExpr);
+      final bRes = await session.compute(bExpr);
+      // q 也回炉一次:用户可能填 `0.5`,Symja 会规范成 `1/2`,渲染才好看
+      final qv = (await session.compute('With[{$bind}, vq]')).text;
+      final av = aRes.text;
+      final bv = bRes.text;
+
+      // 结果那条:Sₙ = (A·n + B)·qⁿ − B,把算出来的 A、B、q 代进去显示。
+      // **值全是 Symja 算的**,这里只负责把数字摆进公式、分数写成 \frac。
+      final s = sFormula(a: av, b: bv, q: qv);
+
+      final aApprox = await session.compute('N[$aExpr, 10]');
+      final bApprox = await session.compute('N[$bExpr, 10]');
+
+      return <ToolOutput>[
+        ToolText('结果 Sₙ = (A·n + B)·qⁿ − B', s.plain, tex: s.tex),
+        ToolText('A', av, tex: aRes.tex),
+        if (aApprox.text != av) ToolText('A(近似)', aApprox.text),
+        ToolText('B', bv, tex: bRes.tex),
+        if (bApprox.text != bv) ToolText('B(近似)', bApprox.text),
+      ];
+    },
+  );
 
   /// 取一个标量输入框:空着或填了多个值都直接报中文错。
   String _need(ToolField f) {
@@ -89,13 +88,14 @@ class _CuoWeiXiangJianPageState extends State<CuoWeiXiangJianPage> {
   required String a,
   required String b,
   required String q,
-}) =>
-    (
-      plain: 'S(n) = ($a·n ${_signed(b, _plain)})·$q^n '
-          '${_signed(_neg(b), _plain)}',
-      tex: 'S_{n} = (${_frac(a)}\\cdot n ${_signed(b, _frac)})\\cdot '
-          '{${_frac(q)}}^{n} ${_signed(_neg(b), _frac)}',
-    );
+}) => (
+  plain:
+      'S(n) = ($a·n ${_signed(b, _plain)})·$q^n '
+      '${_signed(_neg(b), _plain)}',
+  tex:
+      'S_{n} = (${_frac(a)}\\cdot n ${_signed(b, _frac)})\\cdot '
+      '{${_frac(q)}}^{n} ${_signed(_neg(b), _frac)}',
+);
 
 /// `+ 4` / `- \frac{1}{2}`:把带符号的数接到前一项后面,
 /// 不要写成 `+ (-4)` 那种。

+ 15 - 16
lib/tools/cycle_coloring.dart

@@ -13,25 +13,24 @@ class CycleColoringPage extends StatefulWidget {
 }
 
 class _CycleColoringPageState extends State<CycleColoringPage> {
-  final _n = ToolField('n (边数)', hint: '不小于 3 的整数',
-      keyboard: TextInputType.number);
+  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],
-        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 要填一个正整数');
+    title: 'n 边形染色',
+    fields: [_n, _k],
+    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',
-          );
-        },
-      );
+      return exactAndApprox(session, '染色方案数', '($k-1)^$n + ($k-1)*(-1)^$n');
+    },
+  );
 }

+ 95 - 98
lib/tools/function_plotter.dart

@@ -50,11 +50,13 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   void _addCurve() {
     if (_exprs.length >= _maxCurves) return;
     setState(() {
-      _exprs.add(ToolField(
-        '函数 ${_exprs.length + 1}',
-        hint: '例如 x^2、Sin[x]、Sqrt[x]',
-        keyboard: TextInputType.text,
-      ));
+      _exprs.add(
+        ToolField(
+          '函数 ${_exprs.length + 1}',
+          hint: '例如 x^2、Sin[x]、Sqrt[x]',
+          keyboard: TextInputType.text,
+        ),
+      );
     });
   }
 
@@ -66,96 +68,91 @@ class _FunctionPlotterPageState extends State<FunctionPlotterPage> {
   }
 
   Widget _intro() => Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
+    crossAxisAlignment: CrossAxisAlignment.start,
+    children: [
+      Row(
         children: [
-          const Text('函数表达式 (使用 x 作为变量)'),
-          const Text(
-            '支持: +, -, *, /, ^, sin, cos, tan, ln, sqrt, pi, e',
-            style: TextStyle(fontSize: 12),
+          OutlinedButton.icon(
+            onPressed: _exprs.length < _maxCurves ? _addCurve : null,
+            icon: const Icon(Icons.add),
+            label: const Text('添加函数'),
           ),
-          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: 12),
+          OutlinedButton.icon(
+            onPressed: _exprs.length > 1 ? _removeCurve : null,
+            icon: const Icon(Icons.remove),
+            label: const Text('删除函数'),
           ),
         ],
-      );
+      ),
+    ],
+  );
 
   @override
   Widget build(BuildContext context) => ToolScaffold(
-        title: '函数图像绘制',
-        // 默认已经有一个 x^2,打开就该看到图。
-        autoRun: true,
-        fields: _exprs,
-        intro: _intro(),
-        compute: (session) async {
-          final curves = <({String expr, Color color})>[];
-          final series = <CurveSeries>[];
-          final failed = <String>[];
-          // 逐条采样:一条失败只记下它自己,别的照画。
-          for (var i = 0; i < _exprs.length; i++) {
-            final raw = _exprs[i].text;
-            if (raw.isEmpty) continue;
-            final color = _curveColors[i % _curveColors.length];
-            try {
-              // 表达式原样交给 Symja 解析,谁解析谁负责。
-              final s = await session.sample(
-                raw,
-                variable: 'x',
-                from: _sampleFrom,
-                to: _sampleTo,
-                count: _sampleCount,
-              );
-              curves.add((expr: raw, color: color));
-              series.add(CurveSeries(
-                label: raw,
-                color: color,
-                // 非有限值(极点处的 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: _FunctionChart(
-                    session: session,
-                    curves: curves,
-                    initialSeries: series,
-                    initialFrom: _sampleFrom,
-                    initialTo: _sampleTo,
-                  ),
-                ),
+    title: '函数图像绘制',
+    // 默认已经有一个 x^2,打开就该看到图。
+    autoRun: true,
+    fields: _exprs,
+    intro: _intro(),
+    compute: (session) async {
+      final curves = <({String expr, Color color})>[];
+      final series = <CurveSeries>[];
+      final failed = <String>[];
+      // 逐条采样:一条失败只记下它自己,别的照画。
+      for (var i = 0; i < _exprs.length; i++) {
+        final raw = _exprs[i].text;
+        if (raw.isEmpty) continue;
+        final color = _curveColors[i % _curveColors.length];
+        try {
+          // 表达式原样交给 Symja 解析,谁解析谁负责。
+          final s = await session.sample(
+            raw,
+            variable: 'x',
+            from: _sampleFrom,
+            to: _sampleTo,
+            count: _sampleCount,
+          );
+          curves.add((expr: raw, color: color));
+          series.add(
+            CurveSeries(
+              label: raw,
+              color: color,
+              // 非有限值(极点处的 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: _FunctionChart(
+                session: session,
+                curves: curves,
+                initialSeries: series,
+                initialFrom: _sampleFrom,
+                initialTo: _sampleTo,
               ),
-            if (failed.isNotEmpty)
-              ToolText('未能绘制的函数', failed.join('\n')),
-          ];
-        },
-      );
+            ),
+          ),
+        if (failed.isNotEmpty) ToolText('未能绘制的函数', failed.join('\n')),
+      ];
+    },
+  );
 }
 
 /// 会跟着横向滑动条**重新采样**的函数图像。
@@ -221,13 +218,15 @@ class _FunctionChartState extends State<_FunctionChart> {
             to: to,
             count: count,
           );
-          out.add(CurveSeries(
-            label: c.expr,
-            color: c.color,
-            points: <Offset>[
-              for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
-            ],
-          ));
+          out.add(
+            CurveSeries(
+              label: c.expr,
+              color: c.color,
+              points: <Offset>[
+                for (var k = 0; k < s.ys.length; k++) Offset(s.xAt(k), s.ys[k]),
+              ],
+            ),
+          );
         } on SymjaException {
           // 拉远之后算不动了(比如跑到定义域外、或被取消):留着原来的曲线,
           // 别把已经画出来的图清空。
@@ -248,8 +247,6 @@ class _FunctionChartState extends State<_FunctionChart> {
   }
 
   @override
-  Widget build(BuildContext context) => CurvePlot(
-        series: _series,
-        onViewChanged: _onView,
-      );
+  Widget build(BuildContext context) =>
+      CurvePlot(series: _series, onViewChanged: _onView);
 }

+ 20 - 20
lib/tools/group_allocation.dart

@@ -19,25 +19,25 @@ class _GroupAllocationPageState extends State<GroupAllocationPage> {
 
   @override
   Widget build(BuildContext context) => ToolScaffold(
-        title: '分组分配',
-        fields: [_n, _k],
-        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 要填一个正整数');
+    title: '分组分配',
+    fields: [_n, _k],
+    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,
-            ),
-          ];
-        },
-      );
+      return [
+        ...await exactAndApprox(
+          session,
+          '方案数 k!·S($n,$k)',
+          'Factorial[$k]*StirlingS2[$n, $k]',
+        ),
+        ToolText(
+          '斯特林数 S($n,$k)',
+          (await session.compute('StirlingS2[$n, $k]')).text,
+        ),
+      ];
+    },
+  );
 }

+ 57 - 52
lib/tools/histogram.dart

@@ -28,63 +28,66 @@ class _HistogramPageState extends State<HistogramPage> {
 
   @override
   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;
+    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,再让 Symja 判等。
-          //
-          // 判等也要走 Symja:频率填小数时 `Total` 回的是 `1.0`,拿 Dart 的
-          // 字符串去比 `1` 会把合法输入判成错的(用户实测反馈过)。
-          final ok = await session.compute('Total[$f] == 1');
-          if (ok.text != 'True') {
-            final sum = await session.compute('Total[$f]');
-            throw Exception('各组频率之和必须为 1,当前为 ${sum.text}');
-          }
+      // 频率之和必须为 1:交给 Symja 求 Total,再让 Symja 判等。
+      //
+      // 判等也要走 Symja:频率填小数时 `Total` 回的是 `1.0`,拿 Dart 的
+      // 字符串去比 `1` 会把合法输入判成错的(用户实测反馈过)。
+      final ok = await session.compute('Total[$f] == 1');
+      if (ok.text != 'True') {
+        final sum = await session.compute('Total[$f]');
+        throw Exception('各组频率之和必须为 1,当前为 ${sum.text}');
+      }
 
-          // 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]]]';
+      // 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')),
-          ];
+      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),
-            ));
-          }
+      if (_pct.text.isNotEmpty) {
+        outputs.addAll(
+          await exactAndApprox(
+            session,
+            '${_pct.text} 百分位数',
+            _quantile(a, d, f, _pct.text),
+          ),
+        );
+      }
 
-          // 图表:横轴标签 [左, 右),左端点/右端点用精确值;柱高是频率密度 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;
-        },
+      // 图表:横轴标签 [左, 右),左端点/右端点用精确值;柱高是频率密度 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;
+    },
+  );
 
   /// 累计频率首次超过 p 的那一组内插。中位数把 p 取 1/2。
   String _quantile(String a, String d, String f, String p) =>
@@ -129,7 +132,9 @@ class _HistogramPageState extends State<HistogramPage> {
                     child: Text(
                       labels[i],
                       style: const TextStyle(
-                          fontSize: 10, fontWeight: FontWeight.bold),
+                        fontSize: 10,
+                        fontWeight: FontWeight.bold,
+                      ),
                       textAlign: TextAlign.center,
                     ),
                   );

+ 71 - 61
lib/tools/hypergeometric_distribution.dart

@@ -27,68 +27,74 @@ class _HypergeometricDistributionPageState
 
   @override
   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},';
+    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)');
+      }
 
-          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))]'),
-          ];
+      // nn=N,mm=M,kk=n(避开内置名 N)。
+      final wrap = 'With[{nn=$total,mm=$good,kk=$draw},';
 
-          // 表格里的 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 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))]',
+        ),
+      ];
 
-          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]}'));
-          }
+      // 表格里的 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 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;
-        },
+      final exact = await session.compute(
+        '$wrap Rationalize[Table[Binomial[mm,j]*Binomial[nn-mm,kk-j]/'
+        'Binomial[nn,kk], {j, Max[0,kk-(nn-mm)], Min[kk,mm]}]]]',
       );
+      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 probs = parseSymjaList(exact.text);
+      final nums = parseSymjaList(numeric.text);
+      for (var i = 0; i < probs.length; i++) {
+        outputs.add(
+          ToolText(
+            'P(X = ${start + i})',
+            withApprox('${probs[i]}', i < nums.length ? '${nums[i]}' : ''),
+          ),
+        );
+      }
+
+      final values = nums
+          .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) {
@@ -124,8 +130,10 @@ class _HypergeometricDistributionPageState
                 reservedSize: 30,
                 getTitlesWidget: (value, meta) => Padding(
                   padding: const EdgeInsets.only(top: 8),
-                  child: Text('${value.toInt()}',
-                      style: const TextStyle(fontSize: 10)),
+                  child: Text(
+                    '${value.toInt()}',
+                    style: const TextStyle(fontSize: 10),
+                  ),
                 ),
               ),
             ),
@@ -134,8 +142,10 @@ class _HypergeometricDistributionPageState
                 showTitles: true,
                 reservedSize: 40,
                 getTitlesWidget: (value, meta) => value % 0.1 == 0
-                    ? Text(value.toStringAsFixed(1),
-                        style: const TextStyle(fontSize: 10))
+                    ? Text(
+                        value.toStringAsFixed(1),
+                        style: const TextStyle(fontSize: 10),
+                      )
                     : const Text(''),
               ),
             ),

+ 20 - 21
lib/tools/mixed_variance.dart

@@ -25,25 +25,24 @@ class _MixedVariancePageState extends State<MixedVariancePage> {
 
   @override
   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),
-          ];
-        },
-      );
+    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),
+      ];
+    },
+  );
 }

+ 119 - 110
lib/tools/parametric_plotter.dart

@@ -18,10 +18,16 @@ class ParametricPlotterPage extends StatefulWidget {
 }
 
 class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
-  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 _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');
@@ -56,121 +62,124 @@ class _ParametricPlotterPageState extends State<ParametricPlotterPage> {
   }
 
   Widget _intro() => Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
+    crossAxisAlignment: CrossAxisAlignment.start,
+    children: [
+      Wrap(
+        spacing: 8,
+        runSpacing: 8,
         children: [
-          Wrap(
-            spacing: 8,
-            runSpacing: 8,
-            children: [
-              ActionChip(
-                label: const Text('椭圆'),
-                onPressed: () => _applyPreset('2*cos(t)', 'sin(t)', '0', '6.3'),
-              ),
-              ActionChip(
-                label: const Text('螺旋线'),
-                onPressed: () =>
-                    _applyPreset('t*cos(t)', 't*sin(t)', '0', '12.6'),
-              ),
-              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'),
-              ),
-            ],
+          ActionChip(
+            label: const Text('椭圆'),
+            onPressed: () => _applyPreset('2*cos(t)', 'sin(t)', '0', '6.3'),
+          ),
+          ActionChip(
+            label: const Text('螺旋线'),
+            onPressed: () => _applyPreset('t*cos(t)', 't*sin(t)', '0', '12.6'),
+          ),
+          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',
+            ),
           ),
         ],
-      );
+      ),
+    ],
+  );
 
   @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;
+    // 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');
-          }
+      // 校验:都是格式检查,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 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');
-          }
+      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');
+      }
 
-          return [
-            ToolChart(
-              '参数方程图像',
-              // 内部用 Expanded,必须给确定高度;参数曲线常常是圆/椭圆,
-              // 必须等比例(equalAspect: true)。
-              SizedBox(
-                height: 360,
-                child: CurvePlot(series: series, equalAspect: true),
-              ),
-            ),
-            ToolText(
-              '方程',
-              'x = $xRaw,y = $yRaw;t ∈ [$tMinRaw, $tMaxRaw],'
+      return [
+        ToolChart(
+          '参数方程图像',
+          // 内部用 Expanded,必须给确定高度;参数曲线常常是圆/椭圆,
+          // 必须等比例(equalAspect: true)。
+          SizedBox(
+            height: 360,
+            child: CurvePlot(series: series, equalAspect: true),
+          ),
+        ),
+        ToolText(
+          '方程',
+          'x = $xRaw,y = $yRaw;t ∈ [$tMinRaw, $tMaxRaw],'
               '步长 $stepRaw(共 $n 个采样点)',
-            ),
-          ];
-        },
-      );
+        ),
+      ];
+    },
+  );
 }

+ 85 - 84
lib/tools/regression.dart

@@ -23,87 +23,91 @@ class _RegressionPageState extends State<RegressionPage> {
 
   @override
   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;
+    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('回归计算没有返回预期结果');
+      // 十个量一条表达式算齐(隐式乘号一律写出来)。
+      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,无法拟合)');
-          }
+      // 守卫:Σ(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²',
-          ];
+      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));
-            }
-          }
+      // 散点坐标: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]!));
-          }
+      // 回归直线两端点(在 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)),
-          ];
-        },
-      );
+      return <ToolOutput>[
+        for (var i = 0; i < 10; i++) ToolText(labels[i], stats[i]),
+        ToolChart('散点与回归直线', _scatterWithLine(scatter, line)),
+      ];
+    },
+  );
 }
 
 /// 去掉 Symja 近似值尾巴上的精度标记(形如 `0.9943767127`` 或 `0.9943767127`10.`)。
@@ -112,10 +116,7 @@ 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('没有可绘制的数据')),
-    );
+    return const SizedBox(height: 200, child: Center(child: Text('没有可绘制的数据')));
   }
 
   // 坐标范围只是给图留边距,不是工具的结果(原来的实现也这么做)。
@@ -149,11 +150,11 @@ Widget _scatterWithLine(List<FlSpot> scatter, List<FlSpot> line) {
                 show: true,
                 getDotPainter: (spot, percent, bar, index) =>
                     FlDotCirclePainter(
-                  radius: 4,
-                  color: Colors.blue,
-                  strokeWidth: 2,
-                  strokeColor: Colors.blue,
-                ),
+                      radius: 4,
+                      color: Colors.blue,
+                      strokeWidth: 2,
+                      strokeColor: Colors.blue,
+                    ),
               ),
             ),
           if (line.isNotEmpty)

+ 174 - 171
lib/tools/statistical_charts.dart

@@ -49,7 +49,11 @@ class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
 
   void _addRow({String initialLabel = '新数据', String initialValue = '0'}) {
     final n = _rowCount + 1;
-    final label = ToolField('第 $n 项标签', hint: '数据名', keyboard: TextInputType.text);
+    final label = ToolField(
+      '第 $n 项标签',
+      hint: '数据名',
+      keyboard: TextInputType.text,
+    );
     final value = ToolField('第 $n 项数值', hint: '例如 10');
     label.controller.text = initialLabel;
     value.controller.text = initialValue;
@@ -57,9 +61,9 @@ class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
   }
 
   void _onAddRow() => setState(() {
-        _addRow();
-        _runToken++;
-      });
+    _addRow();
+    _runToken++;
+  });
 
   void _onRemoveRow() {
     if (_rowCount <= 1) return;
@@ -86,105 +90,104 @@ class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
   }
 
   Widget _intro() => Column(
-        crossAxisAlignment: CrossAxisAlignment.start,
+    crossAxisAlignment: CrossAxisAlignment.start,
+    children: [
+      const SizedBox(height: 8),
+      Wrap(
+        spacing: 8,
+        runSpacing: 8,
         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),
-                ),
-            ],
-          ),
-          if (_type == 1) ...[
-            const SizedBox(height: 12),
-            Row(
-              children: [
-                const Text('曲线类型:'),
-                const SizedBox(width: 8),
-                ChoiceChip(
-                  label: const Text('直线'),
-                  selected: !_curved,
-                  onSelected: (_) => _setCurved(false),
-                ),
-                const SizedBox(width: 8),
-                ChoiceChip(
-                  label: const Text('光滑曲线'),
-                  selected: _curved,
-                  onSelected: (_) => _setCurved(true),
-                ),
-              ],
+          for (var i = 0; i < _chartTypes.length; i++)
+            ChoiceChip(
+              label: Text(_chartTypes[i]),
+              selected: _type == i,
+              onSelected: (_) => _selectType(i),
+            ),
+        ],
+      ),
+      if (_type == 1) ...[
+        const SizedBox(height: 12),
+        Row(
+          children: [
+            const Text('曲线类型:'),
+            const SizedBox(width: 8),
+            ChoiceChip(
+              label: const Text('直线'),
+              selected: !_curved,
+              onSelected: (_) => _setCurved(false),
+            ),
+            const SizedBox(width: 8),
+            ChoiceChip(
+              label: const Text('光滑曲线'),
+              selected: _curved,
+              onSelected: (_) => _setCurved(true),
             ),
           ],
-          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('删除数据'),
-              ),
-            ],
+        ),
+      ],
+      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('删除数据'),
           ),
         ],
-      );
+      ),
+    ],
+  );
 
   @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('请至少填写一行有效数据');
-          }
+    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),
-          };
+      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),
-            ),
-          ];
-        },
-      );
+      return <ToolOutput>[
+        ToolChart(
+          _chartTypes[_type],
+          // fl_chart 需要一个确定高度,否则在无界的高度里会报错。
+          SizedBox(height: 320, child: chart),
+        ),
+      ];
+    },
+  );
 
   /// 坐标轴的"好看"上限:最大值再留 20% 余量;全零或全负时给个 1,免得轴退化。
   static double _niceMax(List<double> values) {
@@ -196,97 +199,97 @@ class _StatisticalChartsPageState extends State<StatisticalChartsPage> {
   }
 
   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,
-            ),
-          );
-        },
+    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(),
-          ),
-          titlesData: FlTitlesData(
-            show: true,
-            bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
-            leftTitles: const AxisTitles(
-              sideTitles: SideTitles(showTitles: true, reservedSize: 40),
-            ),
-          ),
-          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,
-                  ),
-                ],
-              ),
-          ],
+    BarChartData(
+      alignment: BarChartAlignment.spaceAround,
+      maxY: _niceMax(values),
+      barTouchData: BarTouchData(
+        enabled: true,
+        touchTooltipData: const BarTouchTooltipData(),
+      ),
+      titlesData: FlTitlesData(
+        show: true,
+        bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
+        leftTitles: const AxisTitles(
+          sideTitles: SideTitles(showTitles: true, reservedSize: 40),
         ),
-      );
+      ),
+      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 _lineChart(List<double> values, List<String> labels) => LineChart(
-        LineChartData(
-          lineTouchData: const LineTouchData(enabled: true),
-          gridData: const FlGridData(show: true),
-          titlesData: FlTitlesData(
+    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),
+        ),
+      ),
+      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,
-            bottomTitles: AxisTitles(sideTitles: _bottomTitles(labels)),
-            leftTitles: const AxisTitles(
-              sideTitles: SideTitles(showTitles: true, reservedSize: 40),
-            ),
-          ),
-          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(
+            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;

+ 26 - 8
lib/tools/symmetry.dart

@@ -1,6 +1,7 @@
 // tools/symmetry.dart
 import 'package:flutter/material.dart';
 
+import '../symja.dart';
 import 'tool_kit.dart';
 
 /// 对称变换 —— 三种模式共用一个页面,靠下拉框切换。
@@ -78,10 +79,14 @@ class _SymmetryPageState extends State<SymmetryPage> {
           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}, '
+          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]})')];
+          final rN = parseSymjaList(
+            (await session.compute('N[$expr, 10]')).text,
+          );
+          return [ToolText('对称点', '(${_pair(r, rN, 0)}, ${_pair(r, rN, 1)})')];
         }
 
         if (_mode == 1) {
@@ -91,10 +96,10 @@ class _SymmetryPageState extends State<SymmetryPage> {
           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}, '
+          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)];
+          return [_line(await _withApprox(session, expr))];
         }
 
         // 原直线 Px + Qy + R = 0 关于对称轴 Ax + By + C = 0
@@ -104,14 +109,27 @@ class _SymmetryPageState extends State<SymmetryPage> {
         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}, '
+        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)];
+        return [_line(await _withApprox(session, expr))];
       },
     );
   }
 
+  /// 精确值 + 近似值:每个分量各自配上自己的小数。
+  Future<List<Object>> _withApprox(SymjaSession session, String expr) async {
+    final r = parseSymjaList((await session.compute(expr)).text);
+    final n = parseSymjaList((await session.compute('N[$expr, 10]')).text);
+    return [
+      for (var i = 0; i < r.length; i++)
+        withApprox('${r[i]}', i < n.length ? '${n[i]}' : ''),
+    ];
+  }
+
+  String _pair(List<Object> r, List<Object> n, int i) =>
+      withApprox('${r[i]}', i < n.length ? '${n[i]}' : '');
+
   /// 把 Symja 回来的一串 {A, B, C} 拼成直线方程文本。
   ToolOutput _line(List<Object> r) =>
       ToolText('对称直线方程', '${r[0]}·x + ${r[1]}·y + ${r[2]} = 0');

+ 73 - 47
lib/tools/tool_kit.dart

@@ -117,15 +117,17 @@ class _ToolScaffoldState extends State<ToolScaffold> {
       if (group.length == 1) {
         rows.add(group.first.build(context));
       } else {
-        rows.add(Row(
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            for (var i = 0; i < group.length; i++) ...[
-              if (i > 0) const SizedBox(width: 10),
-              Expanded(child: group[i].build(context)),
+        rows.add(
+          Row(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              for (var i = 0; i < group.length; i++) ...[
+                if (i > 0) const SizedBox(width: 10),
+                Expanded(child: group[i].build(context)),
+              ],
             ],
-          ],
-        ));
+          ),
+        );
       }
       group = <ToolField>[];
     }
@@ -140,19 +142,19 @@ class _ToolScaffoldState extends State<ToolScaffold> {
 
   /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。
   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)),
-          ],
+    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) {
@@ -197,8 +199,11 @@ class _ToolScaffoldState extends State<ToolScaffold> {
                   child: Row(
                     crossAxisAlignment: CrossAxisAlignment.start,
                     children: [
-                      Icon(Icons.error_outline,
-                          size: 20, color: scheme.onErrorContainer),
+                      Icon(
+                        Icons.error_outline,
+                        size: 20,
+                        color: scheme.onErrorContainer,
+                      ),
                       const SizedBox(width: 10),
                       Expanded(
                         child: Text(
@@ -232,8 +237,8 @@ class ToolField {
       decimal: true,
     ),
     this.rowWithPrevious = false,
-  })  : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
-        controller = TextEditingController();
+  }) : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
+       controller = TextEditingController();
 
   final String label;
   final String hint;
@@ -262,18 +267,18 @@ class ToolField {
   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,
-          ),
-        ),
-      );
+    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] 不行 —— 图没有可复制的文本形态。
@@ -307,7 +312,9 @@ class _OutputCard extends StatelessWidget {
       ..clearSnackBars()
       ..showSnackBar(
         SnackBar(
-          content: Text('已复制:${text.length > 30 ? '${text.substring(0, 30)}…' : text}'),
+          content: Text(
+            '已复制:${text.length > 30 ? '${text.substring(0, 30)}…' : text}',
+          ),
           duration: const Duration(milliseconds: 1200),
         ),
       );
@@ -380,31 +387,51 @@ class _OutputCard extends StatelessWidget {
 
   /// 纯文本渲染。刻意用 `Text` 而不是 `SelectableText` —— 后者会把点击吃掉拿去
   /// 做选区,整张卡片的"点一下复制"就时灵时不灵了。
-  Widget _plain(String text) => Text(
-        text,
-        style: const TextStyle(fontFamily: 'monospace', fontSize: 16),
-      );
+  Widget _plain(String text) =>
+      Text(text, style: const TextStyle(fontFamily: 'monospace', fontSize: 16));
 }
 
 /// 精确值 + 近似值两行输出。
 ///
-/// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。现在精确值由 Symja
-/// 原样给出,近似值再问一次 `N[...]`。**两者相等时只留一行** —— 否则每个整数
-/// 结果都会多出一条一模一样的"≈"。
+/// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。精确值走
+/// `Rationalize` —— 输入写成小数(`0.2`)时 Symja 会一路按小数算下去,
+/// 均值显示成 `3.6` 就不再是"精确值"了;`Rationalize` 把它还原成分数 `18/5`。
+/// 近似值再问一次 `N[...]`。
+///
+/// **两者相等时只留一行**,而且要比**数值**而不是字符串:`N[5]` 打印成 `5.0`,
+/// 拿字符串比就变成每个整数结果都多一条没用的 "5.0"。
 Future<List<ToolOutput>> exactAndApprox(
   SymjaSession session,
   String label,
   String expr,
 ) async {
-  final exact = await session.compute(expr, applyMode: false);
+  final exact = await session.compute('Rationalize[$expr]', applyMode: false);
   final approx = await session.compute('N[$expr, 10]', applyMode: false);
-  final same = approx.text == exact.text;
+  final same = _sameValue(exact.text, approx.text);
   return [
     ToolText(label, exact.text, tex: exact.tex),
     if (!same) ToolText('$label(近似)', approx.text, tex: approx.tex),
   ];
 }
 
+/// 两个显示串是不是同一个数(都解析得出来的话)。
+bool _sameValue(String a, String b) {
+  if (a == b) return true;
+  final x = asNumber(a), y = asNumber(b);
+  if (x == null || y == null) return false;
+  return (x - y).abs() <= 1e-9 * (1 + x.abs());
+}
+
+/// 把近似值缀在精确值后面:`16/81 ≈ 0.1975`。同一个数就只留精确值。
+///
+/// 给"一条条分开"的分布表用 —— 每项单独成卡时不拆成两张,
+/// 否则 n=10 的二项分布会列出二十多张卡。
+String withApprox(String exact, String approx) {
+  final a = approx.trim();
+  if (a.isEmpty) return exact;
+  return _sameValue(exact.trim(), a) ? exact : '$exact ≈ $a';
+}
+
 /// 纯数值输出:只给近似值(用户要的本来就是个数,不是分数)。
 Future<ToolOutput> numeric(
   SymjaSession session,
@@ -453,4 +480,3 @@ double? asNumber(Object? item) {
   if (item is! String) return null;
   return double.tryParse(item.replaceAll('`', '').trim());
 }
-

+ 0 - 6
lib/tools/vector_normal.dart

@@ -94,12 +94,6 @@ class _VectorNormalPageState extends State<VectorNormalPage> {
   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'
-      '如果已知两向量坐标,把一个点设为原点即可。',
-    ),
     compute: (session) async {
       final ab = _edge(0, 1); // B - A
       final ac = _edge(0, 2); // C - A

+ 19 - 8
lib/tools_page.dart

@@ -1,6 +1,8 @@
 import 'package:flutter/material.dart';
+import 'package:provider/provider.dart';
 
 import 'plugins.dart';
+import 'settings.dart';
 
 /// 工具网格:可搜索的插件入口。
 ///
@@ -119,6 +121,11 @@ class _ToolCard extends StatelessWidget {
 
   @override
   Widget build(BuildContext context) {
+    // E-Ink 下卡片一律白底黑字黑框 —— 彩色渐变灰阶之后是深浅不一的灰块,
+    // 墨水屏上不好看也不好认。
+    final eInk = context.watch<AppSettings>().eInk;
+    final fg = eInk ? Colors.black : Colors.white;
+
     return Material(
       color: Colors.transparent,
       child: InkWell(
@@ -127,22 +134,26 @@ class _ToolCard extends StatelessWidget {
         child: Ink(
           decoration: BoxDecoration(
             borderRadius: BorderRadius.circular(16),
-            gradient: LinearGradient(
-              begin: Alignment.topLeft,
-              end: Alignment.bottomRight,
-              colors: [color, Color.lerp(color, Colors.black, 0.22)!],
-            ),
+            color: eInk ? Colors.white : null,
+            border: eInk ? Border.all(color: Colors.black) : null,
+            gradient: eInk
+                ? null
+                : LinearGradient(
+                    begin: Alignment.topLeft,
+                    end: Alignment.bottomRight,
+                    colors: [color, Color.lerp(color, Colors.black, 0.22)!],
+                  ),
           ),
           padding: const EdgeInsets.all(14),
           child: Column(
             crossAxisAlignment: CrossAxisAlignment.start,
             mainAxisAlignment: MainAxisAlignment.spaceBetween,
             children: [
-              Icon(plugin.icon, color: Colors.white, size: 26),
+              Icon(plugin.icon, color: fg, size: 26),
               Text(
                 plugin.name,
-                style: const TextStyle(
-                  color: Colors.white,
+                style: TextStyle(
+                  color: fg,
                   fontSize: 15,
                   fontWeight: FontWeight.w600,
                 ),

+ 2 - 2
test/widget_test.dart

@@ -679,7 +679,7 @@ void main() {
         calls
             .where((c) => c.method == 'evaluate')
             .map((c) => '${c.arguments['expr']}'),
-        contains('Factorial[4]*StirlingS2[6, 4]'),
+        contains('Rationalize[Factorial[4]*StirlingS2[6, 4]]'),
       );
     });
 
@@ -726,7 +726,7 @@ void main() {
         calls
             .where((c) => c.method == 'evaluate')
             .map((c) => '${c.arguments['expr']}'),
-        contains('(3-1)^5 + (3-1)*(-1)^5'),
+        contains('Rationalize[(3-1)^5 + (3-1)*(-1)^5]'),
       );
     });
   });