import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_math_fork/flutter_math.dart'; import 'package:provider/provider.dart'; import '../symja.dart'; /// 工具页的公共外壳。 /// /// 十五个工具原先各写各的:各自的 Scaffold、各自拼字符串、各自显示一个 /// `Text`。这套外壳把**输入 → 计算 → 输出**固定下来,好处有三个: /// 1. 计算全部走 [Symja],Dart 只负责排版和取输入(绘图除外,见 [ToolChart]); /// 2. 每条输出**单独成块、点一下就复制**,不再是一大坨拼在一起的文本; /// 3. 重置按钮和错误提示不用每个工具再写一遍。 /// /// 刻意不用「抽象基类 + 子类」那套写法:工具之间的差异只有"有哪些输入框"和 /// "算什么表达式"两件事,交给两个参数就够了,继承反而要每个工具建一个类。 class ToolScaffold extends StatefulWidget { const ToolScaffold({ super.key, required this.title, required this.fields, required this.compute, this.intro, this.autoRun = false, }); final String title; /// 打开就自动算一次。绘图和二分法这类**输入框里已经有默认值**的工具用得上: /// 一进来就该看到图/解,而不是先按一下按钮。 final bool autoRun; /// 输入框。顺序即显示顺序。 /// /// 由**页面**持有(建在 State 里,别建在 build 里,否则主题一变控制器就被换掉、 /// 用户打的字会丢)。外壳不 dispose 它们:对称变换那种一个页面里换几组输入框的 /// 工具,控制器要跨模式活着。 final List fields; /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。 final Widget? intro; /// 计算。自己从 [fields] 里取文本、拼 Symja 表达式、调 [SymjaSession.compute]。 /// /// 返回若干条输出:文字的可点复制,图表的不复制(图没什么可复制的)。 final Future> Function(SymjaSession) compute; @override State createState() => _ToolScaffoldState(); } class _ToolScaffoldState extends State { final List _outputs = []; String? _error; bool _busy = false; /// 换了一组输入框(对称变换那种切模式的情况)就把上一次的结果清掉 —— /// 留着上一模式的答案是误导。 @override void didUpdateWidget(ToolScaffold old) { super.didUpdateWidget(old); if (!identical(old.fields, widget.fields)) { _outputs.clear(); _error = null; } } @override void initState() { super.initState(); if (widget.autoRun) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _run(); }); } } Future _run() async { final session = context.read(); setState(() { _busy = true; _error = null; _outputs.clear(); }); try { final out = await widget.compute(session); if (!mounted) return; setState(() => _outputs.addAll(out)); } on SymjaException catch (e) { if (mounted) setState(() => _error = '$e'); } catch (e) { // 输入格式不对(少填了、写了非数字)在这里兜住,别把异常弹到 framework if (mounted) setState(() => _error = '$e'); } finally { if (mounted) setState(() => _busy = false); } } void _reset() { for (final f in widget.fields) { f.controller.clear(); } setState(() { _outputs.clear(); _error = null; }); } /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。 Widget _busyStrip(ColorScheme scheme) => Padding( padding: const EdgeInsets.symmetric(vertical: 10), child: Row( children: [ const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 10), Text('计算中…', style: TextStyle(color: scheme.outline)), ], ), ); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Scaffold( appBar: AppBar(title: Text(widget.title)), body: ListView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), children: [ if (widget.intro != null) ...[ widget.intro!, const SizedBox(height: 12), ], for (final f in widget.fields) f.build(context), const SizedBox(height: 12), Row( children: [ Expanded( child: FilledButton.icon( onPressed: _busy ? null : _run, icon: const Icon(Icons.calculate_outlined), label: const Text('计算'), ), ), const SizedBox(width: 12), OutlinedButton.icon( onPressed: _busy ? null : _reset, icon: const Icon(Icons.refresh), label: const Text('重置'), ), ], ), if (_busy) _busyStrip(scheme), if (_error != null) Padding( padding: const EdgeInsets.only(top: 16), child: Card( color: scheme.errorContainer, child: Padding( padding: const EdgeInsets.all(14), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.error_outline, size: 20, color: scheme.onErrorContainer), const SizedBox(width: 10), Expanded( child: Text( _error!, style: TextStyle(color: scheme.onErrorContainer), ), ), ], ), ), ), ), for (final o in _outputs) Padding( padding: const EdgeInsets.only(top: 12), child: _OutputCard(output: o), ), ], ), ); } } /// 一个输入框。label 同时是发送给 Symja 时的变量含义提示。 class ToolField { ToolField( this.label, { String? hint, this.keyboard = const TextInputType.numberWithOptions( signed: true, decimal: true, ), }) : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔', controller = TextEditingController(); final String label; final String hint; final TextInputType keyboard; final TextEditingController controller; String get text => controller.text.trim(); /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 —— /// 数个数是记账,不是计算,留在 Dart 侧。 int get count => _tokens.length; List get _tokens => text.split(RegExp(r'[\s,,]+')).where((s) => s.isNotEmpty).toList(); /// 空格/逗号分隔的多个值 → Symja 的列表 `{1, 2, 3}`。 /// /// 工具页允许用户直接写 `1/2` 这样的分数和 `Pi` 这样的常量 —— 它们交给 Symja /// 解析,Dart 侧不做数字转换(转换反而会把 `1/2` 变成 0.5 这种近似值)。 String get list => '{${_tokens.join(', ')}}'; Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: TextField( controller: controller, keyboardType: keyboard, decoration: InputDecoration( labelText: label, hintText: hint, border: const OutlineInputBorder(), isDense: true, ), ), ); } /// 一条输出。[ToolText] 可点复制,[ToolChart] 不行 —— 图没有可复制的文本形态。 sealed class ToolOutput { const ToolOutput(this.label); final String label; } /// 一条文字输出。[tex] 不为空时渲染成数学排版,点复制复制的仍是纯文本。 class ToolText extends ToolOutput { const ToolText(super.label, this.text, {this.tex}); final String text; final String? tex; } /// 一条图表输出。**图表不参与复制**(任务里明确要求的)。 class ToolChart extends ToolOutput { const ToolChart(super.label, this.chart); final Widget chart; } class _OutputCard extends StatelessWidget { const _OutputCard({required this.output}); final ToolOutput output; Future _copy(BuildContext context, String text) async { await Clipboard.setData(ClipboardData(text: text)); if (!context.mounted) return; ScaffoldMessenger.of(context) ..clearSnackBars() ..showSnackBar( SnackBar( content: Text('已复制:${text.length > 30 ? '${text.substring(0, 30)}…' : text}'), duration: const Duration(milliseconds: 1200), ), ); } @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; final o = output; if (o is ToolChart) { return Card( child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(o.label, style: Theme.of(context).textTheme.labelLarge), const SizedBox(height: 8), o.chart, ], ), ), ); } final t = o as ToolText; return Card( clipBehavior: Clip.antiAlias, child: InkWell( onTap: () => _copy(context, t.text), child: Padding( padding: const EdgeInsets.all(14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( t.label, style: TextStyle(fontSize: 12, color: scheme.outline), ), const SizedBox(height: 6), // 有 TeX 就用数学排版显示;太长或没生成就退回纯文本 if (t.tex != null) Math.tex( t.tex!, mathStyle: MathStyle.text, textStyle: TextStyle( fontSize: 20, color: scheme.onSurface, ), onErrorFallback: (_) => _plain(t.text), ) else _plain(t.text), ], ), ), const SizedBox(width: 8), Icon(Icons.copy_outlined, size: 18, color: scheme.outline), ], ), ), ), ); } /// 纯文本渲染。刻意用 `Text` 而不是 `SelectableText` —— 后者会把点击吃掉拿去 /// 做选区,整张卡片的"点一下复制"就时灵时不灵了。 Widget _plain(String text) => Text( text, style: const TextStyle(fontFamily: 'monospace', fontSize: 16), ); } /// 精确值 + 近似值两行输出。 /// /// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。现在精确值由 Symja /// 原样给出,近似值再问一次 `N[...]`。**两者相等时只留一行** —— 否则每个整数 /// 结果都会多出一条一模一样的"≈"。 Future> exactAndApprox( SymjaSession session, String label, String expr, ) async { final exact = await session.compute(expr, applyMode: false); final approx = await session.compute('N[$expr, 10]', applyMode: false); final same = approx.text == exact.text; return [ ToolText(label, exact.text, tex: exact.tex), if (!same) ToolText('$label(近似)', approx.text, tex: approx.tex), ]; } /// 纯数值输出:只给近似值(用户要的本来就是个数,不是分数)。 Future numeric( SymjaSession session, String label, String expr, ) async { final r = await session.compute('N[$expr, 12]', applyMode: false); return ToolText(label, r.text, tex: null); } /// 把 Symja 输出的列表文本拆成嵌套的字符串列表。 /// /// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示, /// 而通道里回来的是 `{{0, 1/6}, {1, 1/3}}` 这样一个字符串。刻意**不**做数字解析: /// 拆出来的还是 Symja 的原文(`1/6` 就是 `1/6`),要算数再交回给 Symja。 List parseSymjaList(String s) { final t = s.trim(); if (!t.startsWith('{') || !t.endsWith('}')) return [t]; final out = []; final buf = StringBuffer(); var depth = 0; for (var i = 1; i < t.length - 1; i++) { final c = t[i]; if (c == '{' || c == '[' || c == '(') depth++; if (c == '}' || c == ']' || c == ')') depth--; if (c == ',' && depth == 0) { out.add(_item(buf.toString())); buf.clear(); continue; } buf.write(c); } if (buf.isNotEmpty) out.add(_item(buf.toString())); return out; } Object _item(String raw) { final t = raw.trim(); return t.startsWith('{') ? parseSymjaList(t) : t; } /// 取出列表里的数字(Symja 的近似值带一个 `` ` `` 精度标记,要去掉)。 /// /// 只给**画图**用 —— 图表要的是 double,而"精确值"那条路始终走 Symja 原文。 double? asNumber(Object? item) { if (item is! String) return null; return double.tryParse(item.replaceAll('`', '').trim()); }