tool_kit.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.dart';
  3. import 'package:flutter_math_fork/flutter_math.dart';
  4. import 'package:provider/provider.dart';
  5. import '../symja.dart';
  6. /// 工具页的公共外壳。
  7. ///
  8. /// 十五个工具原先各写各的:各自的 Scaffold、各自拼字符串、各自显示一个
  9. /// `Text`。这套外壳把**输入 → 计算 → 输出**固定下来,好处有三个:
  10. /// 1. 计算全部走 [Symja],Dart 只负责排版和取输入(绘图除外,见 [ToolChart]);
  11. /// 2. 每条输出**单独成块、点一下就复制**,不再是一大坨拼在一起的文本;
  12. /// 3. 重置按钮和错误提示不用每个工具再写一遍。
  13. ///
  14. /// 刻意不用「抽象基类 + 子类」那套写法:工具之间的差异只有"有哪些输入框"和
  15. /// "算什么表达式"两件事,交给两个参数就够了,继承反而要每个工具建一个类。
  16. class ToolScaffold extends StatefulWidget {
  17. const ToolScaffold({
  18. super.key,
  19. required this.title,
  20. required this.fields,
  21. required this.compute,
  22. this.intro,
  23. this.beforeField = const {},
  24. this.runLabel = '计算',
  25. this.runIcon,
  26. this.autoRun = false,
  27. });
  28. final String title;
  29. /// 打开就自动算一次。绘图和二分法这类**输入框里已经有默认值**的工具用得上:
  30. /// 一进来就该看到图/解,而不是先按一下按钮。
  31. final bool autoRun;
  32. /// 输入框。顺序即显示顺序。
  33. ///
  34. /// 由**页面**持有(建在 State 里,别建在 build 里,否则主题一变控制器就被换掉、
  35. /// 用户打的字会丢)。外壳不 dispose 它们:对称变换那种一个页面里换几组输入框的
  36. /// 工具,控制器要跨模式活着。
  37. final List<ToolField> fields;
  38. /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。
  39. final Widget? intro;
  40. /// 主按钮的文字。绘图类工具写「绘制」比「计算」贴切。
  41. final String runLabel;
  42. /// 主按钮的图标,不给就按"计算"用算盘图标。
  43. final IconData? runIcon;
  44. /// 在第 n 个输入框**前面**插一块自定义 widget(下标按 [fields] 算)。
  45. ///
  46. /// 卷积那种"一组输入框配一组增删按钮"用它 —— 按钮得贴着**自己那一组**,
  47. /// 全塞进 intro 就会两组的按钮一起跑到最上面去。
  48. final Map<int, Widget> beforeField;
  49. /// 计算。自己从 [fields] 里取文本、拼 Symja 表达式、调 [SymjaSession.compute]。
  50. ///
  51. /// 返回若干条输出:文字的可点复制,图表的不复制(图没什么可复制的)。
  52. final Future<List<ToolOutput>> Function(SymjaSession) compute;
  53. @override
  54. State<ToolScaffold> createState() => _ToolScaffoldState();
  55. }
  56. class _ToolScaffoldState extends State<ToolScaffold> {
  57. final List<ToolOutput> _outputs = [];
  58. String? _error;
  59. bool _busy = false;
  60. /// 换了一组输入框(对称变换那种切模式的情况)就把上一次的结果清掉 ——
  61. /// 留着上一模式的答案是误导。
  62. @override
  63. void didUpdateWidget(ToolScaffold old) {
  64. super.didUpdateWidget(old);
  65. if (!identical(old.fields, widget.fields)) {
  66. _outputs.clear();
  67. _error = null;
  68. }
  69. }
  70. @override
  71. void initState() {
  72. super.initState();
  73. if (widget.autoRun) {
  74. WidgetsBinding.instance.addPostFrameCallback((_) {
  75. if (mounted) _run();
  76. });
  77. }
  78. }
  79. Future<void> _run() async {
  80. final session = context.read<SymjaSession>();
  81. setState(() {
  82. _busy = true;
  83. _error = null;
  84. _outputs.clear();
  85. });
  86. try {
  87. final out = await widget.compute(session);
  88. if (!mounted) return;
  89. setState(() => _outputs.addAll(out));
  90. } on SymjaException catch (e) {
  91. if (mounted) setState(() => _error = '$e');
  92. } catch (e) {
  93. // 输入格式不对(少填了、写了非数字)在这里兜住,别把异常弹到 framework
  94. if (mounted) setState(() => _error = '$e');
  95. } finally {
  96. if (mounted) setState(() => _busy = false);
  97. }
  98. }
  99. void _reset() {
  100. for (final f in widget.fields) {
  101. f.controller.clear();
  102. }
  103. setState(() {
  104. _outputs.clear();
  105. _error = null;
  106. });
  107. }
  108. /// 把输入框排成一列,遇到标了 [ToolField.rowWithPrevious] 的就跟前一个并排。
  109. List<Widget> _fieldRows(BuildContext context) {
  110. final rows = <Widget>[];
  111. var group = <ToolField>[];
  112. void flush() {
  113. if (group.isEmpty) return;
  114. if (group.length == 1) {
  115. rows.add(group.first.build(context));
  116. } else {
  117. rows.add(
  118. Row(
  119. crossAxisAlignment: CrossAxisAlignment.start,
  120. children: [
  121. for (var i = 0; i < group.length; i++) ...[
  122. if (i > 0) const SizedBox(width: 10),
  123. Expanded(child: group[i].build(context)),
  124. ],
  125. ],
  126. ),
  127. );
  128. }
  129. group = <ToolField>[];
  130. }
  131. for (var i = 0; i < widget.fields.length; i++) {
  132. final f = widget.fields[i];
  133. final before = widget.beforeField[i];
  134. if (before != null) {
  135. flush();
  136. rows.add(before);
  137. rows.add(const SizedBox(height: 8));
  138. }
  139. if (!f.rowWithPrevious) flush();
  140. group.add(f);
  141. }
  142. flush();
  143. return rows;
  144. }
  145. /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。
  146. Widget _busyStrip(ColorScheme scheme) => Padding(
  147. padding: const EdgeInsets.symmetric(vertical: 10),
  148. child: Row(
  149. children: [
  150. const SizedBox(
  151. width: 14,
  152. height: 14,
  153. child: CircularProgressIndicator(strokeWidth: 2),
  154. ),
  155. const SizedBox(width: 10),
  156. Text('计算中…', style: TextStyle(color: scheme.outline)),
  157. ],
  158. ),
  159. );
  160. @override
  161. Widget build(BuildContext context) {
  162. final scheme = Theme.of(context).colorScheme;
  163. return Scaffold(
  164. appBar: AppBar(title: Text(widget.title)),
  165. body: ListView(
  166. padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
  167. children: [
  168. if (widget.intro != null) ...[
  169. widget.intro!,
  170. const SizedBox(height: 12),
  171. ],
  172. ..._fieldRows(context),
  173. const SizedBox(height: 12),
  174. Row(
  175. children: [
  176. Expanded(
  177. child: FilledButton.icon(
  178. onPressed: _busy ? null : _run,
  179. icon: Icon(widget.runIcon ?? Icons.calculate_outlined),
  180. label: Text(widget.runLabel),
  181. ),
  182. ),
  183. const SizedBox(width: 12),
  184. OutlinedButton.icon(
  185. onPressed: _busy ? null : _reset,
  186. icon: const Icon(Icons.refresh),
  187. label: const Text('重置'),
  188. ),
  189. ],
  190. ),
  191. if (_busy) _busyStrip(scheme),
  192. if (_error != null)
  193. Padding(
  194. padding: const EdgeInsets.only(top: 16),
  195. child: Card(
  196. color: scheme.errorContainer,
  197. child: Padding(
  198. padding: const EdgeInsets.all(14),
  199. child: Row(
  200. crossAxisAlignment: CrossAxisAlignment.start,
  201. children: [
  202. Icon(
  203. Icons.error_outline,
  204. size: 20,
  205. color: scheme.onErrorContainer,
  206. ),
  207. const SizedBox(width: 10),
  208. Expanded(
  209. child: Text(
  210. _error!,
  211. style: TextStyle(color: scheme.onErrorContainer),
  212. ),
  213. ),
  214. ],
  215. ),
  216. ),
  217. ),
  218. ),
  219. for (final o in _outputs)
  220. Padding(
  221. padding: const EdgeInsets.only(top: 12),
  222. child: _OutputCard(output: o),
  223. ),
  224. ],
  225. ),
  226. );
  227. }
  228. }
  229. /// 一个输入框。label 同时是发送给 Symja 时的变量含义提示。
  230. class ToolField {
  231. ToolField(
  232. this.label, {
  233. String? hint,
  234. this.keyboard = const TextInputType.numberWithOptions(
  235. signed: true,
  236. decimal: true,
  237. ),
  238. this.rowWithPrevious = false,
  239. }) : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
  240. controller = TextEditingController();
  241. final String label;
  242. final String hint;
  243. /// 输入法键盘。**默认一律数字键盘**(带符号和小数点):工具页绝大多数输入
  244. /// 是数,而输入法本身能切到字母 —— 表达式栏照样打得进 `Sin[x]`。
  245. /// 这样每个框点下去的手感一致,不会有的弹数字、有的弹全键盘。
  246. final TextInputType keyboard;
  247. final TextEditingController controller;
  248. /// 和**上一个**输入框并排放在同一行(宽度平分)。
  249. ///
  250. /// 成对出现的东西不该各占一行 —— 卷积的「取值 i / 概率 i」、
  251. /// 卡方检验的 2×2 列联表都是这样。
  252. final bool rowWithPrevious;
  253. String get text => controller.text.trim();
  254. /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 ——
  255. /// 数个数是记账,不是计算,留在 Dart 侧。
  256. int get count => _tokens.length;
  257. List<String> get _tokens =>
  258. text.split(RegExp(r'[\s,,]+')).where((s) => s.isNotEmpty).toList();
  259. /// 空格/逗号分隔的多个值 → Symja 的列表 `{1, 2, 3}`。
  260. ///
  261. /// 工具页允许用户直接写 `1/2` 这样的分数和 `Pi` 这样的常量 —— 它们交给 Symja
  262. /// 解析,Dart 侧不做数字转换(转换反而会把 `1/2` 变成 0.5 这种近似值)。
  263. String get list => '{${_tokens.join(', ')}}';
  264. Widget build(BuildContext context) => Padding(
  265. padding: const EdgeInsets.symmetric(vertical: 6),
  266. child: TextField(
  267. controller: controller,
  268. keyboardType: keyboard,
  269. decoration: InputDecoration(
  270. labelText: label,
  271. hintText: hint,
  272. border: const OutlineInputBorder(),
  273. isDense: true,
  274. ),
  275. ),
  276. );
  277. }
  278. /// 一条输出。[ToolText] 可点复制,[ToolChart] 不行 —— 图没有可复制的文本形态。
  279. sealed class ToolOutput {
  280. const ToolOutput(this.label);
  281. final String label;
  282. }
  283. /// 一条文字输出。[tex] 不为空时渲染成数学排版,点复制复制的仍是纯文本。
  284. class ToolText extends ToolOutput {
  285. const ToolText(super.label, this.text, {this.tex});
  286. final String text;
  287. final String? tex;
  288. }
  289. /// 一条图表输出。**图表不参与复制**(任务里明确要求的)。
  290. class ToolChart extends ToolOutput {
  291. const ToolChart(super.label, this.chart);
  292. final Widget chart;
  293. }
  294. class _OutputCard extends StatelessWidget {
  295. const _OutputCard({required this.output});
  296. final ToolOutput output;
  297. Future<void> _copy(BuildContext context, String text) async {
  298. await Clipboard.setData(ClipboardData(text: text));
  299. if (!context.mounted) return;
  300. ScaffoldMessenger.of(context)
  301. ..clearSnackBars()
  302. ..showSnackBar(
  303. SnackBar(
  304. content: Text(
  305. '已复制:${text.length > 30 ? '${text.substring(0, 30)}…' : text}',
  306. ),
  307. duration: const Duration(milliseconds: 1200),
  308. ),
  309. );
  310. }
  311. @override
  312. Widget build(BuildContext context) {
  313. final scheme = Theme.of(context).colorScheme;
  314. final o = output;
  315. if (o is ToolChart) {
  316. return Card(
  317. child: Padding(
  318. padding: const EdgeInsets.all(12),
  319. child: Column(
  320. crossAxisAlignment: CrossAxisAlignment.start,
  321. children: [
  322. Text(o.label, style: Theme.of(context).textTheme.labelLarge),
  323. const SizedBox(height: 8),
  324. o.chart,
  325. ],
  326. ),
  327. ),
  328. );
  329. }
  330. final t = o as ToolText;
  331. return Card(
  332. clipBehavior: Clip.antiAlias,
  333. child: InkWell(
  334. onTap: () => _copy(context, t.text),
  335. child: Padding(
  336. padding: const EdgeInsets.all(14),
  337. child: Row(
  338. crossAxisAlignment: CrossAxisAlignment.center,
  339. children: [
  340. Expanded(
  341. child: Column(
  342. crossAxisAlignment: CrossAxisAlignment.start,
  343. children: [
  344. Text(
  345. t.label,
  346. style: TextStyle(fontSize: 12, color: scheme.outline),
  347. ),
  348. const SizedBox(height: 6),
  349. // 有 TeX 就用数学排版显示;太长或没生成就退回纯文本
  350. if (t.tex != null)
  351. Math.tex(
  352. t.tex!,
  353. mathStyle: MathStyle.text,
  354. textStyle: TextStyle(
  355. fontSize: 20,
  356. color: scheme.onSurface,
  357. ),
  358. onErrorFallback: (_) => _plain(t.text),
  359. )
  360. else
  361. _plain(t.text),
  362. ],
  363. ),
  364. ),
  365. const SizedBox(width: 8),
  366. Icon(Icons.copy_outlined, size: 18, color: scheme.outline),
  367. ],
  368. ),
  369. ),
  370. ),
  371. );
  372. }
  373. /// 纯文本渲染。刻意用 `Text` 而不是 `SelectableText` —— 后者会把点击吃掉拿去
  374. /// 做选区,整张卡片的"点一下复制"就时灵时不灵了。
  375. Widget _plain(String text) =>
  376. Text(text, style: const TextStyle(fontFamily: 'monospace', fontSize: 16));
  377. }
  378. /// 近似值的 Symja 写法:保留 k 位**小数**。
  379. ///
  380. /// 为什么不是 `N[expr, k]`:**Symja 的 N 忽略第二个参数**(实测 4 位和 15 位打印
  381. /// 一模一样)。`NumberForm` / `SetPrecision` 也没实现(会原样返回 numberform(...))。
  382. /// `Round[值, 1/10^k]` 是真把值截到 k 位小数,文本和 LaTeX 都跟着变干净。
  383. String approxExpr(String expr, int digits) => 'N[Round[$expr, 1/10^$digits]]';
  384. /// 精确值 + 近似值两行输出。
  385. ///
  386. /// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。精确值走
  387. /// `Rationalize` —— 输入写成小数(`0.2`)时 Symja 会一路按小数算下去,
  388. /// 均值显示成 `3.6` 就不再是"精确值"了;`Rationalize` 把它还原成分数 `18/5`。
  389. /// 近似值再问一次 `N[...]`。
  390. ///
  391. /// **两者相等时只留一行**,而且要比**数值**而不是字符串:`N[5]` 打印成 `5.0`,
  392. /// 拿字符串比就变成每个整数结果都多一条没用的 "5.0"。
  393. Future<List<ToolOutput>> exactAndApprox(
  394. SymjaSession session,
  395. String label,
  396. String expr,
  397. ) async {
  398. final exact = await session.compute('Rationalize[$expr]', applyMode: false);
  399. // 近似值也从**有理化之后**的数转小数:输入写成 0.2/0.3 时机器浮点会带出
  400. // `3.5999999999999996` 这种尾巴,先还原成分数再取小数就干净了(3.6)。
  401. // 位数听设置里那个滑动条(计算器的"近似"也是同一个值)。
  402. final approx = await session.compute(
  403. approxExpr('Rationalize[$expr]', session.approximateDigits),
  404. applyMode: false,
  405. );
  406. final same = _sameValue(exact.text, approx.text);
  407. return [
  408. ToolText(label, exact.text, tex: exact.tex),
  409. if (!same) ToolText('$label(近似)', approx.text, tex: approx.tex),
  410. ];
  411. }
  412. /// 两个显示串是不是同一个数(都解析得出来的话)。
  413. bool _sameValue(String a, String b) {
  414. if (a == b) return true;
  415. final x = asNumber(a), y = asNumber(b);
  416. if (x == null || y == null) return false;
  417. return (x - y).abs() <= 1e-9 * (1 + x.abs());
  418. }
  419. /// 把近似值缀在精确值后面:`16/81 ≈ 0.1975`。同一个数就只留精确值。
  420. ///
  421. /// 给"一条条分开"的分布表用 —— 每项单独成卡时不拆成两张,
  422. /// 否则 n=10 的二项分布会列出二十多张卡。
  423. String withApprox(String exact, String approx) {
  424. final a = approx.trim();
  425. if (a.isEmpty) return exact;
  426. return _sameValue(exact.trim(), a) ? exact : '$exact ≈ $a';
  427. }
  428. /// 纯数值输出:只给近似值(用户要的本来就是个数,不是分数)。
  429. Future<ToolOutput> numeric(
  430. SymjaSession session,
  431. String label,
  432. String expr,
  433. ) async {
  434. final r = await session.compute(
  435. approxExpr('Rationalize[$expr]', session.approximateDigits),
  436. applyMode: false,
  437. );
  438. return ToolText(label, r.text, tex: null);
  439. }
  440. /// 数值文本 → LaTeX:分数写成 `\frac{a}{b}`,其余原样。值都是 Symja 给的,这里只排版。
  441. String texNum(String v) {
  442. final t = v.trim();
  443. final m = RegExp(r'^(-?)(\d+)/(\d+)$').firstMatch(t);
  444. if (m == null) return t;
  445. return '${m[1]}\\frac{${m[2]}}{${m[3]}}';
  446. }
  447. /// 拼一条"… = 0"的等式(直线方程、圆的一般方程都用它)。
  448. ///
  449. /// 每项是 `(系数, 变量部分的 LaTeX, 变量部分的纯文本)` —— 变量部分留空就是常数项;
  450. /// 系数是 Symja 算出来的**精确**值原文(`-2`、`1/2` …)。会自动处理:
  451. /// · 系数是 0 的项不写(圆的 F = 0 时那一项就不该出现)
  452. /// · 系数 ±1 又带变量时省略 1(写 `x` 而不是 `1·x`)
  453. /// · 首项不带正号,其余项按正负接 `+` / `-`
  454. ({String plain, String tex}) polyEqualsZero(
  455. List<(String coef, String varTex, String varPlain)> terms,
  456. ) {
  457. final plain = StringBuffer();
  458. final tex = StringBuffer();
  459. var first = true;
  460. for (final (coef, varTex, varPlain) in terms) {
  461. final t = coef.trim();
  462. final n = asNumber(t);
  463. if (n != null && n == 0) continue; // 系数为 0:整项不写
  464. final neg = t.startsWith('-');
  465. final mag = neg ? t.substring(1) : t;
  466. // 系数是 ±1 又带变量时,1 不写
  467. final hideOne = varPlain.isNotEmpty && mag == '1';
  468. final sign = first ? (neg ? '-' : '') : (neg ? ' - ' : ' + ');
  469. plain.write(sign);
  470. tex.write(sign);
  471. if (!hideOne) {
  472. plain.write(varPlain.isEmpty ? mag : '$mag·');
  473. tex.write(texNum(mag));
  474. if (varPlain.isNotEmpty) tex.write(r' \cdot ');
  475. }
  476. plain.write(varPlain);
  477. tex.write(varTex);
  478. first = false;
  479. }
  480. if (first) {
  481. plain.write('0');
  482. tex.write('0');
  483. }
  484. return (plain: '$plain = 0', tex: '$tex = 0');
  485. }
  486. /// 把 Symja 输出的列表文本拆成嵌套的字符串列表。
  487. ///
  488. /// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示,
  489. /// 而通道里回来的是 `{{0, 1/6}, {1, 1/3}}` 这样一个字符串。刻意**不**做数字解析:
  490. /// 拆出来的还是 Symja 的原文(`1/6` 就是 `1/6`),要算数再交回给 Symja。
  491. List<Object> parseSymjaList(String s) {
  492. final t = s.trim();
  493. if (!t.startsWith('{') || !t.endsWith('}')) return [t];
  494. final out = <Object>[];
  495. final buf = StringBuffer();
  496. var depth = 0;
  497. for (var i = 1; i < t.length - 1; i++) {
  498. final c = t[i];
  499. if (c == '{' || c == '[' || c == '(') depth++;
  500. if (c == '}' || c == ']' || c == ')') depth--;
  501. if (c == ',' && depth == 0) {
  502. out.add(_item(buf.toString()));
  503. buf.clear();
  504. continue;
  505. }
  506. buf.write(c);
  507. }
  508. if (buf.isNotEmpty) out.add(_item(buf.toString()));
  509. return out;
  510. }
  511. Object _item(String raw) {
  512. final t = raw.trim();
  513. return t.startsWith('{') ? parseSymjaList(t) : t;
  514. }
  515. /// 取出列表里的数字(Symja 的近似值带一个 `` ` `` 精度标记,要去掉)。
  516. ///
  517. /// 只给**画图**用 —— 图表要的是 double,而"精确值"那条路始终走 Symja 原文。
  518. double? asNumber(Object? item) {
  519. if (item is! String) return null;
  520. return double.tryParse(item.replaceAll('`', '').trim());
  521. }