| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498 |
- 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<ToolField> fields;
- /// 工具自己的说明(公式长什么样、参数什么含义),显示在输入框上方。
- final Widget? intro;
- /// 计算。自己从 [fields] 里取文本、拼 Symja 表达式、调 [SymjaSession.compute]。
- ///
- /// 返回若干条输出:文字的可点复制,图表的不复制(图没什么可复制的)。
- final Future<List<ToolOutput>> Function(SymjaSession) compute;
- @override
- State<ToolScaffold> createState() => _ToolScaffoldState();
- }
- class _ToolScaffoldState extends State<ToolScaffold> {
- final List<ToolOutput> _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<void> _run() async {
- final session = context.read<SymjaSession>();
- 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;
- });
- }
- /// 把输入框排成一列,遇到标了 [ToolField.rowWithPrevious] 的就跟前一个并排。
- List<Widget> _fieldRows(BuildContext context) {
- final rows = <Widget>[];
- var group = <ToolField>[];
- void flush() {
- if (group.isEmpty) return;
- if (group.length == 1) {
- rows.add(group.first.build(context));
- } else {
- rows.add(
- Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- for (var i = 0; i < group.length; i++) ...[
- if (i > 0) const SizedBox(width: 10),
- Expanded(child: group[i].build(context)),
- ],
- ],
- ),
- );
- }
- group = <ToolField>[];
- }
- for (final f in widget.fields) {
- if (!f.rowWithPrevious) flush();
- group.add(f);
- }
- flush();
- return rows;
- }
- /// 计算中:给个可见的反馈,复杂表达式可能要算好几秒。
- 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),
- ],
- ..._fieldRows(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,
- ),
- this.rowWithPrevious = false,
- }) : hint = hint ?? '输入分数如 1/2,或多个值用空格分隔',
- controller = TextEditingController();
- final String label;
- final String hint;
- final TextInputType keyboard;
- final TextEditingController controller;
- /// 和**上一个**输入框并排放在同一行(宽度平分)。
- ///
- /// 成对出现的东西不该各占一行 —— 卷积的「取值 i / 概率 i」、
- /// 卡方检验的 2×2 列联表都是这样。
- final bool rowWithPrevious;
- String get text => controller.text.trim();
- /// 这一栏里填了几个值。用来做"三组数据个数要一样"这类检查 ——
- /// 数个数是记账,不是计算,留在 Dart 侧。
- int get count => _tokens.length;
- List<String> 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<void> _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));
- }
- /// 近似值的 Symja 写法:保留 k 位**小数**。
- ///
- /// 为什么不是 `N[expr, k]`:**Symja 的 N 忽略第二个参数**(实测 4 位和 15 位打印
- /// 一模一样)。`NumberForm` / `SetPrecision` 也没实现(会原样返回 numberform(...))。
- /// `Round[值, 1/10^k]` 是真把值截到 k 位小数,文本和 LaTeX 都跟着变干净。
- String approxExpr(String expr, int digits) => 'N[Round[$expr, 1/10^$digits]]';
- /// 精确值 + 近似值两行输出。
- ///
- /// 工具页原先在 Dart 里既给分数又给小数(`1/3 (≈0.3333)`)。精确值走
- /// `Rationalize` —— 输入写成小数(`0.2`)时 Symja 会一路按小数算下去,
- /// 均值显示成 `3.6` 就不再是"精确值"了;`Rationalize` 把它还原成分数 `18/5`。
- /// 近似值再问一次 `N[...]`。
- ///
- /// **两者相等时只留一行**,而且要比**数值**而不是字符串:`N[5]` 打印成 `5.0`,
- /// 拿字符串比就变成每个整数结果都多一条没用的 "5.0"。
- Future<List<ToolOutput>> exactAndApprox(
- SymjaSession session,
- String label,
- String expr,
- ) async {
- final exact = await session.compute('Rationalize[$expr]', applyMode: false);
- // 近似值也从**有理化之后**的数转小数:输入写成 0.2/0.3 时机器浮点会带出
- // `3.5999999999999996` 这种尾巴,先还原成分数再取小数就干净了(3.6)。
- // 位数听设置里那个滑动条(计算器的"近似"也是同一个值)。
- final approx = await session.compute(
- approxExpr('Rationalize[$expr]', session.approximateDigits),
- applyMode: false,
- );
- final same = _sameValue(exact.text, approx.text);
- return [
- ToolText(label, exact.text, tex: exact.tex),
- if (!same) ToolText('$label(近似)', approx.text, tex: approx.tex),
- ];
- }
- /// 两个显示串是不是同一个数(都解析得出来的话)。
- bool _sameValue(String a, String b) {
- if (a == b) return true;
- final x = asNumber(a), y = asNumber(b);
- if (x == null || y == null) return false;
- return (x - y).abs() <= 1e-9 * (1 + x.abs());
- }
- /// 把近似值缀在精确值后面:`16/81 ≈ 0.1975`。同一个数就只留精确值。
- ///
- /// 给"一条条分开"的分布表用 —— 每项单独成卡时不拆成两张,
- /// 否则 n=10 的二项分布会列出二十多张卡。
- String withApprox(String exact, String approx) {
- final a = approx.trim();
- if (a.isEmpty) return exact;
- return _sameValue(exact.trim(), a) ? exact : '$exact ≈ $a';
- }
- /// 纯数值输出:只给近似值(用户要的本来就是个数,不是分数)。
- Future<ToolOutput> numeric(
- SymjaSession session,
- String label,
- String expr,
- ) async {
- final r = await session.compute(
- approxExpr('Rationalize[$expr]', session.approximateDigits),
- applyMode: false,
- );
- return ToolText(label, r.text, tex: null);
- }
- /// 把 Symja 输出的列表文本拆成嵌套的字符串列表。
- ///
- /// 工具要拿到"一条条"结果(比如每个 k 的概率、每对卷积取值)才能分开显示,
- /// 而通道里回来的是 `{{0, 1/6}, {1, 1/3}}` 这样一个字符串。刻意**不**做数字解析:
- /// 拆出来的还是 Symja 的原文(`1/6` 就是 `1/6`),要算数再交回给 Symja。
- List<Object> parseSymjaList(String s) {
- final t = s.trim();
- if (!t.startsWith('{') || !t.endsWith('}')) return [t];
- final out = <Object>[];
- 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());
- }
|