calculator_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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 'symja.dart';
  7. import 'symja_functions.dart';
  8. /// 科学计算器(默认主界面)。
  9. ///
  10. /// 表达式用**字符串**表示,按键在光标处插入 —— 这是 Q28b 定的第一阶段。
  11. /// 结构化节点输入(节点树 / 槽 / 选区)是后续独立的升级,但下游接口不变:
  12. /// 求值、历史、双界面看到的始终是一个表达式字符串。
  13. class CalculatorPage extends StatefulWidget {
  14. const CalculatorPage({super.key});
  15. @override
  16. State<CalculatorPage> createState() => _CalculatorPageState();
  17. }
  18. class _CalculatorPageState extends State<CalculatorPage> {
  19. final _ctrl = TextEditingController();
  20. final _focus = FocusNode();
  21. /// 边输入边算的结果(不算历史、不动 `%`)。
  22. SymjaResult? _preview;
  23. /// 按 = 提交后的结果。
  24. SymjaResult? _committed;
  25. String? _error;
  26. Timer? _debounce;
  27. int _seq = 0;
  28. @override
  29. void dispose() {
  30. _debounce?.cancel();
  31. _ctrl.dispose();
  32. _focus.dispose();
  33. super.dispose();
  34. }
  35. // ------------------------------------------------------------------ 表达式编辑
  36. /// 在光标处插入 [text],并把光标往回退 [back] 位(用于 `Integrate[□, x]` 这类模板)。
  37. void _insert(String text, {int back = 0}) {
  38. final v = _ctrl.value;
  39. final sel = v.selection;
  40. final start = sel.isValid ? sel.start : v.text.length;
  41. final end = sel.isValid ? sel.end : v.text.length;
  42. final newText = v.text.replaceRange(start, end, text);
  43. _ctrl.value = TextEditingValue(
  44. text: newText,
  45. selection:
  46. TextSelection.collapsed(offset: start + text.length - back),
  47. );
  48. _onChanged();
  49. }
  50. void _backspace() {
  51. final v = _ctrl.value;
  52. final sel = v.selection;
  53. final start = sel.isValid ? sel.start : v.text.length;
  54. final end = sel.isValid ? sel.end : v.text.length;
  55. if (start == end && start == 0) return;
  56. final from = start == end ? start - 1 : start;
  57. final newText = v.text.replaceRange(from, end, '');
  58. _ctrl.value = TextEditingValue(
  59. text: newText,
  60. selection: TextSelection.collapsed(offset: from),
  61. );
  62. _onChanged();
  63. }
  64. void _clear() {
  65. _ctrl.clear();
  66. setState(() {
  67. _preview = null;
  68. _committed = null;
  69. _error = null;
  70. });
  71. }
  72. void _onChanged() {
  73. setState(() {
  74. _committed = null;
  75. _error = null;
  76. });
  77. _debounce?.cancel();
  78. // 防抖:按键连打时不要每个字符都过一次通道
  79. _debounce = Timer(const Duration(milliseconds: 500), _runPreview);
  80. }
  81. Future<void> _runPreview() async {
  82. final expr = _ctrl.text.trim();
  83. if (expr.isEmpty) {
  84. if (mounted) setState(() => _preview = null);
  85. return;
  86. }
  87. final session = context.read<SymjaSession>();
  88. final id = ++_seq;
  89. final r = await session.preview(expr);
  90. if (!mounted || id != _seq) return; // 已有更新的预览在路上
  91. setState(() => _preview = r);
  92. }
  93. // ------------------------------------------------------------------ 提交
  94. Future<void> _commit() async {
  95. final expr = _ctrl.text.trim();
  96. if (expr.isEmpty) return;
  97. _debounce?.cancel();
  98. final session = context.read<SymjaSession>();
  99. final id = ++_seq; // 让在途的预览作废
  100. try {
  101. final r = await session.evaluate(expr);
  102. if (!mounted || id != _seq) return;
  103. setState(() {
  104. _committed = r;
  105. _preview = null;
  106. _error = null;
  107. });
  108. } on SymjaException catch (e) {
  109. if (!mounted || id != _seq) return;
  110. setState(() {
  111. _error = '$e';
  112. _committed = null;
  113. _preview = null;
  114. });
  115. }
  116. await session.refreshVariables();
  117. }
  118. /// 把当前表达式在 x ∈ [-6.3, 6.3] 上采样并画出来。
  119. Future<void> _plotCurrent() async {
  120. final expr = _ctrl.text.trim();
  121. if (expr.isEmpty) return;
  122. final session = context.read<SymjaSession>();
  123. final messenger = ScaffoldMessenger.of(context);
  124. try {
  125. final s = await session.sample(expr, from: -6.3, to: 6.3, count: 240);
  126. if (!mounted) return;
  127. await showDialog<void>(
  128. context: context,
  129. builder: (_) => AlertDialog(
  130. title: Text('$expr (${s.ys.length} 点,${s.elapsedMs} ms)'),
  131. content: SizedBox(
  132. width: 360,
  133. height: 280,
  134. child: CurvePlot(series: [
  135. CurveSeries(
  136. label: expr,
  137. color: const Color(0xFF3F51B5),
  138. points: [
  139. for (var i = 0; i < s.ys.length; i++)
  140. Offset(s.xAt(i), s.ys[i]),
  141. ],
  142. ),
  143. ]),
  144. ),
  145. actions: [
  146. TextButton(
  147. onPressed: () => Navigator.pop(context),
  148. child: const Text('关闭'),
  149. ),
  150. ],
  151. ),
  152. );
  153. } on SymjaException catch (e) {
  154. messenger.showSnackBar(SnackBar(content: Text('$e')));
  155. }
  156. }
  157. Future<void> _showPalette() async {
  158. // 面板返回「要插入什么」+「光标从末尾回退几位」:
  159. // 函数是 `Sin[]` 退 1(光标在括号里),常量是 `Pi` 退 0。
  160. final picked = await showModalBottomSheet<({String text, int back})>(
  161. context: context,
  162. isScrollControlled: true,
  163. showDragHandle: true,
  164. builder: (_) => const _FunctionPalette(),
  165. );
  166. if (picked != null) _insert(picked.text, back: picked.back);
  167. }
  168. // ------------------------------------------------------------------ UI
  169. @override
  170. Widget build(BuildContext context) {
  171. return Scaffold(
  172. appBar: AppBar(
  173. title: const Text('计算器'),
  174. actions: [
  175. IconButton(
  176. tooltip: '画图(x ∈ [-6.3, 6.3])',
  177. icon: const Icon(Icons.show_chart),
  178. onPressed: _plotCurrent,
  179. ),
  180. IconButton(
  181. tooltip: '函数面板',
  182. icon: const Icon(Icons.functions),
  183. onPressed: _showPalette,
  184. ),
  185. IconButton(
  186. tooltip: '清空',
  187. icon: const Icon(Icons.refresh),
  188. onPressed: _clear,
  189. ),
  190. ],
  191. ),
  192. body: SafeArea(
  193. child: Column(
  194. children: [
  195. _display(context),
  196. const Divider(height: 1),
  197. Expanded(child: _keypad(context)),
  198. ],
  199. ),
  200. ),
  201. );
  202. }
  203. Widget _display(BuildContext context) {
  204. final scheme = Theme.of(context).colorScheme;
  205. final shown = _committed ?? _preview;
  206. return Container(
  207. width: double.infinity,
  208. padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
  209. color: scheme.surfaceContainerLow,
  210. child: Column(
  211. crossAxisAlignment: CrossAxisAlignment.end,
  212. children: [
  213. TextField(
  214. controller: _ctrl,
  215. focusNode: _focus,
  216. maxLines: 3,
  217. minLines: 1,
  218. textAlign: TextAlign.right,
  219. style: const TextStyle(fontFamily: 'monospace', fontSize: 18),
  220. decoration: const InputDecoration(
  221. isDense: true,
  222. border: InputBorder.none,
  223. hintText: '按下面的键,或点这里用键盘输入',
  224. ),
  225. onChanged: (_) => _onChanged(),
  226. onSubmitted: (_) => _commit(),
  227. ),
  228. const SizedBox(height: 8),
  229. SizedBox(
  230. height: 56,
  231. child: Align(
  232. alignment: Alignment.centerRight,
  233. child: SingleChildScrollView(
  234. scrollDirection: Axis.horizontal,
  235. reverse: true,
  236. child: _resultView(context, shown),
  237. ),
  238. ),
  239. ),
  240. ],
  241. ),
  242. );
  243. }
  244. Widget _resultView(BuildContext context, SymjaResult? shown) {
  245. final scheme = Theme.of(context).colorScheme;
  246. if (_error != null) {
  247. return SelectableText(
  248. _error!,
  249. style: TextStyle(
  250. fontFamily: 'monospace',
  251. fontSize: 13,
  252. color: scheme.error,
  253. ),
  254. );
  255. }
  256. if (shown == null) {
  257. return Text(
  258. _ctrl.text.trim().isEmpty ? '' : '…',
  259. style: TextStyle(fontSize: 22, color: scheme.outline),
  260. );
  261. }
  262. final plain = SelectableText(
  263. shown.text,
  264. style: const TextStyle(fontFamily: 'monospace', fontSize: 22),
  265. );
  266. final tex = shown.tex;
  267. final body = tex == null
  268. ? plain
  269. : Math.tex(
  270. tex,
  271. mathStyle: MathStyle.display,
  272. textStyle: TextStyle(fontSize: 26, color: scheme.onSurface),
  273. onErrorFallback: (_) => plain,
  274. );
  275. return Row(
  276. mainAxisSize: MainAxisSize.min,
  277. children: [
  278. if (_committed != null) ...[
  279. Icon(Icons.check_circle, size: 16, color: scheme.primary),
  280. const SizedBox(width: 6),
  281. Text('${_committed!.elapsedMs} ms',
  282. style: TextStyle(fontSize: 11, color: scheme.outline)),
  283. const SizedBox(width: 10),
  284. ],
  285. body,
  286. ],
  287. );
  288. }
  289. Widget _keypad(BuildContext context) {
  290. return LayoutBuilder(
  291. builder: (context, box) {
  292. const rows = <List<_Key>>[
  293. [
  294. _Key('∫', insert: 'Integrate[', after: ', x]'),
  295. _Key('Σ', insert: 'Sum[', after: ', {n, 1, Infinity}]'),
  296. _Key('d/dx', insert: 'D[', after: ', x]'),
  297. _Key('lim', insert: 'Limit[', after: ', x->0]'),
  298. _Key('solve', insert: 'Solve[', after: ', x]'),
  299. ],
  300. [
  301. _Key('sin', insert: 'Sin[', after: ']'),
  302. _Key('cos', insert: 'Cos[', after: ']'),
  303. _Key('tan', insert: 'Tan[', after: ']'),
  304. _Key('ln', insert: 'Log[', after: ']'),
  305. _Key('√', insert: 'Sqrt[', after: ']'),
  306. ],
  307. [
  308. _Key('7', insert: '7'),
  309. _Key('8', insert: '8'),
  310. _Key('9', insert: '9'),
  311. _Key('(', insert: '('),
  312. _Key(')', insert: ')'),
  313. ],
  314. [
  315. _Key('4', insert: '4'),
  316. _Key('5', insert: '5'),
  317. _Key('6', insert: '6'),
  318. _Key('×', insert: '*'),
  319. _Key('÷', insert: '/'),
  320. ],
  321. [
  322. _Key('1', insert: '1'),
  323. _Key('2', insert: '2'),
  324. _Key('3', insert: '3'),
  325. _Key('+', insert: '+'),
  326. _Key('−', insert: '-'),
  327. ],
  328. [
  329. _Key('0', insert: '0'),
  330. _Key('.', insert: '.'),
  331. _Key('^', insert: '^'),
  332. // 变量键:没有它们就没法只用按钮写出 Integrate[x^2, x]。
  333. // π / e 挪进了函数面板(那里的常量是可以点的)。
  334. _Key('x', insert: 'x'),
  335. _Key('n', insert: 'n'),
  336. ],
  337. [
  338. _Key('⌫', action: _KeyAction.backspace, tint: true),
  339. _Key('CLR', action: _KeyAction.clear, tint: true),
  340. _Key(',', insert: ', '),
  341. _Key('ANS', insert: '%', tint: true),
  342. _Key('=', action: _KeyAction.commit, primary: true),
  343. ],
  344. ];
  345. // 7 行 + 6 个 6px 间隙,再扣掉 Padding 的上下各 6px ——
  346. // box.maxHeight 是 padding **之前**的高度,漏掉这一项会溢出 12px。
  347. const gaps = 6 * 6.0;
  348. const padding = 12.0;
  349. final h = (box.maxHeight - padding - gaps) / 7;
  350. return Padding(
  351. padding: const EdgeInsets.all(6),
  352. child: Column(
  353. children: [
  354. for (var r = 0; r < rows.length; r++) ...[
  355. if (r > 0) const SizedBox(height: 6),
  356. SizedBox(
  357. height: h,
  358. child: Row(
  359. children: [
  360. for (var c = 0; c < rows[r].length; c++) ...[
  361. if (c > 0) const SizedBox(width: 6),
  362. Expanded(child: _keyButton(rows[r][c])),
  363. ],
  364. ],
  365. ),
  366. ),
  367. ],
  368. ],
  369. ),
  370. );
  371. },
  372. );
  373. }
  374. Widget _keyButton(_Key k) {
  375. final scheme = Theme.of(context).colorScheme;
  376. final bg = k.primary
  377. ? scheme.primary
  378. : k.tint
  379. ? scheme.surfaceContainerHighest
  380. : scheme.surfaceContainerLow;
  381. final fg = k.primary ? scheme.onPrimary : scheme.onSurface;
  382. return Material(
  383. color: bg,
  384. borderRadius: BorderRadius.circular(10),
  385. child: InkWell(
  386. borderRadius: BorderRadius.circular(10),
  387. onTap: () {
  388. switch (k.action) {
  389. case _KeyAction.insert:
  390. // 模板形如 Insert[□, x]:先插前半段,再插后半段,光标夹在中间
  391. _insert(k.insert + (k.after ?? ''), back: (k.after ?? '').length);
  392. case _KeyAction.backspace:
  393. _backspace();
  394. case _KeyAction.clear:
  395. _clear();
  396. case _KeyAction.commit:
  397. _commit();
  398. case null:
  399. _insert(k.insert);
  400. }
  401. },
  402. child: Center(
  403. child: Text(
  404. k.label,
  405. style: TextStyle(
  406. color: fg,
  407. fontSize: k.label.length > 2 ? 13 : 17,
  408. fontFamily: k.label.length > 2 ? null : 'monospace',
  409. ),
  410. ),
  411. ),
  412. ),
  413. );
  414. }
  415. }
  416. enum _KeyAction { insert, backspace, clear, commit }
  417. @immutable
  418. class _Key {
  419. final String label;
  420. /// 插入的文本(模板时是前半段)。
  421. final String insert;
  422. /// 模板的后半段;光标会停在 [insert] 与 [after] 之间。
  423. final String? after;
  424. final _KeyAction? action;
  425. final bool tint;
  426. final bool primary;
  427. const _Key(
  428. this.label, {
  429. this.insert = '',
  430. this.after,
  431. this.action = _KeyAction.insert,
  432. this.tint = false,
  433. this.primary = false,
  434. });
  435. }
  436. /// 函数面板:检索 Symja 的两百多个函数,选中后插入 `Name[]`。
  437. class _FunctionPalette extends StatefulWidget {
  438. const _FunctionPalette();
  439. @override
  440. State<_FunctionPalette> createState() => _FunctionPaletteState();
  441. }
  442. class _FunctionPaletteState extends State<_FunctionPalette> {
  443. final _search = TextEditingController();
  444. String _q = '';
  445. @override
  446. void dispose() {
  447. _search.dispose();
  448. super.dispose();
  449. }
  450. @override
  451. Widget build(BuildContext context) {
  452. final q = _q.trim().toLowerCase();
  453. final fns = kSymjaFunctions
  454. .where((f) => q.isEmpty || f.toLowerCase().contains(q))
  455. .toList(growable: false);
  456. return DraggableScrollableSheet(
  457. expand: false,
  458. initialChildSize: 0.75,
  459. maxChildSize: 0.95,
  460. builder: (context, scroll) => Column(
  461. children: [
  462. Padding(
  463. padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
  464. child: TextField(
  465. controller: _search,
  466. autofocus: true,
  467. decoration: InputDecoration(
  468. isDense: true,
  469. hintText: '函数名(共 ${kSymjaFunctions.length} 个)',
  470. prefixIcon: const Icon(Icons.search),
  471. border: const OutlineInputBorder(),
  472. ),
  473. onChanged: (v) => setState(() => _q = v),
  474. ),
  475. ),
  476. // 常量做成可点的 —— 它们从键盘上挪走了,得有个地方能按。
  477. Padding(
  478. padding: const EdgeInsets.symmetric(horizontal: 16),
  479. child: Wrap(
  480. spacing: 8,
  481. runSpacing: 4,
  482. crossAxisAlignment: WrapCrossAlignment.center,
  483. children: [
  484. Text('常量',
  485. style: Theme.of(context).textTheme.bodySmall),
  486. for (final c in kSymjaConstants)
  487. ActionChip(
  488. label: Text(
  489. c,
  490. style: const TextStyle(
  491. fontFamily: 'monospace', fontSize: 12),
  492. ),
  493. visualDensity: VisualDensity.compact,
  494. materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
  495. onPressed: () =>
  496. Navigator.pop(context, (text: c, back: 0)),
  497. ),
  498. ],
  499. ),
  500. ),
  501. const Divider(height: 16),
  502. Expanded(
  503. child: fns.isEmpty
  504. ? const Center(child: Text('没有匹配的函数'))
  505. : ListView.builder(
  506. controller: scroll,
  507. itemCount: fns.length,
  508. itemBuilder: (_, i) => ListTile(
  509. dense: true,
  510. title: Text(
  511. fns[i],
  512. style: const TextStyle(fontFamily: 'monospace'),
  513. ),
  514. onTap: () => Navigator.pop(
  515. context, (text: '${fns[i]}[]', back: 1)),
  516. ),
  517. ),
  518. ),
  519. ],
  520. ),
  521. );
  522. }
  523. }