calculator_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter_math_fork/flutter_math.dart';
  4. import 'package:provider/provider.dart';
  5. import 'curve_plot.dart';
  6. import 'expr_editor.dart';
  7. import 'expr_node.dart';
  8. import 'symja.dart';
  9. import 'symja_functions.dart';
  10. /// 科学计算器(默认主界面)。
  11. ///
  12. /// 输入用**结构化节点树**(见 lib/expr_node.dart),不是字符串:
  13. /// 按键把节点插进当前光标槽,分数竖排、指数上标、多参数函数各有自己的槽。
  14. /// 纯文本输入留给控制台。
  15. class CalculatorPage extends StatefulWidget {
  16. const CalculatorPage({super.key});
  17. @override
  18. State<CalculatorPage> createState() => _CalculatorPageState();
  19. }
  20. class _CalculatorPageState extends State<CalculatorPage> {
  21. final _doc = ExprDoc();
  22. SymjaResult? _preview;
  23. SymjaResult? _committed;
  24. String? _error;
  25. Timer? _debounce;
  26. int _seq = 0;
  27. @override
  28. void dispose() {
  29. _debounce?.cancel();
  30. super.dispose();
  31. }
  32. // ------------------------------------------------------------------ 求值
  33. /// 编辑器每次变化都走这里:清掉上次的提交态,防抖后重算预览。
  34. void _onDocChanged() {
  35. setState(() {
  36. _committed = null;
  37. _error = null;
  38. });
  39. _debounce?.cancel();
  40. _debounce = Timer(const Duration(milliseconds: 500), _runPreview);
  41. }
  42. String get _source => _doc.toSymja();
  43. Future<void> _runPreview() async {
  44. final expr = _source;
  45. if (expr.isEmpty || expr == 'Null') {
  46. if (mounted) setState(() => _preview = null);
  47. return;
  48. }
  49. final session = context.read<SymjaSession>();
  50. final id = ++_seq;
  51. final r = await session.preview(expr);
  52. if (!mounted || id != _seq) return; // 已有更新的预览在路上
  53. setState(() => _preview = r);
  54. }
  55. Future<void> _commit() async {
  56. final expr = _source;
  57. if (expr.isEmpty || expr == 'Null') return;
  58. _debounce?.cancel();
  59. final session = context.read<SymjaSession>();
  60. final id = ++_seq; // 让在途的预览作废
  61. try {
  62. final r = await session.evaluate(expr);
  63. if (!mounted || id != _seq) return;
  64. setState(() {
  65. _committed = r;
  66. _preview = null;
  67. _error = null;
  68. });
  69. } on SymjaException catch (e) {
  70. if (!mounted || id != _seq) return;
  71. setState(() {
  72. _error = '$e';
  73. _committed = null;
  74. _preview = null;
  75. });
  76. }
  77. await session.refreshVariables();
  78. }
  79. Future<void> _plotCurrent() async {
  80. final expr = _source;
  81. if (expr.isEmpty || expr == 'Null') return;
  82. final session = context.read<SymjaSession>();
  83. final messenger = ScaffoldMessenger.of(context);
  84. try {
  85. final s = await session.sample(expr, from: -6.3, to: 6.3, count: 240);
  86. if (!mounted) return;
  87. await showDialog<void>(
  88. context: context,
  89. builder: (_) => AlertDialog(
  90. title: Text('$expr (${s.ys.length} 点,${s.elapsedMs} ms)'),
  91. content: SizedBox(
  92. width: 360,
  93. height: 280,
  94. child: CurvePlot(series: [
  95. CurveSeries(
  96. label: expr,
  97. color: const Color(0xFF3F51B5),
  98. points: [
  99. for (var i = 0; i < s.ys.length; i++)
  100. Offset(s.xAt(i), s.ys[i]),
  101. ],
  102. ),
  103. ]),
  104. ),
  105. actions: [
  106. TextButton(
  107. onPressed: () => Navigator.pop(context),
  108. child: const Text('关闭'),
  109. ),
  110. ],
  111. ),
  112. );
  113. } on SymjaException catch (e) {
  114. messenger.showSnackBar(SnackBar(content: Text('$e')));
  115. }
  116. }
  117. // ------------------------------------------------------------------ 按键
  118. void _press(_Key k) {
  119. switch (k.act) {
  120. case _Act.char:
  121. if (k.mergeDigit) {
  122. // 数字键并入左边那个数字字面量,`42` 才是一个数而不是两个节点
  123. _doc.insertChar(k.payload);
  124. } else {
  125. _doc.insert(LitNode(k.payload));
  126. }
  127. case _Act.fn:
  128. _doc.makeFunction(k.payload);
  129. case _Act.fnPrefixed:
  130. // 像 Integrate 这种,第一个参数是要填的,后面预置好
  131. _doc.makeFunction(k.payload, prefill: k.prefill);
  132. // prefill 之后光标停在第一个槽(空的),正是要填的位置
  133. case _Act.sqrt:
  134. _doc.makeSqrt();
  135. case _Act.group:
  136. _doc.makeGroup();
  137. case _Act.power:
  138. _doc.makePower();
  139. case _Act.frac:
  140. _doc.makeFraction();
  141. case _Act.postfix:
  142. // 阶乘是后缀:把光标左边那个节点收进来当操作数
  143. final operand = _doc.takeLeft();
  144. _doc.insert(PostfixNode(
  145. operand: SeqNode(operand == null ? null : [operand]),
  146. ));
  147. case _Act.comma:
  148. _doc.nextArgument();
  149. case _Act.back:
  150. _doc.backspace();
  151. case _Act.clear:
  152. _doc.clear();
  153. case _Act.commit:
  154. _commit();
  155. return; // 提交不改文档,不用刷新
  156. }
  157. _onDocChanged();
  158. }
  159. Future<void> _showPalette() async {
  160. final picked = await showModalBottomSheet<({String value, bool isFn})>(
  161. context: context,
  162. isScrollControlled: true,
  163. showDragHandle: true,
  164. builder: (_) => const _FunctionPalette(),
  165. );
  166. if (picked == null) return;
  167. if (picked.isFn) {
  168. _doc.makeFunction(picked.value);
  169. } else {
  170. _doc.insert(LitNode(picked.value));
  171. }
  172. _onDocChanged();
  173. }
  174. // ------------------------------------------------------------------ UI
  175. @override
  176. Widget build(BuildContext context) {
  177. final session = context.watch<SymjaSession>();
  178. return Scaffold(
  179. appBar: AppBar(
  180. title: const Text('计算器'),
  181. actions: [
  182. // 精确 / 近似 切换。默认精确:`1/3` 就是 `1/3`,这是符号计算的意义所在。
  183. TextButton(
  184. onPressed: () {
  185. session.approximate = !session.approximate;
  186. _onDocChanged(); // 立刻按新模式重算预览
  187. },
  188. child: Text(
  189. session.approximate ? '≈ 近似' : '精确',
  190. style: TextStyle(
  191. fontFamily: 'monospace',
  192. fontSize: 13,
  193. color: Theme.of(context).colorScheme.primary,
  194. ),
  195. ),
  196. ),
  197. IconButton(
  198. tooltip: '画图(x ∈ [-6.3, 6.3])',
  199. icon: const Icon(Icons.show_chart),
  200. onPressed: _plotCurrent,
  201. ),
  202. IconButton(
  203. tooltip: '函数面板',
  204. icon: const Icon(Icons.functions),
  205. onPressed: _showPalette,
  206. ),
  207. IconButton(
  208. tooltip: '清空',
  209. icon: const Icon(Icons.refresh),
  210. onPressed: () {
  211. _doc.clear();
  212. _onDocChanged();
  213. },
  214. ),
  215. ],
  216. ),
  217. body: SafeArea(
  218. child: Column(
  219. children: [
  220. _display(context),
  221. const Divider(height: 1),
  222. Expanded(child: _keypad(context)),
  223. ],
  224. ),
  225. ),
  226. );
  227. }
  228. Widget _display(BuildContext context) {
  229. final scheme = Theme.of(context).colorScheme;
  230. final shown = _committed ?? _preview;
  231. return Container(
  232. width: double.infinity,
  233. constraints: const BoxConstraints(minHeight: 132),
  234. padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
  235. color: scheme.surfaceContainerLow,
  236. child: Column(
  237. crossAxisAlignment: CrossAxisAlignment.end,
  238. children: [
  239. ExprEditor(doc: _doc, onChanged: _onDocChanged),
  240. const SizedBox(height: 6),
  241. // 序列化结果摆在明面上:用户能看到"到底会被求值成什么"
  242. Text(
  243. _source,
  244. textAlign: TextAlign.right,
  245. style: TextStyle(
  246. fontFamily: 'monospace',
  247. fontSize: 11,
  248. color: scheme.outline,
  249. ),
  250. ),
  251. const SizedBox(height: 6),
  252. SizedBox(
  253. height: 52,
  254. width: double.infinity,
  255. // 等比缩到放得下,**不横向滚动** ——
  256. // 滚动的话长结果只能看到尾巴,"显示不全"就是这么来的。
  257. child: FittedBox(
  258. fit: BoxFit.scaleDown,
  259. alignment: Alignment.centerRight,
  260. child: _resultView(context, shown),
  261. ),
  262. ),
  263. ],
  264. ),
  265. );
  266. }
  267. Widget _resultView(BuildContext context, SymjaResult? shown) {
  268. final scheme = Theme.of(context).colorScheme;
  269. if (_error != null) {
  270. return SelectableText(
  271. _error!,
  272. style: TextStyle(
  273. fontFamily: 'monospace',
  274. fontSize: 13,
  275. color: scheme.error,
  276. ),
  277. );
  278. }
  279. if (shown == null) return const SizedBox.shrink();
  280. final plain = SelectableText(
  281. shown.text,
  282. style: const TextStyle(fontFamily: 'monospace', fontSize: 22),
  283. );
  284. final tex = shown.tex;
  285. final body = tex == null
  286. ? plain
  287. : Math.tex(
  288. tex,
  289. mathStyle: MathStyle.display,
  290. textStyle: TextStyle(fontSize: 26, color: scheme.onSurface),
  291. onErrorFallback: (_) => plain,
  292. );
  293. return Row(
  294. mainAxisSize: MainAxisSize.min,
  295. children: [
  296. if (_committed != null) ...[
  297. Icon(Icons.check_circle, size: 16, color: scheme.primary),
  298. const SizedBox(width: 6),
  299. Text('${_committed!.elapsedMs} ms',
  300. style: TextStyle(fontSize: 11, color: scheme.outline)),
  301. const SizedBox(width: 10),
  302. ],
  303. body,
  304. ],
  305. );
  306. }
  307. Widget _keypad(BuildContext context) {
  308. return LayoutBuilder(
  309. builder: (context, box) {
  310. final rows = <List<_Key>>[
  311. [
  312. _Key('∫', _Act.fnPrefixed, payload: 'Integrate', prefill: ['', 'x']),
  313. _Key('Σ', _Act.fnPrefixed,
  314. payload: 'Sum', prefill: ['', '{n, 1, Infinity}']),
  315. _Key('d/dx', _Act.fnPrefixed, payload: 'D', prefill: ['', 'x']),
  316. _Key('lim', _Act.fnPrefixed, payload: 'Limit', prefill: ['', 'x->0']),
  317. _Key('solve', _Act.fnPrefixed, payload: 'Solve', prefill: ['', 'x']),
  318. ],
  319. [
  320. _Key('sin', _Act.fn, payload: 'Sin'),
  321. _Key('cos', _Act.fn, payload: 'Cos'),
  322. _Key('tan', _Act.fn, payload: 'Tan'),
  323. _Key('ln', _Act.fn, payload: 'Log'),
  324. _Key('√', _Act.sqrt),
  325. ],
  326. [
  327. _Key('7', _Act.char, payload: '7', mergeDigit: true),
  328. _Key('8', _Act.char, payload: '8', mergeDigit: true),
  329. _Key('9', _Act.char, payload: '9', mergeDigit: true),
  330. _Key('(', _Act.group),
  331. _Key('/', _Act.frac),
  332. ],
  333. [
  334. _Key('4', _Act.char, payload: '4', mergeDigit: true),
  335. _Key('5', _Act.char, payload: '5', mergeDigit: true),
  336. _Key('6', _Act.char, payload: '6', mergeDigit: true),
  337. _Key('×', _Act.char, payload: '*'),
  338. _Key('!', _Act.postfix),
  339. ],
  340. [
  341. _Key('1', _Act.char, payload: '1', mergeDigit: true),
  342. _Key('2', _Act.char, payload: '2', mergeDigit: true),
  343. _Key('3', _Act.char, payload: '3', mergeDigit: true),
  344. _Key('+', _Act.char, payload: '+'),
  345. _Key('−', _Act.char, payload: '-'),
  346. ],
  347. [
  348. _Key('0', _Act.char, payload: '0', mergeDigit: true),
  349. _Key('.', _Act.char, payload: '.', mergeDigit: true),
  350. _Key('^', _Act.power),
  351. _Key('x', _Act.char, payload: 'x'),
  352. _Key('n', _Act.char, payload: 'n'),
  353. ],
  354. [
  355. _Key('⌫', _Act.back, tint: true),
  356. _Key('CLR', _Act.clear, tint: true),
  357. _Key(',', _Act.comma),
  358. _Key('ANS', _Act.char, payload: '%', tint: true),
  359. _Key('=', _Act.commit, primary: true),
  360. ],
  361. ];
  362. // 7 行 + 6 个间隙,再扣掉 Padding 上下各 6 ——
  363. // box.maxHeight 是 padding **之前**的高度,漏掉会溢出。
  364. final h = (box.maxHeight - 12 - 6 * 6) / 7;
  365. return Padding(
  366. padding: const EdgeInsets.all(6),
  367. child: Column(
  368. children: [
  369. for (var r = 0; r < rows.length; r++) ...[
  370. if (r > 0) const SizedBox(height: 6),
  371. SizedBox(
  372. height: h,
  373. child: Row(
  374. children: [
  375. for (var c = 0; c < rows[r].length; c++) ...[
  376. if (c > 0) const SizedBox(width: 6),
  377. Expanded(child: _keyButton(rows[r][c])),
  378. ],
  379. ],
  380. ),
  381. ),
  382. ],
  383. ],
  384. ),
  385. );
  386. },
  387. );
  388. }
  389. Widget _keyButton(_Key k) {
  390. final scheme = Theme.of(context).colorScheme;
  391. final bg = k.primary
  392. ? scheme.primary
  393. : k.tint
  394. ? scheme.surfaceContainerHighest
  395. : scheme.surfaceContainerLow;
  396. final fg = k.primary ? scheme.onPrimary : scheme.onSurface;
  397. return Material(
  398. // 加 key 是为了让测试能精确定位按键 —— 编辑器里渲染出的字面量(比如 'x')
  399. // 和键盘上同名的按键都符合 find.text,会撞车。
  400. key: ValueKey('key-${k.label}'),
  401. color: bg,
  402. borderRadius: BorderRadius.circular(10),
  403. child: InkWell(
  404. borderRadius: BorderRadius.circular(10),
  405. onTap: () => _press(k),
  406. child: Center(
  407. child: Text(
  408. k.label,
  409. style: TextStyle(
  410. color: fg,
  411. fontSize: k.label.length > 2 ? 13 : 17,
  412. fontFamily: k.label.length > 2 ? null : 'monospace',
  413. ),
  414. ),
  415. ),
  416. ),
  417. );
  418. }
  419. }
  420. enum _Act {
  421. char,
  422. fn,
  423. fnPrefixed,
  424. sqrt,
  425. group,
  426. power,
  427. frac,
  428. postfix,
  429. comma,
  430. back,
  431. clear,
  432. commit,
  433. }
  434. @immutable
  435. class _Key {
  436. final String label;
  437. final _Act act;
  438. final String payload;
  439. /// fnPrefixed 用:第一个参数留空给用户填,其余的预置。
  440. final List<String> prefill;
  441. /// 数字键:并入左边那个数字字面量,而不是各建一个节点。
  442. final bool mergeDigit;
  443. final bool tint;
  444. final bool primary;
  445. const _Key(
  446. this.label,
  447. this.act, {
  448. this.payload = '',
  449. this.prefill = const [],
  450. this.mergeDigit = false,
  451. this.tint = false,
  452. this.primary = false,
  453. });
  454. }
  455. /// 函数面板:检索 Symja 的两百多个函数名,选中后插入一个函数节点。
  456. class _FunctionPalette extends StatefulWidget {
  457. const _FunctionPalette();
  458. @override
  459. State<_FunctionPalette> createState() => _FunctionPaletteState();
  460. }
  461. class _FunctionPaletteState extends State<_FunctionPalette> {
  462. final _search = TextEditingController();
  463. String _q = '';
  464. @override
  465. void dispose() {
  466. _search.dispose();
  467. super.dispose();
  468. }
  469. @override
  470. Widget build(BuildContext context) {
  471. final q = _q.trim().toLowerCase();
  472. final fns = kSymjaFunctions
  473. .where((f) => q.isEmpty || f.toLowerCase().contains(q))
  474. .toList(growable: false);
  475. return DraggableScrollableSheet(
  476. expand: false,
  477. initialChildSize: 0.75,
  478. maxChildSize: 0.95,
  479. builder: (context, scroll) => Column(
  480. children: [
  481. Padding(
  482. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  483. child: TextField(
  484. controller: _search,
  485. autofocus: true,
  486. decoration: InputDecoration(
  487. isDense: true,
  488. hintText: '函数名(共 ${kSymjaFunctions.length} 个)',
  489. prefixIcon: const Icon(Icons.search),
  490. border: const OutlineInputBorder(),
  491. ),
  492. onChanged: (v) => setState(() => _q = v),
  493. ),
  494. ),
  495. // 常量做成可点的 —— 它们从键盘上挪走了,得有个地方能按。
  496. Padding(
  497. padding: const EdgeInsets.symmetric(horizontal: 16),
  498. child: Wrap(
  499. spacing: 8,
  500. runSpacing: 4,
  501. crossAxisAlignment: WrapCrossAlignment.center,
  502. children: [
  503. Text('常量', style: Theme.of(context).textTheme.bodySmall),
  504. for (final c in kSymjaConstants)
  505. ActionChip(
  506. label: Text(
  507. c,
  508. style: const TextStyle(
  509. fontFamily: 'monospace', fontSize: 12),
  510. ),
  511. visualDensity: VisualDensity.compact,
  512. materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
  513. onPressed: () =>
  514. Navigator.pop(context, (value: c, isFn: false)),
  515. ),
  516. ],
  517. ),
  518. ),
  519. const Divider(height: 16),
  520. Expanded(
  521. child: fns.isEmpty
  522. ? const Center(child: Text('没有匹配的函数'))
  523. : ListView.builder(
  524. controller: scroll,
  525. itemCount: fns.length,
  526. itemBuilder: (_, i) => ListTile(
  527. dense: true,
  528. title: Text(
  529. fns[i],
  530. style: const TextStyle(fontFamily: 'monospace'),
  531. ),
  532. onTap: () => Navigator.pop(
  533. context, (value: fns[i], isFn: true)),
  534. ),
  535. ),
  536. ),
  537. ],
  538. ),
  539. );
  540. }
  541. }