import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:provider/provider.dart'; import 'curve_plot.dart'; import 'symja.dart'; import 'symja_functions.dart'; /// 科学计算器(默认主界面)。 /// /// 表达式用**字符串**表示,按键在光标处插入 —— 这是 Q28b 定的第一阶段。 /// 结构化节点输入(节点树 / 槽 / 选区)是后续独立的升级,但下游接口不变: /// 求值、历史、双界面看到的始终是一个表达式字符串。 class CalculatorPage extends StatefulWidget { const CalculatorPage({super.key}); @override State createState() => _CalculatorPageState(); } class _CalculatorPageState extends State { final _ctrl = TextEditingController(); final _focus = FocusNode(); /// 边输入边算的结果(不算历史、不动 `%`)。 SymjaResult? _preview; /// 按 = 提交后的结果。 SymjaResult? _committed; String? _error; Timer? _debounce; int _seq = 0; @override void dispose() { _debounce?.cancel(); _ctrl.dispose(); _focus.dispose(); super.dispose(); } // ------------------------------------------------------------------ 表达式编辑 /// 在光标处插入 [text],并把光标往回退 [back] 位(用于 `Integrate[□, x]` 这类模板)。 void _insert(String text, {int back = 0}) { final v = _ctrl.value; final sel = v.selection; final start = sel.isValid ? sel.start : v.text.length; final end = sel.isValid ? sel.end : v.text.length; final newText = v.text.replaceRange(start, end, text); _ctrl.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start + text.length - back), ); _onChanged(); } void _backspace() { final v = _ctrl.value; final sel = v.selection; final start = sel.isValid ? sel.start : v.text.length; final end = sel.isValid ? sel.end : v.text.length; if (start == end && start == 0) return; final from = start == end ? start - 1 : start; final newText = v.text.replaceRange(from, end, ''); _ctrl.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: from), ); _onChanged(); } void _clear() { _ctrl.clear(); setState(() { _preview = null; _committed = null; _error = null; }); } void _onChanged() { setState(() { _committed = null; _error = null; }); _debounce?.cancel(); // 防抖:按键连打时不要每个字符都过一次通道 _debounce = Timer(const Duration(milliseconds: 500), _runPreview); } Future _runPreview() async { final expr = _ctrl.text.trim(); if (expr.isEmpty) { 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 = _ctrl.text.trim(); if (expr.isEmpty) 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(); } /// 把当前表达式在 x ∈ [-6.3, 6.3] 上采样并画出来。 Future _plotCurrent() async { final expr = _ctrl.text.trim(); if (expr.isEmpty) return; final session = context.read(); final messenger = ScaffoldMessenger.of(context); try { final s = await session.sample(expr, from: -6.3, to: 6.3, count: 240); if (!mounted) return; await showDialog( context: context, builder: (_) => AlertDialog( title: Text('$expr (${s.ys.length} 点,${s.elapsedMs} ms)'), content: SizedBox( width: 360, height: 280, child: CurvePlot(series: [ CurveSeries( label: expr, color: const Color(0xFF3F51B5), points: [ for (var i = 0; i < s.ys.length; i++) Offset(s.xAt(i), s.ys[i]), ], ), ]), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('关闭'), ), ], ), ); } on SymjaException catch (e) { messenger.showSnackBar(SnackBar(content: Text('$e'))); } } Future _showPalette() async { // 面板返回「要插入什么」+「光标从末尾回退几位」: // 函数是 `Sin[]` 退 1(光标在括号里),常量是 `Pi` 退 0。 final picked = await showModalBottomSheet<({String text, int back})>( context: context, isScrollControlled: true, showDragHandle: true, builder: (_) => const _FunctionPalette(), ); if (picked != null) _insert(picked.text, back: picked.back); } // ------------------------------------------------------------------ UI @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('计算器'), actions: [ IconButton( tooltip: '画图(x ∈ [-6.3, 6.3])', icon: const Icon(Icons.show_chart), onPressed: _plotCurrent, ), IconButton( tooltip: '函数面板', icon: const Icon(Icons.functions), onPressed: _showPalette, ), IconButton( tooltip: '清空', icon: const Icon(Icons.refresh), onPressed: _clear, ), ], ), body: SafeArea( child: Column( children: [ _display(context), 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, padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), color: scheme.surfaceContainerLow, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ TextField( controller: _ctrl, focusNode: _focus, maxLines: 3, minLines: 1, textAlign: TextAlign.right, style: const TextStyle(fontFamily: 'monospace', fontSize: 18), decoration: const InputDecoration( isDense: true, border: InputBorder.none, hintText: '按下面的键,或点这里用键盘输入', ), onChanged: (_) => _onChanged(), onSubmitted: (_) => _commit(), ), const SizedBox(height: 8), SizedBox( height: 56, child: Align( alignment: Alignment.centerRight, child: SingleChildScrollView( scrollDirection: Axis.horizontal, reverse: true, 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 Text( _ctrl.text.trim().isEmpty ? '' : '…', style: TextStyle(fontSize: 22, color: scheme.outline), ); } final plain = SelectableText( 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) { const rows = >[ [ _Key('∫', insert: 'Integrate[', after: ', x]'), _Key('Σ', insert: 'Sum[', after: ', {n, 1, Infinity}]'), _Key('d/dx', insert: 'D[', after: ', x]'), _Key('lim', insert: 'Limit[', after: ', x->0]'), _Key('solve', insert: 'Solve[', after: ', x]'), ], [ _Key('sin', insert: 'Sin[', after: ']'), _Key('cos', insert: 'Cos[', after: ']'), _Key('tan', insert: 'Tan[', after: ']'), _Key('ln', insert: 'Log[', after: ']'), _Key('√', insert: 'Sqrt[', after: ']'), ], [ _Key('7', insert: '7'), _Key('8', insert: '8'), _Key('9', insert: '9'), _Key('(', insert: '('), _Key(')', insert: ')'), ], [ _Key('4', insert: '4'), _Key('5', insert: '5'), _Key('6', insert: '6'), _Key('×', insert: '*'), _Key('÷', insert: '/'), ], [ _Key('1', insert: '1'), _Key('2', insert: '2'), _Key('3', insert: '3'), _Key('+', insert: '+'), _Key('−', insert: '-'), ], [ _Key('0', insert: '0'), _Key('.', insert: '.'), _Key('^', insert: '^'), // 变量键:没有它们就没法只用按钮写出 Integrate[x^2, x]。 // π / e 挪进了函数面板(那里的常量是可以点的)。 _Key('x', insert: 'x'), _Key('n', insert: 'n'), ], [ _Key('⌫', action: _KeyAction.backspace, tint: true), _Key('CLR', action: _KeyAction.clear, tint: true), _Key(',', insert: ', '), _Key('ANS', insert: '%', tint: true), _Key('=', action: _KeyAction.commit, primary: true), ], ]; // 7 行 + 6 个 6px 间隙,再扣掉 Padding 的上下各 6px —— // box.maxHeight 是 padding **之前**的高度,漏掉这一项会溢出 12px。 const gaps = 6 * 6.0; const padding = 12.0; final h = (box.maxHeight - padding - gaps) / 7; 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 k) { final scheme = Theme.of(context).colorScheme; final bg = k.primary ? scheme.primary : k.tint ? scheme.surfaceContainerHighest : scheme.surfaceContainerLow; final fg = k.primary ? scheme.onPrimary : scheme.onSurface; return Material( color: bg, borderRadius: BorderRadius.circular(10), child: InkWell( borderRadius: BorderRadius.circular(10), onTap: () { switch (k.action) { case _KeyAction.insert: // 模板形如 Insert[□, x]:先插前半段,再插后半段,光标夹在中间 _insert(k.insert + (k.after ?? ''), back: (k.after ?? '').length); case _KeyAction.backspace: _backspace(); case _KeyAction.clear: _clear(); case _KeyAction.commit: _commit(); case null: _insert(k.insert); } }, child: Center( child: Text( k.label, style: TextStyle( color: fg, fontSize: k.label.length > 2 ? 13 : 17, fontFamily: k.label.length > 2 ? null : 'monospace', ), ), ), ), ); } } enum _KeyAction { insert, backspace, clear, commit } @immutable class _Key { final String label; /// 插入的文本(模板时是前半段)。 final String insert; /// 模板的后半段;光标会停在 [insert] 与 [after] 之间。 final String? after; final _KeyAction? action; final bool tint; final bool primary; const _Key( this.label, { this.insert = '', this.after, this.action = _KeyAction.insert, this.tint = false, this.primary = false, }); } /// 函数面板:检索 Symja 的两百多个函数,选中后插入 `Name[]`。 class _FunctionPalette extends StatefulWidget { const _FunctionPalette(); @override State<_FunctionPalette> createState() => _FunctionPaletteState(); } class _FunctionPaletteState extends State<_FunctionPalette> { final _search = TextEditingController(); String _q = ''; @override void dispose() { _search.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final q = _q.trim().toLowerCase(); final fns = kSymjaFunctions .where((f) => q.isEmpty || f.toLowerCase().contains(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, (text: c, back: 0)), ), ], ), ), 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'), ), onTap: () => Navigator.pop( context, (text: '${fns[i]}[]', back: 1)), ), ), ), ], ), ); } }