expr_editor.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  1. import 'package:flutter/material.dart';
  2. import 'expr_node.dart';
  3. /// 结构化表达式的 2D 编辑器视图。
  4. ///
  5. /// 渲染方式:直接把节点树翻译成 widget 树 —— 分式用 Column(分子/横线/分母)、
  6. /// 幂用 Row + 抬高的上标、函数用 `名字[ 参数 ]`。没有自己写排版引擎,
  7. /// 因为 Flutter 的布局本来就够用,而且这样点击命中、无障碍都能白拿。
  8. class ExprEditor extends StatelessWidget {
  9. const ExprEditor({
  10. super.key,
  11. required this.doc,
  12. required this.onChanged,
  13. required this.onCaretTapped,
  14. this.fontSize = 22,
  15. });
  16. final ExprDoc doc;
  17. /// 光标或内容变化后调用,让页面刷新。
  18. final VoidCallback onChanged;
  19. /// 用户点了**光标已经所在的那个位置**(且没有选区)—— 页面据此弹出"粘贴"。
  20. final VoidCallback onCaretTapped;
  21. final double fontSize;
  22. /// 点一个节点。
  23. ///
  24. /// 三种语义:
  25. /// · 有选区时 → 扩展选区("按住选中一个,再点另一个")
  26. /// · 没选区、但点的是光标已经待着的地方 → 交给页面弹粘贴
  27. /// · 其余 → 移动光标
  28. void _tapNode(SlotPath parent, int index, bool before) {
  29. if (doc.selection != null) {
  30. doc.extendSelectionTo(parent, index);
  31. onChanged();
  32. return;
  33. }
  34. final target = SlotPath(parent.steps, before ? index : index + 1);
  35. if (doc.cursor == target) {
  36. onCaretTapped();
  37. return;
  38. }
  39. doc.cursor = target;
  40. onChanged();
  41. }
  42. /// 长按一个节点 → 选中它,工具条随之出现。
  43. void _longPressNode(SlotPath parent, int index) {
  44. doc.selectNodeAt(parent, index);
  45. onChanged();
  46. }
  47. /// 点槽的空白处:有选区就取消选区,否则光标落到槽尾。
  48. void _tapSlot(SlotPath path, SeqNode seq) {
  49. if (doc.selection != null) {
  50. doc.clearSelection();
  51. onChanged();
  52. return;
  53. }
  54. final p = SlotPath(path.steps, seq.children.length);
  55. if (doc.cursor == p) {
  56. onCaretTapped();
  57. return;
  58. }
  59. doc.cursor = p;
  60. onChanged();
  61. }
  62. @override
  63. Widget build(BuildContext context) {
  64. return SingleChildScrollView(
  65. scrollDirection: Axis.horizontal,
  66. reverse: true, // 长表达式总是把光标一侧露出来
  67. child: ConstrainedBox(
  68. constraints: BoxConstraints(
  69. minWidth: MediaQuery.sizeOf(context).width - 32,
  70. ),
  71. child: Align(
  72. alignment: Alignment.centerRight,
  73. child: Padding(
  74. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  75. child: _SlotView(
  76. doc: doc,
  77. seq: doc.root,
  78. path: SlotPath.root,
  79. size: fontSize,
  80. onTapNode: _tapNode,
  81. onLongPressNode: _longPressNode,
  82. onTapSlot: _tapSlot,
  83. ),
  84. ),
  85. ),
  86. ),
  87. );
  88. }
  89. }
  90. /// 一个槽的视图:把孩子按顺序排开,在光标位置插一根竖线。
  91. class _SlotView extends StatelessWidget {
  92. const _SlotView({
  93. required this.doc,
  94. required this.seq,
  95. required this.path,
  96. required this.size,
  97. required this.onTapNode,
  98. required this.onLongPressNode,
  99. required this.onTapSlot,
  100. this.vpad = 3,
  101. });
  102. final ExprDoc doc;
  103. final SeqNode seq;
  104. final SlotPath path;
  105. final double size;
  106. /// 上下内边距。上下限这种要紧贴运算符的地方传 0。
  107. final double vpad;
  108. final void Function(SlotPath parent, int index, bool before) onTapNode;
  109. final void Function(SlotPath parent, int index) onLongPressNode;
  110. final void Function(SlotPath path, SeqNode seq) onTapSlot;
  111. @override
  112. Widget build(BuildContext context) {
  113. final scheme = Theme.of(context).colorScheme;
  114. // 用 sameSlotAs 而不是 ==:offset 是槽内的插入位置,不属于槽的身份
  115. final focused = doc.cursor.sameSlotAs(path);
  116. final sel = doc.selection;
  117. final selectedHere = sel != null && !sel.isEmpty && sel.path.sameSlotAs(path);
  118. return GestureDetector(
  119. onTap: () => onTapSlot(path, seq),
  120. behavior: HitTestBehavior.opaque,
  121. child: Container(
  122. padding: EdgeInsets.symmetric(horizontal: 2, vertical: vpad),
  123. // 空槽只有一个光标那么宽(2.5px),手指点不到 —— 而"点光标处弹粘贴"
  124. // 恰恰最常在空槽里用(先按 ln 再粘参数)。给个最小的可点面积。
  125. constraints: focused && seq.isEmpty
  126. ? const BoxConstraints(minWidth: 24, minHeight: 30)
  127. : null,
  128. decoration: BoxDecoration(
  129. borderRadius: BorderRadius.circular(4),
  130. color: focused ? scheme.primary.withValues(alpha: 0.06) : null,
  131. ),
  132. child: Wrap(
  133. crossAxisAlignment: WrapCrossAlignment.center,
  134. children: [
  135. for (var i = 0; i < seq.children.length; i++) ...[
  136. if (focused && doc.cursor.offset == i) _caret(scheme),
  137. _SelectedOrNot(
  138. highlighted: selectedHere && i >= sel.start && i < sel.end,
  139. child: _NodeView(
  140. doc: doc,
  141. node: seq.children[i],
  142. parent: path,
  143. index: i,
  144. size: size,
  145. onTapNode: onTapNode,
  146. onLongPressNode: onLongPressNode,
  147. onTapSlot: onTapSlot,
  148. ),
  149. ),
  150. ],
  151. if (focused && doc.cursor.offset >= seq.children.length)
  152. _caret(scheme),
  153. // 空槽给个占位,否则用户不知道能往里点
  154. if (seq.isEmpty && !focused) _placeholder(scheme),
  155. ],
  156. ),
  157. ),
  158. );
  159. }
  160. /// 光标。**常亮、不闪烁**。
  161. ///
  162. /// 之前做成 0.15~1.0 的平滑呼吸,最暗那半程看着就跟没有一样(用户反馈
  163. /// "有时能看到有时看不到")。计算器不是文本编辑器,聚焦的槽本来就有底色
  164. /// 高亮,这里再常亮一根实心竖线,位置一眼就能看到。
  165. Widget _caret(ColorScheme scheme) => Container(
  166. // 整个编辑区同时最多只有一根光标,所以固定 key 不会冲突。
  167. // 加它是为了能写"打完字光标还在不在"的回归测试 —— 这个 bug
  168. // 之前就是靠人眼看截图才发现的。
  169. key: const ValueKey('expr-caret'),
  170. width: 2.5,
  171. height: size * 1.25,
  172. margin: const EdgeInsets.symmetric(horizontal: 1),
  173. color: scheme.primary,
  174. );
  175. Widget _placeholder(ColorScheme scheme) => Container(
  176. width: 12,
  177. height: size * 1.05,
  178. margin: const EdgeInsets.symmetric(horizontal: 1),
  179. decoration: BoxDecoration(
  180. border: Border.all(color: scheme.outlineVariant, width: 1),
  181. borderRadius: BorderRadius.circular(2),
  182. ),
  183. );
  184. }
  185. /// 单个节点的视图。
  186. class _NodeView extends StatelessWidget {
  187. const _NodeView({
  188. required this.doc,
  189. required this.node,
  190. required this.parent,
  191. required this.index,
  192. required this.size,
  193. required this.onTapNode,
  194. required this.onLongPressNode,
  195. required this.onTapSlot,
  196. });
  197. final ExprDoc doc;
  198. final ExprNode node;
  199. /// 该节点所在槽的路径,以及它在槽里的下标。
  200. final SlotPath parent;
  201. final int index;
  202. final double size;
  203. final void Function(SlotPath parent, int index, bool before) onTapNode;
  204. final void Function(SlotPath parent, int index) onLongPressNode;
  205. final void Function(SlotPath path, SeqNode seq) onTapSlot;
  206. @override
  207. Widget build(BuildContext context) {
  208. final scheme = Theme.of(context).colorScheme;
  209. final body = switch (node) {
  210. LitNode l => _literal(l, scheme),
  211. FuncNode f => _function(context, f),
  212. FracNode f => _fraction(context, f),
  213. PowNode p => _power(context, p),
  214. GroupNode g => _group(context, g),
  215. BraceNode b => _brace(context, b),
  216. BigOpNode b => _bigOp(context, b),
  217. PostfixNode p => _row([
  218. _slot(context, p.operand, parent.push(index, 0)),
  219. _text(p.op, size),
  220. ]),
  221. };
  222. // 点节点的**左半边**把光标放到它前面,右半边放到它后面。
  223. //
  224. // 之前无论点哪都落到"后面",于是偏移 0(表达式最前面)那个位置永远够不着 ——
  225. // 用户只能把光标停在第一个字符之后,回不到行首。
  226. //
  227. // 宽度用 Builder 拿:Builder 自身不是 RenderObject,findRenderObject()
  228. // 会往下找到 GestureDetector 那个 box,尺寸正是节点的尺寸。
  229. return Builder(
  230. builder: (inner) => GestureDetector(
  231. behavior: HitTestBehavior.opaque,
  232. onLongPress: () => onLongPressNode(parent, index),
  233. onTapDown: (d) {
  234. final box = inner.findRenderObject() as RenderBox?;
  235. final before = d.localPosition.dx < (box?.size.width ?? 0) / 2;
  236. onTapNode(parent, index, before);
  237. },
  238. child: body,
  239. ),
  240. );
  241. }
  242. Widget _literal(LitNode l, ColorScheme scheme) {
  243. // 有显示名的(如 ANS)不加斜体 —— 它是缩写不是变量
  244. final isLetter =
  245. l.display == null && RegExp(r'^[A-Za-z]+$').hasMatch(l.text);
  246. // 最小宽度:`i`、`l`、`.` 这类窄字形,点击区只有字形那么宽(22 号字里
  247. // `i` 才 5~6px),手指点不中 —— 用户实测反馈"i 没法被选中、光标没法移到
  248. // i 附近"。给个字面量最小宽度,点中它才可靠。
  249. //
  250. // 为什么是 12 而不是更适合手指的 24/48:这个宽度是**布局**宽度,宽了就会
  251. // 在字与字之间留下真空隙(跟 `Transform` 那种只改视觉不同)。衬线体 22 号
  252. // 的数字约 11px 宽,12 只兜住真正窄的字形,`1 + 2` 这类常见写法不会被撑开。
  253. return ConstrainedBox(
  254. constraints: const BoxConstraints(minWidth: 12),
  255. child: Text(
  256. l.display ?? l.text,
  257. style: TextStyle(
  258. fontSize: size,
  259. fontFamily: 'serif',
  260. fontStyle: isLetter ? FontStyle.italic : FontStyle.normal,
  261. color: scheme.onSurface,
  262. ),
  263. ),
  264. );
  265. }
  266. /// 常见函数用**数学记号**显示,而不是代码式的 `Name[...]`。
  267. ///
  268. /// 关键点:这里只改**怎么画**,不改槽本身 —— 参数槽还是原来那些,
  269. /// 所以照样能点进去编辑、光标照样能进。
  270. Widget _function(BuildContext context, FuncNode f) {
  271. switch (f.name) {
  272. case 'Sqrt':
  273. if (f.args.length == 1) return _radical(context, f);
  274. case 'Abs':
  275. if (f.args.length == 1) return _bars(context, f);
  276. case 'Integrate':
  277. if (f.args.length == 2) return _integral(context, f);
  278. case 'Sum':
  279. if (f.args.length == 2) return _bigOperator(context, f, '∑');
  280. case 'Product':
  281. if (f.args.length == 2) return _bigOperator(context, f, '∏');
  282. }
  283. return _plainFunction(context, f);
  284. }
  285. TextStyle _mathStyle(BuildContext context, double scale) => TextStyle(
  286. fontSize: size * scale,
  287. color: Theme.of(context).colorScheme.onSurface,
  288. );
  289. /// √ 盖住被开方数。
  290. ///
  291. /// 用画笔画成**一条连起来的折线**(下折 → 上挑 → 横线),而不是
  292. /// "√ 字符 + 一条上边框" —— 后者两边各按各的字形对齐,中间必然断开
  293. /// (用户实测反馈"根号是断开的")。
  294. Widget _radical(BuildContext context, FuncNode f) => _Radical(
  295. size: size,
  296. color: Theme.of(context).colorScheme.onSurface,
  297. child: _slot(context, f.args.first, parent.push(index, 0)),
  298. );
  299. Widget _bars(BuildContext context, FuncNode f) => _row([
  300. Text('|', style: _mathStyle(context, 1.2)),
  301. _slot(context, f.args.first, parent.push(index, 0)),
  302. Text('|', style: _mathStyle(context, 1.2)),
  303. ]);
  304. /// `Integrate[f, x]` 画成 `∫ f dx`。
  305. ///
  306. /// 参数顺序被重排了(数学写法就是 ∫ … d…),但**槽还是那两个槽**,
  307. /// 所以光标和点击命中都不受影响。
  308. Widget _integral(BuildContext context, FuncNode f) => _row([
  309. Text('∫', style: _mathStyle(context, 1.6)),
  310. _slot(context, f.args[0], parent.push(index, 0)),
  311. Text('d', style: _mathStyle(context, 0.9)),
  312. _slot(context, f.args[1], parent.push(index, 1)),
  313. ]);
  314. /// `Sum[f, {n,1,∞}]` 画成 `Σ f {n,1,∞}`(迭代器小一号)。
  315. Widget _bigOperator(BuildContext context, FuncNode f, String glyph) => _row([
  316. Text(glyph, style: _mathStyle(context, 1.6)),
  317. _slot(context, f.args[0], parent.push(index, 0)),
  318. Transform.translate(
  319. offset: Offset(0, size * 0.25),
  320. child: _slot(context, f.args[1], parent.push(index, 1), scale: 0.7),
  321. ),
  322. ]);
  323. Widget _plainFunction(BuildContext context, FuncNode f) {
  324. final scheme = Theme.of(context).colorScheme;
  325. return _row([
  326. Text(
  327. f.name,
  328. style: TextStyle(fontSize: size * 0.85, color: scheme.primary),
  329. ),
  330. Text('[', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  331. for (var i = 0; i < f.args.length; i++) ...[
  332. if (i > 0)
  333. Text(', ', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  334. _slot(context, f.args[i], parent.push(index, i)),
  335. ],
  336. Text(']', style: TextStyle(fontSize: size, color: scheme.onSurface)),
  337. ]);
  338. }
  339. Widget _fraction(BuildContext context, FracNode f) {
  340. final scheme = Theme.of(context).colorScheme;
  341. return Padding(
  342. padding: const EdgeInsets.symmetric(horizontal: 2),
  343. // IntrinsicWidth 让横线刚好横跨分子分母里较宽的那个。
  344. // 不能直接 width: double.infinity —— 这里是 Wrap 里的无界宽度,会报错。
  345. child: IntrinsicWidth(
  346. child: Column(
  347. mainAxisSize: MainAxisSize.min,
  348. children: [
  349. _slot(context, f.num, parent.push(index, 0)),
  350. Container(height: 1.4, color: scheme.onSurface),
  351. _slot(context, f.den, parent.push(index, 1)),
  352. ],
  353. ),
  354. ),
  355. );
  356. }
  357. Widget _power(BuildContext context, PowNode p) {
  358. return _row([
  359. _slot(context, p.base, parent.push(index, 0)),
  360. Transform.translate(
  361. offset: Offset(0, -size * 0.35),
  362. child: _slot(context, p.exp, parent.push(index, 1), scale: 0.7),
  363. ),
  364. ]);
  365. }
  366. Widget _group(BuildContext context, GroupNode g) {
  367. final scheme = Theme.of(context).colorScheme;
  368. final style = TextStyle(fontSize: size, color: scheme.onSurface);
  369. return _row([
  370. Text('(', style: style),
  371. _slot(context, g.inner, parent.push(index, 0)),
  372. Text(')', style: style),
  373. ]);
  374. }
  375. Widget _brace(BuildContext context, BraceNode b) {
  376. final style = TextStyle(
  377. fontSize: size,
  378. color: Theme.of(context).colorScheme.onSurface,
  379. );
  380. return _row([
  381. Text('{', style: style),
  382. _slot(context, b.inner, parent.push(index, 0)),
  383. Text('}', style: style),
  384. ]);
  385. }
  386. /// 大运算符:上下限画在**习惯位置**。
  387. ///
  388. /// ∫ 上限定在上方、下限定在下方,后面接被积函数和 dx
  389. /// ∑ / ∏ 同理,下限在下、上限在上
  390. /// lim 的条件写在 "lim" 正下方
  391. ///
  392. /// 上下限为空时不占位(不定积分就是"∫ f dx",没有上下限)。
  393. Widget _bigOp(BuildContext context, BigOpNode b) {
  394. final body = _slot(context, b.body, parent.push(index, 0));
  395. final variable = _slot(context, b.variable, parent.push(index, 1));
  396. // 上下限紧贴运算符:槽的上下内边距收掉,再往中间拽几像素
  397. // 下限**不**上拽。字形在行框里的墨迹并不上下居中(∑ 偏上、基线以下留白多),
  398. // 上下对称地拽同样的量,上限正好、下限就压到字形上了。
  399. final lower = _slot(context, b.lower, parent.push(index, 2),
  400. scale: 0.5, vpad: 0);
  401. final upper = Transform.translate(
  402. offset: const Offset(0, 3),
  403. child: _slot(context, b.upper, parent.push(index, 3),
  404. scale: 0.5, vpad: 0),
  405. );
  406. if (b.kind == 'Limit') {
  407. // lim 下面写"x→0"
  408. // 三个元素**必须同一个字号**:框大小不同时居中对齐会让基线错开,
  409. // 横着的箭头就没法同时正对两端的 x 和数字。
  410. final under = Row(
  411. mainAxisSize: MainAxisSize.min,
  412. children: [
  413. _slot(context, b.variable, parent.push(index, 1),
  414. scale: 0.6, vpad: 0),
  415. Text('→', style: _mathStyle(context, 0.6).copyWith(height: 1.0)),
  416. _slot(context, b.lower, parent.push(index, 2),
  417. scale: 0.6, vpad: 0),
  418. ],
  419. );
  420. return _row([
  421. Column(
  422. mainAxisSize: MainAxisSize.min,
  423. children: [
  424. Text('lim', style: _mathStyle(context, 0.95).copyWith(height: 1.0)),
  425. Transform.translate(offset: const Offset(0, -2), child: under),
  426. ],
  427. ),
  428. body,
  429. ]);
  430. }
  431. final glyph = switch (b.kind) {
  432. 'Integrate' => '∫',
  433. 'Sum' => '∑',
  434. 'Product' => '∏',
  435. _ => '∑',
  436. };
  437. // height: 1.0 把字体自带的行距空白压掉 —— 默认行高约 1.16em,
  438. // 37 号字上下各多出好几个像素,看着就是"离运算符很远"。
  439. final stack = Column(
  440. mainAxisSize: MainAxisSize.min,
  441. children: [
  442. // 上下限**始终渲染** —— 空的时候画成小占位框。
  443. // 不渲染的话不定积分的两个限槽就成了够不着的死结构,永远填不进去。
  444. upper,
  445. Text(glyph, style: _mathStyle(context, 1.7).copyWith(height: 1.0)),
  446. lower,
  447. ],
  448. );
  449. if (b.kind == 'Integrate') {
  450. return _row([
  451. stack,
  452. body,
  453. Text('d', style: _mathStyle(context, 0.9)),
  454. variable,
  455. ]);
  456. }
  457. return _row([stack, body]);
  458. }
  459. Widget _row(List<Widget> children) => Wrap(
  460. crossAxisAlignment: WrapCrossAlignment.center,
  461. children: children,
  462. );
  463. /// [scale] 是**真的把字号改小**,而不是 Transform 缩放。
  464. ///
  465. /// Transform 只改视觉、不改布局,占位仍是原尺寸 —— 结果就是"字看着小了
  466. /// 但离运算符老远"(用户实测反馈)。直接把 size 传下去,布局才会跟着缩。
  467. Widget _slot(BuildContext context, SeqNode seq, SlotPath path,
  468. {double scale = 1, double vpad = 3}) =>
  469. _SlotView(
  470. doc: doc,
  471. seq: seq,
  472. path: path,
  473. size: size * scale,
  474. vpad: vpad,
  475. onTapNode: onTapNode,
  476. onLongPressNode: onLongPressNode,
  477. onTapSlot: onTapSlot,
  478. );
  479. Widget _text(String t, double s) => Text(t, style: TextStyle(fontSize: s));
  480. }
  481. /// 根号:画笔画出的折线 + 上划线,被开方数挂在横线下面。
  482. class _Radical extends StatelessWidget {
  483. const _Radical({
  484. required this.size,
  485. required this.color,
  486. required this.child,
  487. });
  488. final double size;
  489. final Color color;
  490. final Widget child;
  491. @override
  492. Widget build(BuildContext context) {
  493. final arm = size * 0.42; // 左侧那一段"折"的宽度
  494. return CustomPaint(
  495. painter: _RadicalPainter(color, arm),
  496. child: Padding(
  497. padding: EdgeInsets.only(left: arm, top: size * 0.30),
  498. child: child,
  499. ),
  500. );
  501. }
  502. }
  503. class _RadicalPainter extends CustomPainter {
  504. _RadicalPainter(this.color, this.arm);
  505. final Color color;
  506. final double arm;
  507. @override
  508. void paint(Canvas canvas, Size s) {
  509. final paint = Paint()
  510. ..color = color
  511. ..strokeWidth = 1.4
  512. ..style = PaintingStyle.stroke
  513. ..strokeJoin = StrokeJoin.miter
  514. ..strokeCap = StrokeCap.butt;
  515. // 从左下起笔 → 折到底 → 挑到顶 → 横着盖住被开方数
  516. final path = Path()
  517. ..moveTo(0, s.height * 0.55)
  518. ..lineTo(arm * 0.4, s.height * 0.92)
  519. ..lineTo(arm, 0)
  520. ..lineTo(s.width, 0);
  521. canvas.drawPath(path, paint);
  522. }
  523. @override
  524. bool shouldRepaint(covariant _RadicalPainter old) =>
  525. old.color != color || old.arm != arm;
  526. }
  527. /// 选中时给节点盖一层高亮背景。
  528. class _SelectedOrNot extends StatelessWidget {
  529. const _SelectedOrNot({required this.highlighted, required this.child});
  530. final bool highlighted;
  531. final Widget child;
  532. @override
  533. Widget build(BuildContext context) {
  534. if (!highlighted) return child;
  535. return Container(
  536. decoration: BoxDecoration(
  537. color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.22),
  538. borderRadius: BorderRadius.circular(3),
  539. ),
  540. child: child,
  541. );
  542. }
  543. }