| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736 |
- 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 'symja.dart';
- import 'symja_functions.dart';
- /// 科学计算器(默认主界面)。
- ///
- /// 输入用**结构化节点树**(见 lib/expr_node.dart),不是字符串:
- /// 按键把节点插进当前光标槽,分数竖排、指数上标、多参数函数各有自己的槽。
- /// 纯文本输入留给控制台。
- class CalculatorPage extends StatefulWidget {
- const CalculatorPage({super.key});
- @override
- State<CalculatorPage> createState() => _CalculatorPageState();
- }
- class _CalculatorPageState extends State<CalculatorPage> {
- 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<void> _copy(String text, String what) async {
- await Clipboard.setData(ClipboardData(text: text));
- _snack('已复制$what:$text');
- }
- /// 从剪贴板读一段 Symja 表达式,解析成节点树后整体替换。
- ///
- /// 解析器只认一个明确的子集,**认不出来就报错**,不会猜着解析 ——
- /// 猜错了你会看到算错的结果,那比报错糟糕得多。
- Future<void> _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<void> _copySelection() async {
- final t = _doc.selectionText;
- if (t.isEmpty) return;
- await _copy(t, '选中内容');
- setState(() => _doc.clearSelection());
- }
- Future<void> _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<void> _runPreview() async {
- final expr = _source;
- if (expr.isEmpty || expr == 'Null') {
- 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 = _source;
- if (expr.isEmpty || expr == 'Null') 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();
- }
- // ------------------------------------------------------------------ 按键
- void _press(_Key k) {
- switch (k.act) {
- 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<void> _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<SymjaSession>();
- 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 = <List<_Key>>[
- // 1 大运算符
- [
- _Key('∫', _Act.bigOp, payload: 'Integrate', prefill: ['x']),
- _Key('Σ', _Act.bigOp,
- payload: 'Sum', prefill: ['n', '1', 'Infinity']),
- _Key('d/dx', _Act.fnPrefixed, payload: 'D', prefill: ['', 'x']),
- _Key('lim', _Act.bigOp, payload: 'Limit', prefill: ['x', '0']),
- _Key('solve', _Act.fnPrefixed, payload: 'Solve', prefill: ['', 'x']),
- ],
- // 2 常用函数
- [
- _Key('sin', _Act.fn, payload: 'Sin'),
- _Key('cos', _Act.fn, payload: 'Cos'),
- _Key('tan', _Act.fn, payload: 'Tan'),
- _Key('ln', _Act.fn, payload: 'Log'),
- _Key('√', _Act.sqrt),
- ],
- // 3 变量
- [
- _Key('x', _Act.char, payload: 'x', mathVar: true),
- _Key('y', _Act.char, payload: 'y', mathVar: true),
- _Key('a', _Act.char, payload: 'a', mathVar: true),
- _Key('t', _Act.char, payload: 't', mathVar: true),
- _Key('n', _Act.char, payload: 'n', mathVar: true),
- ],
- // 4 运算符:解方程、赋值、代入、定义函数全靠它们。
- // 都是"运算符"不是"函数",所以放键盘上而不是函数面板里。
- [
- _Key('=', _Act.char, payload: '=', tint: true),
- _Key('==', _Act.char, payload: '==', tint: true),
- // /. 和 -> 永远成对出现(代入就是 `式子 /. x -> 值`),
- // 合成一个键直接给出骨架,省一个位置给逗号(矩阵行分隔)
- _Key('/.→', _Act.subst, tint: true),
- _Key(',', _Act.comma),
- _Key('{', _Act.brace, tint: true),
- ],
- // 5 数字与括号
- [
- _Key('7', _Act.char, payload: '7', mergeDigit: true),
- _Key('8', _Act.char, payload: '8', mergeDigit: true),
- _Key('9', _Act.char, payload: '9', mergeDigit: true),
- _Key('(', _Act.group),
- _Key('!', _Act.postfix),
- ],
- [
- _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: '-'),
- ],
- // 7 花括号:矩阵用
- [
- _Key('0', _Act.char, payload: '0', mergeDigit: true),
- _Key('.', _Act.char, payload: '.', mergeDigit: true),
- _Key('^', _Act.power),
- // 常量。编辑框显示 π / e,送进引擎的是 Pi / E(和 ANS 同一套机制)
- _Key('π', _Act.char, payload: 'Pi', tint: true, display: 'π'),
- _Key('e', _Act.char, payload: 'E', tint: true, display: 'e'),
- ],
- [
- _Key('⌫', _Act.back, tint: true),
- _Key('CLR', _Act.clear, tint: true),
- // 虚数单位。送进 Symja 的是 I,编辑框里显示小写 i
- _Key('i', _Act.char, payload: 'I', tint: true, display: 'i'),
- _Key('ANS', _Act.char, payload: '%', tint: true, display: 'ANS'),
- // 键盘负责"敲字符",✓ 负责"执行"。两者都叫 = 会撞车。
- _Key('✓', _Act.commit, primary: true),
- ],
- ];
- // 7 行 + 6 个间隙,再扣掉 Padding 上下各 6 ——
- // box.maxHeight 是 padding **之前**的高度,漏掉会溢出。
- final h = (box.maxHeight - 12 - 8 * 6) / 9;
- 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(
- // 加 key 是为了让测试能精确定位按键 —— 编辑器里渲染出的字面量(比如 'x')
- // 和键盘上同名的按键都符合 find.text,会撞车。
- key: ValueKey('key-${k.label}'),
- color: bg,
- borderRadius: 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,
- 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<String> prefill;
- /// 数字键:并入左边那个数字字面量,而不是各建一个节点。
- final bool mergeDigit;
- /// 编辑框里显示的名字(和真正送出去的 payload 不同时用)。
- final String? display;
- /// 是不是"数学变量"。变量键的标签用**斜体衬线**,和运算符区分开 ——
- /// 等宽字体里 `x`(变量)和 `×`(乘号)几乎一模一样,用户实测反馈过。
- final bool mathVar;
- final bool tint;
- final bool primary;
- 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,
- });
- }
- /// 函数面板:检索 Symja 的两百多个函数名,选中后插入一个函数节点。
- 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, (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)),
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
|