| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567 |
- 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<CalculatorPage> createState() => _CalculatorPageState();
- }
- class _CalculatorPageState extends State<CalculatorPage> {
- 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<void> _runPreview() async {
- final expr = _ctrl.text.trim();
- if (expr.isEmpty) {
- if (mounted) setState(() => _preview = null);
- return;
- }
- final session = context.read<SymjaSession>();
- final id = ++_seq;
- final r = await session.preview(expr);
- if (!mounted || id != _seq) return; // 已有更新的预览在路上
- setState(() => _preview = r);
- }
- // ------------------------------------------------------------------ 提交
- Future<void> _commit() async {
- final expr = _ctrl.text.trim();
- if (expr.isEmpty) return;
- _debounce?.cancel();
- final session = context.read<SymjaSession>();
- 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<void> _plotCurrent() async {
- final expr = _ctrl.text.trim();
- if (expr.isEmpty) return;
- final session = context.read<SymjaSession>();
- 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<void>(
- 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<void> _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 = <List<_Key>>[
- [
- _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)),
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
|