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.beforeField = const {}, this.runLabel = '计算', this.runIcon, this.autoRun = false, }); final String title; /// 打开就自动算一次。绘图和二分法这类**输入框里已经有默认值**的工具用得上: /// 一进来就该看到图/解,而不是先按一下按钮。 final bool autoRun; /// 输入框。顺序即显示顺序。 /// /// 由**页面**持有(建在 State 里,别建在 build 里,否则主题一变控制器就被换掉、 /// 用户打的字会丢)。外壳不 dispose 它们:对称变换那种一个页面里换几组输入框的 /// 工具,控制器要跨模式活着。 final List fields; /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。 final Widget? intro; /// 主按钮的文字。绘图类工具写「绘制」比「计算」贴切。 final String runLabel; /// 主按钮的图标,不给就按"计算"用算盘图标。 final IconData? runIcon; /// 在第 n 个输入框**前面**插一块自定义 widget(下标按 [fields] 算)。 /// /// 卷积那种"一组输入框配一组增删按钮"用它 —— 按钮得贴着**自己那一组**, /// 全塞进 intro 就会两组的按钮一起跑到最上面去。 final Map beforeField; /// 计算。自己从 [fields] 里取文本、拼 Symja 表达式、调 [SymjaSession.compute]。 /// /// 返回若干条输出:文字的可点复制,图表的不复制(图没什么可复制的)。 final Future> Function(SymjaSession) compute; @override State createState() => _ToolScaffoldState(); } class _ToolScaffoldState extends State { final List _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 _run() async { final session = context.read(); 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; }); } /// 把输入框排成一列,遇到标了 [ToolField.rowWithPrevious] 的就跟前一个并排。 List _fieldRows(BuildContext context) { final rows = []; var group = []; void flush() { if (group.isEmpty) return; 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)), ], ], ), ); } group = []; } for (var i = 0; i < widget.fields.length; i++) { final f = widget.fields[i]; final before = widget.beforeField[i]; if (before != null) { flush(); rows.add(before); rows.add(const SizedBox(height: 8)); } if (!f.rowWithPrevious) flush(); group.add(f); } flush(); return rows; } /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。 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), ], ..._fieldRows(context), const SizedBox(height: 12), Row( children: [ Expanded( child: FilledButton.icon( onPressed: _busy ? null : _run, icon: Icon(widget.runIcon ?? Icons.calculate_outlined), label: Text(widget.runLabel), ), ), 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, ), this.rowWithPrevious = false, }) : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔', controller = TextEditingController(); final String label; final String hint; /// 输入法键盘。**默认一律数字键盘**(带符号和小数点):工具页绝大多数输入 /// 是数,而输入法本身能切到字母 —— 表达式栏照样打得进 `Sin[x]`。 /// 这样每个框点下去的手感一致,不会有的弹数字、有的弹全键盘。 final TextInputType keyboard; final TextEditingController controller; /// 和**上一个**输入框并排放在同一行(宽度平分)。 /// /// 成对出现的东西不该各占一行 —— 卷积的「取值 i / 概率 i」、 /// 卡方检验的 2×2 列联表都是这样。 final bool rowWithPrevious; String get text => controller.text.trim(); /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 —— /// 数个数是记账,不是计算,留在 Dart 侧。 int get count => _tokens.length; List 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 _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)); } /// 近似值的 Symja 写法:保留 k 位**小数**。 /// /// 为什么不是 `N[expr, k]`:**Symja 的 N 忽略第二个参数**(实测 4 位和 15 位打印 /// 一模一样)。`NumberForm` / `SetPrecision` 也没实现(会原样返回 numberform(...))。 /// `Round[值, 1/10^k]` 是真把值截到 k 位小数,文本和 LaTeX 都跟着变干净。 String approxExpr(String expr, int digits) => 'N[Round[$expr, 1/10^$digits]]'; /// 精确值 + 近似值两行输出。 /// /// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。精确值走 /// `Rationalize` —— 输入写成小数(`0.2`)时 Symja 会一路按小数算下去, /// 均值显示成 `3.6` 就不再是"精确值"了;`Rationalize` 把它还原成分数 `18/5`。 /// 近似值再问一次 `N[...]`。 /// /// **两者相等时只留一行**,而且要比**数值**而不是字符串:`N[5]` 打印成 `5.0`, /// 拿字符串比就变成每个整数结果都多一条没用的 "5.0"。 Future> exactAndApprox( SymjaSession session, String label, String expr, ) async { final exact = await session.compute('Rationalize[$expr]', applyMode: false); // 近似值也从**有理化之后**的数转小数:输入写成 0.2/0.3 时机器浮点会带出 // `3.5999999999999996` 这种尾巴,先还原成分数再取小数就干净了(3.6)。 // 位数听设置里那个滑动条(计算器的"近似"也是同一个值)。 final approx = await session.compute( approxExpr('Rationalize[$expr]', session.approximateDigits), applyMode: false, ); 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 numeric( SymjaSession session, String label, String expr, ) async { final r = await session.compute( approxExpr('Rationalize[$expr]', session.approximateDigits), applyMode: false, ); return ToolText(label, r.text, tex: null); } /// 数值文本 → LaTeX:分数写成 `\frac{a}{b}`,其余原样。值都是 Symja 给的,这里只排版。 String texNum(String v) { final t = v.trim(); final m = RegExp(r'^(-?)(\d+)/(\d+)$').firstMatch(t); if (m == null) return t; return '${m[1]}\\frac{${m[2]}}{${m[3]}}'; } /// 拼一条"… = 0"的等式(直线方程、圆的一般方程都用它)。 /// /// 每项是 `(系数, 变量部分的 LaTeX, 变量部分的纯文本)` —— 变量部分留空就是常数项; /// 系数是 Symja 算出来的**精确**值原文(`-2`、`1/2` …)。会自动处理: /// · 系数是 0 的项不写(圆的 F = 0 时那一项就不该出现) /// · 系数 ±1 又带变量时省略 1(写 `x` 而不是 `1·x`) /// · 首项不带正号,其余项按正负接 `+` / `-` ({String plain, String tex}) polyEqualsZero( List<(String coef, String varTex, String varPlain)> terms, ) { final plain = StringBuffer(); final tex = StringBuffer(); var first = true; for (final (coef, varTex, varPlain) in terms) { final t = coef.trim(); final n = asNumber(t); if (n != null && n == 0) continue; // 系数为 0:整项不写 final neg = t.startsWith('-'); final mag = neg ? t.substring(1) : t; // 系数是 ±1 又带变量时,1 不写 final hideOne = varPlain.isNotEmpty && mag == '1'; final sign = first ? (neg ? '-' : '') : (neg ? ' - ' : ' + '); plain.write(sign); tex.write(sign); if (!hideOne) { plain.write(varPlain.isEmpty ? mag : '$mag·'); tex.write(texNum(mag)); if (varPlain.isNotEmpty) tex.write(r' \cdot '); } plain.write(varPlain); tex.write(varTex); first = false; } if (first) { plain.write('0'); tex.write('0'); } return (plain: '$plain = 0', tex: '$tex = 0'); } /// 把 Symja 输出的列表文本拆成嵌套的字符串列表。 /// /// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示, /// 而通道里回来的是 `{{0, 1/6}, {1, 1/3}}` 这样一个字符串。刻意**不**做数字解析: /// 拆出来的还是 Symja 的原文(`1/6` 就是 `1/6`),要算数再交回给 Symja。 List parseSymjaList(String s) { final t = s.trim(); if (!t.startsWith('{') || !t.endsWith('}')) return [t]; final out = []; 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()); }