import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:provider/provider.dart'; import 'expr_editor.dart'; import 'expr_node.dart'; import 'expr_parse.dart'; import 'settings.dart'; import 'symja.dart'; import 'symja_functions.dart'; /// 科学计算器(默认主界面)。 /// /// 输入用**结构化节点树**(见 lib/expr_node.dart),不是字符串: /// 按键把节点插进当前光标槽,分数竖排、指数上标、多参数函数各有自己的槽。 /// 纯文本输入留给控制台。 class CalculatorPage extends StatefulWidget { const CalculatorPage({super.key}); @override State createState() => _CalculatorPageState(); } class _CalculatorPageState extends State { final _doc = ExprDoc(); SymjaResult? _preview; SymjaResult? _committed; String? _error; Timer? _debounce; int _seq = 0; /// 用户点了"光标已经在的地方"—— 显示粘贴入口。 bool _pastePrompt = false; @override void dispose() { _debounce?.cancel(); super.dispose(); } // ------------------------------------------------------------------ 求值 /// 编辑器每次变化都走这里:清掉上次的提交态,防抖后重算预览。 void _onDocChanged() { setState(() { _committed = null; _error = null; _pastePrompt = false; // 动过内容/光标就收起操作栏 }); _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 500), _runPreview); } String get _source => _doc.toSymja(); void _snack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar(content: Text(msg))); } Future _copy(String text, String what) async { await Clipboard.setData(ClipboardData(text: text)); _snack('已复制$what:$text'); } /// 从剪贴板读一段 Symja 表达式,解析成节点树后整体替换。 /// /// 解析器只认一个明确的子集,**认不出来就报错**,不会猜着解析 —— /// 猜错了你会看到算错的结果,那比报错糟糕得多。 Future _paste() async { final data = await Clipboard.getData(Clipboard.kTextPlain); final text = (data?.text ?? '').trim(); if (text.isEmpty) { _snack('剪贴板里没有文本'); return; } try { final parsed = parseSymjaExpr(text); setState(() { _pastePrompt = false; if (_doc.selection != null) _doc.deleteSelection(); // 有选区就替换掉 _doc.insertNodesAtCursor(parsed.children); // 否则插到光标处 _preview = null; _committed = null; _error = null; }); _onDocChanged(); _snack('已粘贴到光标处:${_doc.toSymja()}'); } on ExprParseException catch (e) { _snack('这段表达式没法转成按钮式输入 —— $e'); } } // ------------------------------------------------------------------ 选区操作 /// 操作栏上的图标按钮。压扁一点,别把这条栏撑高。 Widget _barButton(IconData icon, String tip, VoidCallback onTap) => IconButton( tooltip: tip, icon: Icon(icon, size: 20), visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), constraints: const BoxConstraints(minWidth: 36, minHeight: 36), onPressed: onTap, ); Future _copySelection() async { final t = _doc.selectionText; if (t.isEmpty) return; await _copy(t, '选中内容'); setState(() => _doc.clearSelection()); } Future _cutSelection() async { final t = _doc.selectionText; if (t.isEmpty) return; await Clipboard.setData(ClipboardData(text: t)); setState(() { _doc.deleteSelection(); _pastePrompt = false; }); _onDocChanged(); _snack('已剪切:$t'); } void _dismissBar() { setState(() { _pastePrompt = false; _doc.clearSelection(); }); } /// 选中部分内容后自动出现的操作栏;或点了光标处时的粘贴入口。 Widget? _contextBar() { final sel = _doc.selection; final hasSel = sel != null && !sel.isEmpty; if (!hasSel && !_pastePrompt) return null; final scheme = Theme.of(context).colorScheme; return Material( color: scheme.surfaceContainerHighest, child: Padding( padding: const EdgeInsets.only(left: 12, right: 4), child: Row( children: [ Expanded( child: Text( hasSel ? '已选中 ${_doc.selectionText}' : '粘贴到光标处', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontFamily: 'monospace', fontSize: 12), ), ), // 用图标而不是文字按钮 —— 文字太占地方,一条栏塞不下几个 if (hasSel) ...[ _barButton(Icons.copy, '复制', _copySelection), _barButton(Icons.cut, '剪切', _cutSelection), ] else _barButton(Icons.paste, '粘贴', _paste), _barButton(Icons.close, '取消选择', _dismissBar), ], ), ), ); } Future _runPreview() async { final expr = _source; if (expr.isEmpty || expr == 'Null') { if (mounted) setState(() => _preview = null); return; } final session = context.read(); final id = ++_seq; final r = await session.preview(expr); if (!mounted || id != _seq) return; // 已有更新的预览在路上 setState(() => _preview = r); } Future _commit() async { final expr = _source; if (expr.isEmpty || expr == 'Null') return; _debounce?.cancel(); final session = context.read(); final id = ++_seq; // 让在途的预览作废 try { final r = await session.evaluate(expr); if (!mounted || id != _seq) return; setState(() { _committed = r; _preview = null; _error = null; }); } on SymjaException catch (e) { if (!mounted || id != _seq) return; setState(() { _error = '$e'; _committed = null; _preview = null; }); } await session.refreshVariables(); } // ------------------------------------------------------------------ 按键 /// ⇧ 层是否开着。按一次只生效一次(跟计算器上的 shift 一样)。 bool _shift = false; void _press(_Key raw) { // shift 键本身:切一下层,不往表达式里写东西 if (raw.act == _Act.shift) { setState(() => _shift = !_shift); return; } // 开着 shift 且这个键有 shift 层:按 shift 层那个动作,然后复位 final k = (_shift && raw.shifted != null) ? raw.shifted! : raw; if (_shift) setState(() => _shift = false); switch (k.act) { case _Act.shift: return; // 上面已经处理过,这里只是让 switch 穷尽 case _Act.char: if (k.mergeDigit) { // 数字键并入左边那个数字字面量,`42` 才是一个数而不是两个节点 _doc.insertChar(k.payload); } else { _doc.insert(LitNode(k.payload, display: k.display)); } case _Act.fn: _doc.makeFunction(k.payload); case _Act.fnPrefixed: // 像 D 这种,第一个参数留空给用户填,其余预置好 _doc.makeFunction(k.payload, prefill: k.prefill); case _Act.bigOp: // ∫ / Σ / lim:上下限单独成槽,才画得到习惯位置 final p = k.prefill; _doc.makeBigOp( k.payload, variable: p.isNotEmpty ? p[0] : 'x', lower: p.length > 1 ? p[1] : '', upper: p.length > 2 ? p[2] : '', ); case _Act.sqrt: _doc.makeFunction('Sqrt'); case _Act.group: _doc.makeGroup(); case _Act.power: _doc.makePower(); case _Act.subst: // 代入骨架:`/. x -> `,光标落在最后,接着打要代进去的值就行。 // 变量是 x 的占多数;要代 t/y 就点一下那个 x 改掉。 _doc.insert(LitNode('/. ')); _doc.insert(LitNode('x')); _doc.insert(LitNode(' -> ')); case _Act.brace: _doc.makeBrace(); case _Act.frac: _doc.makeFraction(); case _Act.postfix: // 阶乘是后缀:把光标左边那个节点收进来当操作数 final operand = _doc.takeLeft(); _doc.insert(PostfixNode( operand: SeqNode(operand == null ? null : [operand]), )); case _Act.comma: // 在函数参数里 → 切到下一个参数;在花括号里(矩阵行)→ 插一个逗号 if (!_doc.nextArgument()) _doc.insert(LitNode(',')); case _Act.back: _doc.backspace(); case _Act.clear: _doc.clear(); case _Act.commit: _commit(); return; // 提交不改文档,不用刷新 } _onDocChanged(); } Future _showPalette() async { final picked = await showModalBottomSheet<({String value, bool isFn})>( context: context, isScrollControlled: true, showDragHandle: true, builder: (_) => const _FunctionPalette(), ); if (picked == null) return; if (picked.isFn) { _doc.makeFunction(picked.value); } else { _doc.insert(LitNode(picked.value)); } _onDocChanged(); } // ------------------------------------------------------------------ UI @override Widget build(BuildContext context) { final session = context.watch(); final bar = _contextBar(); return Scaffold( appBar: AppBar( // 不显示标题、顶栏压扁 —— 省下来的竖向空间给键盘 toolbarHeight: 44, actions: [ // 精确 / 近似 切换。默认精确:`1/3` 就是 `1/3`,这是符号计算的意义所在。 TextButton( onPressed: () { session.approximate = !session.approximate; _onDocChanged(); // 立刻按新模式重算预览 }, child: Text( session.approximate ? '≈ 近似' : '精确', style: TextStyle( fontFamily: 'monospace', fontSize: 13, color: Theme.of(context).colorScheme.primary, ), ), ), IconButton( tooltip: '函数面板', icon: const Icon(Icons.functions), onPressed: _showPalette, ), ], ), body: SafeArea( child: Column( children: [ _display(context), ?bar, const Divider(height: 1), Expanded(child: _keypad(context)), ], ), ), ); } Widget _display(BuildContext context) { final scheme = Theme.of(context).colorScheme; final shown = _committed ?? _preview; return Container( width: double.infinity, constraints: const BoxConstraints(minHeight: 132), padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), color: scheme.surfaceContainerLow, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ // 固定高度 + 超高时纵向滚动。 // 不固定的话,表达式一变高(分式、Σ 带上下限)编辑区就撑大, // 下面的键盘整体往下跳,手指刚瞄好的键就换位置了。 SizedBox( height: 104, child: SingleChildScrollView( scrollDirection: Axis.vertical, reverse: true, // 内容超高时把底部(光标那侧)留在视野里 child: ExprEditor( doc: _doc, onChanged: _onDocChanged, onCaretTapped: () => setState(() => _pastePrompt = true), ), ), ), const SizedBox(height: 6), // 常显。它就是真正送进引擎的那串文本(也是"复制表达式"复制走的内容), // 空槽会写成 Null、隐含括号也会露出来。 Text( _source, textAlign: TextAlign.right, maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( fontFamily: 'monospace', fontSize: 11, color: scheme.outline, ), ), const SizedBox(height: 6), SizedBox( height: 52, width: double.infinity, child: GestureDetector( key: const ValueKey('calc-result'), // 点结果 = 复制结果 onTap: shown == null ? null : () => _copy(shown.text, '结果'), behavior: HitTestBehavior.opaque, child: FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerRight, child: _resultView(context, shown), ), ), ), ], ), ); } Widget _resultView(BuildContext context, SymjaResult? shown) { final scheme = Theme.of(context).colorScheme; if (_error != null) { return SelectableText( _error!, style: TextStyle( fontFamily: 'monospace', fontSize: 13, color: scheme.error, ), ); } if (shown == null) return const SizedBox.shrink(); // 用 Text 而不是 SelectableText:点结果是"复制",选中文字反而挡住点击 final plain = Text( shown.text, style: const TextStyle(fontFamily: 'monospace', fontSize: 22), ); final tex = shown.tex; final body = tex == null ? plain : Math.tex( tex, mathStyle: MathStyle.display, textStyle: TextStyle(fontSize: 26, color: scheme.onSurface), onErrorFallback: (_) => plain, ); return Row( mainAxisSize: MainAxisSize.min, children: [ if (_committed != null) ...[ Icon(Icons.check_circle, size: 16, color: scheme.primary), const SizedBox(width: 6), Text('${_committed!.elapsedMs} ms', style: TextStyle(fontSize: 11, color: scheme.outline)), const SizedBox(width: 10), ], body, ], ); } Widget _keypad(BuildContext context) { return LayoutBuilder( builder: (context, box) { final rows = >[ // 1 大运算符。∫ 收在 d/dx 的 shift 里 —— 求和/求积一层、求导/求积分一层, // 同族的放一起,比原来"∫ 自己一个键、shift 却是 ∏"顺。 [ _Key('Σ', _Act.bigOp, payload: 'Sum', prefill: ['n', '1', 'Infinity'], shifted: _Key('∏', _Act.bigOp, payload: 'Product', prefill: ['n', '1', 'Infinity'])), _Key('d/dx', _Act.fnPrefixed, payload: 'D', prefill: ['', 'x'], shifted: _Key('∫', _Act.bigOp, payload: 'Integrate', prefill: ['x'])), _Key('lim', _Act.bigOp, payload: 'Limit', prefill: ['x', '0']), _Key('solve', _Act.fnPrefixed, payload: 'Solve', prefill: ['', 'x'], shifted: _Key('nsolve', _Act.fnPrefixed, payload: 'NSolve', prefill: ['', 'x'])), _Key('√', _Act.sqrt, shifted: _Key('|x|', _Act.fn, payload: 'Abs')), ], // 2 函数。! 也放这排(它本来就是个函数),shift 是组合数 [ _Key('sin', _Act.fn, payload: 'Sin', shifted: _Key('asin', _Act.fn, payload: 'ArcSin')), _Key('cos', _Act.fn, payload: 'Cos', shifted: _Key('acos', _Act.fn, payload: 'ArcCos')), _Key('tan', _Act.fn, payload: 'Tan', shifted: _Key('atan', _Act.fn, payload: 'ArcTan')), _Key('ln', _Act.fn, payload: 'Log', shifted: _Key('lg', _Act.fn, payload: 'Log10')), // 组合数直接显示,shift 才是排列数 _Key('C', _Act.fnPrefixed, payload: 'Binomial', prefill: ['', ''], shifted: _Key('P', _Act.fnPrefixed, payload: 'FactorialPower', prefill: ['', ''])), ], // 3 变量。原来那排运算符(赋值、等号、代入、逗号、矩阵)收进它们的 shift, // 于是整排省掉一个 —— 键盘从 9 排压到 8 排,键也更高。 [ _Key('x', _Act.char, payload: 'x', mathVar: true, shifted: _Key('=', _Act.char, payload: '=', tint: true)), _Key('y', _Act.char, payload: 'y', mathVar: true, shifted: _Key('==', _Act.char, payload: '==', tint: true)), _Key('a', _Act.char, payload: 'a', mathVar: true, shifted: _Key('/.→', _Act.subst, tint: true)), _Key('t', _Act.char, payload: 't', mathVar: true, shifted: _Key(',', _Act.comma)), _Key('n', _Act.char, payload: 'n', mathVar: true, shifted: _Key('{', _Act.brace, tint: true)), ], // 4 数字与括号 [ _Key('7', _Act.char, payload: '7', mergeDigit: true), _Key('8', _Act.char, payload: '8', mergeDigit: true), _Key('9', _Act.char, payload: '9', mergeDigit: true), // 阶乘收进括号的 shift(用得比组合数少) _Key('(', _Act.group, shifted: _Key('!', _Act.postfix)), _Key('i', _Act.char, payload: 'I', tint: true, display: 'i'), ], [ _Key('4', _Act.char, payload: '4', mergeDigit: true), _Key('5', _Act.char, payload: '5', mergeDigit: true), _Key('6', _Act.char, payload: '6', mergeDigit: true), _Key('×', _Act.char, payload: '*'), _Key('/', _Act.frac), ], [ _Key('1', _Act.char, payload: '1', mergeDigit: true), _Key('2', _Act.char, payload: '2', mergeDigit: true), _Key('3', _Act.char, payload: '3', mergeDigit: true), _Key('+', _Act.char, payload: '+'), _Key('−', _Act.char, payload: '-'), ], [ _Key('0', _Act.char, payload: '0', mergeDigit: true), _Key('.', _Act.char, payload: '.', mergeDigit: true), _Key('^', _Act.power), // 常量。编辑框显示 π / e,送进引擎的是 Pi / E(和 ANS 同一套机制) _Key('e', _Act.char, payload: 'E', tint: true, display: 'e'), _Key('π', _Act.char, payload: 'Pi', tint: true, display: 'π'), ], [ // ⇧ 开着的时候这个键是深色的 —— 一眼能看出"现在是 shift 层" _Key('⇧', _Act.shift, tint: true, primary: _shift), _Key('⌫', _Act.back, tint: true), _Key('CLR', _Act.clear, tint: true), _Key('ANS', _Act.char, payload: '%', tint: true, display: 'ANS'), // 键盘负责"敲字符",✓ 负责"执行"。两者都叫 = 会撞车。 _Key('✓', _Act.commit, primary: true), ], ]; // 行数**按实际排数算**(改键盘时加减一排不用回来改这里 —— // 之前写死 9,缩到 8 排之后底下就空出一条)。 // box.maxHeight 是 padding **之前**的高度,漏掉会溢出。 final h = (box.maxHeight - 12 - (rows.length - 1) * 6) / rows.length; return Padding( padding: const EdgeInsets.all(6), child: Column( children: [ for (var r = 0; r < rows.length; r++) ...[ if (r > 0) const SizedBox(height: 6), SizedBox( height: h, child: Row( children: [ for (var c = 0; c < rows[r].length; c++) ...[ if (c > 0) const SizedBox(width: 6), Expanded(child: _keyButton(rows[r][c])), ], ], ), ), ], ], ), ); }, ); } Widget _keyButton(_Key raw) { // shift 开着就整体换成 shift 层:有 shift 层的键显示备用功能, // 没有的保持原样。这样"按一下看看有什么"就能发现 shift 层。 final k = (_shift && raw.shifted != null) ? raw.shifted! : raw; final scheme = Theme.of(context).colorScheme; // E-Ink 下按键的底色是白的(配色压成了白底黑字),跟背景一个颜色就看不见了, // 所以改成**描边**区分:功能键/提交键一圈黑,数字键一圈浅灰。 final eInk = context.watch().eInk; final bg = eInk ? (k.primary ? Colors.black : Colors.white) : k.primary ? scheme.primary : k.tint ? scheme.surfaceContainerHighest : scheme.surfaceContainerLow; final fg = eInk ? (k.primary ? Colors.white : Colors.black) : (k.primary ? scheme.onPrimary : scheme.onSurface); return Material( // 加 key 是为了让测试能精确定位按键 —— 编辑器里渲染出的字面量(比如 'x') // 和键盘上同名的按键都符合 find.text,会撞车。 key: ValueKey('key-${k.label}'), color: bg, shape: eInk ? RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide( color: k.tint || k.primary ? Colors.black : const Color(0xFF9E9E9E), ), ) : null, borderRadius: eInk ? null : BorderRadius.circular(10), child: InkWell( borderRadius: BorderRadius.circular(10), onTap: () => _press(k), child: Center( child: Text( k.label, style: TextStyle( color: fg, fontSize: k.label.length > 2 ? 13 : (k.mathVar ? 20 : 17), fontFamily: k.mathVar ? 'serif' : (k.label.length > 2 ? null : 'monospace'), fontStyle: k.mathVar ? FontStyle.italic : FontStyle.normal, ), ), ), ), ); } } enum _Act { char, shift, fn, fnPrefixed, bigOp, subst, brace, sqrt, group, power, frac, postfix, comma, back, clear, commit, } @immutable class _Key { final String label; final _Act act; final String payload; /// fnPrefixed 用:第一个参数留空给用户填,其余的预置。 final List prefill; /// 数字键:并入左边那个数字字面量,而不是各建一个节点。 final bool mergeDigit; /// 编辑框里显示的名字(和真正送出去的 payload 不同时用)。 final String? display; /// 是不是"数学变量"。变量键的标签用**斜体衬线**,和运算符区分开 —— /// 等宽字体里 `x`(变量)和 `×`(乘号)几乎一模一样,用户实测反馈过。 final bool mathVar; final bool tint; final bool primary; /// 按了 ⇧ 之后这个键变成什么。null = 这个键没有 shift 层。 /// /// 用"整个备用键"而不是几个零散字段:shift 前后的动作类型本来就可能不同 /// (`√` 是 sqrt、shift 之后是普通函数 `Abs`),嵌套一个 _Key 最省事。 final _Key? shifted; const _Key( this.label, this.act, { this.payload = '', this.prefill = const [], this.mergeDigit = false, this.display, this.mathVar = false, this.tint = false, this.primary = false, this.shifted, }); } /// 函数面板:检索 Symja 的两百多个函数名,选中后插入一个函数节点。 class _FunctionPalette extends StatefulWidget { const _FunctionPalette(); @override State<_FunctionPalette> createState() => _FunctionPaletteState(); } class _FunctionPaletteState extends State<_FunctionPalette> { final _search = TextEditingController(); String _q = ''; /// 名字按大小写不敏感匹配;中文只出现在说明里,所以说明也拿来当关键词 —— /// 想找方差的人不该非得记住那个词叫 `Variance`。 static bool _matches(String fn, String q) { if (fn.toLowerCase().contains(q.toLowerCase())) return true; final help = kSymjaFunctionHelp[fn]; return help != null && help.contains(q); } @override void dispose() { _search.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final q = _q.trim(); final fns = kSymjaFunctions .where((f) => q.isEmpty || _matches(f, q)) .toList(growable: false); return DraggableScrollableSheet( expand: false, initialChildSize: 0.75, maxChildSize: 0.95, builder: (context, scroll) => Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: TextField( controller: _search, autofocus: true, decoration: InputDecoration( isDense: true, hintText: '函数名或中文说明(共 ${kSymjaFunctions.length} 个)', prefixIcon: const Icon(Icons.search), border: const OutlineInputBorder(), ), onChanged: (v) => setState(() => _q = v), ), ), // 常量做成可点的 —— 它们从键盘上挪走了,得有个地方能按。 Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Wrap( spacing: 8, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ Text('常量', style: Theme.of(context).textTheme.bodySmall), for (final c in kSymjaConstants) ActionChip( label: Text( c, style: const TextStyle( fontFamily: 'monospace', fontSize: 12), ), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, onPressed: () => Navigator.pop(context, (value: c, isFn: false)), ), ], ), ), const Divider(height: 16), Expanded( child: fns.isEmpty ? const Center(child: Text('没有匹配的函数')) : ListView.builder( controller: scroll, itemCount: fns.length, itemBuilder: (_, i) => ListTile( dense: true, title: Text( fns[i], style: const TextStyle(fontFamily: 'monospace'), ), // 一句中文说明,挂名字后面 subtitle: Text( kSymjaFunctionHelp[fns[i]] ?? '', style: const TextStyle(fontSize: 12), ), onTap: () => Navigator.pop( context, (value: fns[i], isFn: true)), ), ), ), ], ), ); } }