tool_kit.dart 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  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.autoRun = false,
  24. });
  25. final String title;
  26. /// 打开就自动算一次。绘图和二分法这类**输入框里已经有默认值**的工具用得上:
  27. /// 一进来就该看到图/解,而不是先按一下按钮。
  28. final bool autoRun;
  29. /// 输入框。顺序即显示顺序。
  30. ///
  31. /// 由**页面**持有(建在 State 里,别建在 build 里,否则主题一变控制器就被换掉、
  32. /// 用户打的字会丢)。外壳不 dispose 它们:对称变换那种一个页面里换几组输入框的
  33. /// 工具,控制器要跨模式活着。
  34. final List<ToolField> fields;
  35. /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。
  36. final Widget? intro;
  37. /// 计算。自己从 [fields] 里取文本、拼 Symja 表达式、调 [SymjaSession.compute]。
  38. ///
  39. /// 返回若干条输出:文字的可点复制,图表的不复制(图没什么可复制的)。
  40. final Future<List<ToolOutput>> Function(SymjaSession) compute;
  41. @override
  42. State<ToolScaffold> createState() => _ToolScaffoldState();
  43. }
  44. class _ToolScaffoldState extends State<ToolScaffold> {
  45. final List<ToolOutput> _outputs = [];
  46. String? _error;
  47. bool _busy = false;
  48. /// 换了一组输入框(对称变换那种切模式的情况)就把上一次的结果清掉 ——
  49. /// 留着上一模式的答案是误导。
  50. @override
  51. void didUpdateWidget(ToolScaffold old) {
  52. super.didUpdateWidget(old);
  53. if (!identical(old.fields, widget.fields)) {
  54. _outputs.clear();
  55. _error = null;
  56. }
  57. }
  58. @override
  59. void initState() {
  60. super.initState();
  61. if (widget.autoRun) {
  62. WidgetsBinding.instance.addPostFrameCallback((_) {
  63. if (mounted) _run();
  64. });
  65. }
  66. }
  67. Future<void> _run() async {
  68. final session = context.read<SymjaSession>();
  69. setState(() {
  70. _busy = true;
  71. _error = null;
  72. _outputs.clear();
  73. });
  74. try {
  75. final out = await widget.compute(session);
  76. if (!mounted) return;
  77. setState(() => _outputs.addAll(out));
  78. } on SymjaException catch (e) {
  79. if (mounted) setState(() => _error = '$e');
  80. } catch (e) {
  81. // 输入格式不对(少填了、写了非数字)在这里兜住,别把异常弹到 framework
  82. if (mounted) setState(() => _error = '$e');
  83. } finally {
  84. if (mounted) setState(() => _busy = false);
  85. }
  86. }
  87. void _reset() {
  88. for (final f in widget.fields) {
  89. f.controller.clear();
  90. }
  91. setState(() {
  92. _outputs.clear();
  93. _error = null;
  94. });
  95. }
  96. /// 把输入框排成一列,遇到标了 [ToolField.rowWithPrevious] 的就跟前一个并排。
  97. List<Widget> _fieldRows(BuildContext context) {
  98. final rows = <Widget>[];
  99. var group = <ToolField>[];
  100. void flush() {
  101. if (group.isEmpty) return;
  102. if (group.length == 1) {
  103. rows.add(group.first.build(context));
  104. } else {
  105. rows.add(Row(
  106. crossAxisAlignment: CrossAxisAlignment.start,
  107. children: [
  108. for (var i = 0; i < group.length; i++) ...[
  109. if (i > 0) const SizedBox(width: 10),
  110. Expanded(child: group[i].build(context)),
  111. ],
  112. ],
  113. ));
  114. }
  115. group = <ToolField>[];
  116. }
  117. for (final f in widget.fields) {
  118. if (!f.rowWithPrevious) flush();
  119. group.add(f);
  120. }
  121. flush();
  122. return rows;
  123. }
  124. /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。
  125. Widget _busyStrip(ColorScheme scheme) => Padding(
  126. padding: const EdgeInsets.symmetric(vertical: 10),
  127. child: Row(
  128. children: [
  129. const SizedBox(
  130. width: 14,
  131. height: 14,
  132. child: CircularProgressIndicator(strokeWidth: 2),
  133. ),
  134. const SizedBox(width: 10),
  135. Text('计算中…', style: TextStyle(color: scheme.outline)),
  136. ],
  137. ),
  138. );
  139. @override
  140. Widget build(BuildContext context) {
  141. final scheme = Theme.of(context).colorScheme;
  142. return Scaffold(
  143. appBar: AppBar(title: Text(widget.title)),
  144. body: ListView(
  145. padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
  146. children: [
  147. if (widget.intro != null) ...[
  148. widget.intro!,
  149. const SizedBox(height: 12),
  150. ],
  151. ..._fieldRows(context),
  152. const SizedBox(height: 12),
  153. Row(
  154. children: [
  155. Expanded(
  156. child: FilledButton.icon(
  157. onPressed: _busy ? null : _run,
  158. icon: const Icon(Icons.calculate_outlined),
  159. label: const Text('计算'),
  160. ),
  161. ),
  162. const SizedBox(width: 12),
  163. OutlinedButton.icon(
  164. onPressed: _busy ? null : _reset,
  165. icon: const Icon(Icons.refresh),
  166. label: const Text('重置'),
  167. ),
  168. ],
  169. ),
  170. if (_busy) _busyStrip(scheme),
  171. if (_error != null)
  172. Padding(
  173. padding: const EdgeInsets.only(top: 16),
  174. child: Card(
  175. color: scheme.errorContainer,
  176. child: Padding(
  177. padding: const EdgeInsets.all(14),
  178. child: Row(
  179. crossAxisAlignment: CrossAxisAlignment.start,
  180. children: [
  181. Icon(Icons.error_outline,
  182. size: 20, color: scheme.onErrorContainer),
  183. const SizedBox(width: 10),
  184. Expanded(
  185. child: Text(
  186. _error!,
  187. style: TextStyle(color: scheme.onErrorContainer),
  188. ),
  189. ),
  190. ],
  191. ),
  192. ),
  193. ),
  194. ),
  195. for (final o in _outputs)
  196. Padding(
  197. padding: const EdgeInsets.only(top: 12),
  198. child: _OutputCard(output: o),
  199. ),
  200. ],
  201. ),
  202. );
  203. }
  204. }
  205. /// 一个输入框。label 同时是发送给 Symja 时的变量含义提示。
  206. class ToolField {
  207. ToolField(
  208. this.label, {
  209. String? hint,
  210. this.keyboard = const TextInputType.numberWithOptions(
  211. signed: true,
  212. decimal: true,
  213. ),
  214. this.rowWithPrevious = false,
  215. }) : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
  216. controller = TextEditingController();
  217. final String label;
  218. final String hint;
  219. final TextInputType keyboard;
  220. final TextEditingController controller;
  221. /// 和**上一个**输入框并排放在同一行(宽度平分)。
  222. ///
  223. /// 成对出现的东西不该各占一行 —— 卷积的「取值 i / 概率 i」、
  224. /// 卡方检验的 2×2 列联表都是这样。
  225. final bool rowWithPrevious;
  226. String get text => controller.text.trim();
  227. /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 ——
  228. /// 数个数是记账,不是计算,留在 Dart 侧。
  229. int get count => _tokens.length;
  230. List<String> get _tokens =>
  231. text.split(RegExp(r'[\s,,]+')).where((s) => s.isNotEmpty).toList();
  232. /// 空格/逗号分隔的多个值 → Symja 的列表 `{1, 2, 3}`。
  233. ///
  234. /// 工具页允许用户直接写 `1/2` 这样的分数和 `Pi` 这样的常量 —— 它们交给 Symja
  235. /// 解析,Dart 侧不做数字转换(转换反而会把 `1/2` 变成 0.5 这种近似值)。
  236. String get list => '{${_tokens.join(', ')}}';
  237. Widget build(BuildContext context) => Padding(
  238. padding: const EdgeInsets.symmetric(vertical: 6),
  239. child: TextField(
  240. controller: controller,
  241. keyboardType: keyboard,
  242. decoration: InputDecoration(
  243. labelText: label,
  244. hintText: hint,
  245. border: const OutlineInputBorder(),
  246. isDense: true,
  247. ),
  248. ),
  249. );
  250. }
  251. /// 一条输出。[ToolText] 可点复制,[ToolChart] 不行 —— 图没有可复制的文本形态。
  252. sealed class ToolOutput {
  253. const ToolOutput(this.label);
  254. final String label;
  255. }
  256. /// 一条文字输出。[tex] 不为空时渲染成数学排版,点复制复制的仍是纯文本。
  257. class ToolText extends ToolOutput {
  258. const ToolText(super.label, this.text, {this.tex});
  259. final String text;
  260. final String? tex;
  261. }
  262. /// 一条图表输出。**图表不参与复制**(任务里明确要求的)。
  263. class ToolChart extends ToolOutput {
  264. const ToolChart(super.label, this.chart);
  265. final Widget chart;
  266. }
  267. class _OutputCard extends StatelessWidget {
  268. const _OutputCard({required this.output});
  269. final ToolOutput output;
  270. Future<void> _copy(BuildContext context, String text) async {
  271. await Clipboard.setData(ClipboardData(text: text));
  272. if (!context.mounted) return;
  273. ScaffoldMessenger.of(context)
  274. ..clearSnackBars()
  275. ..showSnackBar(
  276. SnackBar(
  277. content: Text('已复制:${text.length > 30 ? '${text.substring(0, 30)}…' : text}'),
  278. duration: const Duration(milliseconds: 1200),
  279. ),
  280. );
  281. }
  282. @override
  283. Widget build(BuildContext context) {
  284. final scheme = Theme.of(context).colorScheme;
  285. final o = output;
  286. if (o is ToolChart) {
  287. return Card(
  288. child: Padding(
  289. padding: const EdgeInsets.all(12),
  290. child: Column(
  291. crossAxisAlignment: CrossAxisAlignment.start,
  292. children: [
  293. Text(o.label, style: Theme.of(context).textTheme.labelLarge),
  294. const SizedBox(height: 8),
  295. o.chart,
  296. ],
  297. ),
  298. ),
  299. );
  300. }
  301. final t = o as ToolText;
  302. return Card(
  303. clipBehavior: Clip.antiAlias,
  304. child: InkWell(
  305. onTap: () => _copy(context, t.text),
  306. child: Padding(
  307. padding: const EdgeInsets.all(14),
  308. child: Row(
  309. crossAxisAlignment: CrossAxisAlignment.center,
  310. children: [
  311. Expanded(
  312. child: Column(
  313. crossAxisAlignment: CrossAxisAlignment.start,
  314. children: [
  315. Text(
  316. t.label,
  317. style: TextStyle(fontSize: 12, color: scheme.outline),
  318. ),
  319. const SizedBox(height: 6),
  320. // 有 TeX 就用数学排版显示;太长或没生成就退回纯文本
  321. if (t.tex != null)
  322. Math.tex(
  323. t.tex!,
  324. mathStyle: MathStyle.text,
  325. textStyle: TextStyle(
  326. fontSize: 20,
  327. color: scheme.onSurface,
  328. ),
  329. onErrorFallback: (_) => _plain(t.text),
  330. )
  331. else
  332. _plain(t.text),
  333. ],
  334. ),
  335. ),
  336. const SizedBox(width: 8),
  337. Icon(Icons.copy_outlined, size: 18, color: scheme.outline),
  338. ],
  339. ),
  340. ),
  341. ),
  342. );
  343. }
  344. /// 纯文本渲染。刻意用 `Text` 而不是 `SelectableText` —— 后者会把点击吃掉拿去
  345. /// 做选区,整张卡片的"点一下复制"就时灵时不灵了。
  346. Widget _plain(String text) => Text(
  347. text,
  348. style: const TextStyle(fontFamily: 'monospace', fontSize: 16),
  349. );
  350. }
  351. /// 精确值 + 近似值两行输出。
  352. ///
  353. /// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。现在精确值由 Symja
  354. /// 原样给出,近似值再问一次 `N[...]`。**两者相等时只留一行** —— 否则每个整数
  355. /// 结果都会多出一条一模一样的"≈"。
  356. Future<List<ToolOutput>> exactAndApprox(
  357. SymjaSession session,
  358. String label,
  359. String expr,
  360. ) async {
  361. final exact = await session.compute(expr, applyMode: false);
  362. final approx = await session.compute('N[$expr, 10]', applyMode: false);
  363. final same = approx.text == exact.text;
  364. return [
  365. ToolText(label, exact.text, tex: exact.tex),
  366. if (!same) ToolText('$label(近似)', approx.text, tex: approx.tex),
  367. ];
  368. }
  369. /// 纯数值输出:只给近似值(用户要的本来就是个数,不是分数)。
  370. Future<ToolOutput> numeric(
  371. SymjaSession session,
  372. String label,
  373. String expr,
  374. ) async {
  375. final r = await session.compute('N[$expr, 12]', applyMode: false);
  376. return ToolText(label, r.text, tex: null);
  377. }
  378. /// 把 Symja 输出的列表文本拆成嵌套的字符串列表。
  379. ///
  380. /// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示,
  381. /// 而通道里回来的是 `{{0, 1/6}, {1, 1/3}}` 这样一个字符串。刻意**不**做数字解析:
  382. /// 拆出来的还是 Symja 的原文(`1/6` 就是 `1/6`),要算数再交回给 Symja。
  383. List<Object> parseSymjaList(String s) {
  384. final t = s.trim();
  385. if (!t.startsWith('{') || !t.endsWith('}')) return [t];
  386. final out = <Object>[];
  387. final buf = StringBuffer();
  388. var depth = 0;
  389. for (var i = 1; i < t.length - 1; i++) {
  390. final c = t[i];
  391. if (c == '{' || c == '[' || c == '(') depth++;
  392. if (c == '}' || c == ']' || c == ')') depth--;
  393. if (c == ',' && depth == 0) {
  394. out.add(_item(buf.toString()));
  395. buf.clear();
  396. continue;
  397. }
  398. buf.write(c);
  399. }
  400. if (buf.isNotEmpty) out.add(_item(buf.toString()));
  401. return out;
  402. }
  403. Object _item(String raw) {
  404. final t = raw.trim();
  405. return t.startsWith('{') ? parseSymjaList(t) : t;
  406. }
  407. /// 取出列表里的数字(Symja 的近似值带一个 `` ` `` 精度标记,要去掉)。
  408. ///
  409. /// 只给**画图**用 —— 图表要的是 double,而"精确值"那条路始终走 Symja 原文。
  410. double? asNumber(Object? item) {
  411. if (item is! String) return null;
  412. return double.tryParse(item.replaceAll('`', '').trim());
  413. }