nandi/vflutter_ffipublic Fork 0
472eb493bac94bdcb79a38829ccca0c541256fef
Commits
Clone
git clone https://git.rickub.com/nandi/vflutter_ffi.git
git clone ssh://git@rickub.com/nandi/vflutter_ffi.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

main.dart · 585 lines · 18.8 KBDart Blame HistoryRaw
Replace V with Nim 472eb49 nandithebull 8h ago1// A Mandelbrot explorer whose pixels are computed in Nim.
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago2//
3// What this is actually demonstrating:
4// * bulk data across the FFI boundary — a full RGBA frame per render,
Replace V with Nim 472eb49 nandithebull 8h ago5// into a buffer Dart allocates and Nim fills in place (nothing to nf_free)
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago6// * band parallelism — the image is split into horizontal strips rendered
7// on separate isolates, which is safe because the library is -gc none
Replace V with Nim 472eb49 nandithebull 8h ago8// * the same algorithm written twice, in Nim and in Dart, so the cost of the
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago9// bridge is a number on screen rather than a claim
10import 'dart:async';
11import 'dart:math' as math;
12import 'dart:typed_data';
13import 'dart:ui' as ui;
14
15import 'package:flutter/gestures.dart';
16import 'package:flutter/material.dart';
Replace V with Nim 472eb49 nandithebull 8h ago17import 'package:nimflutter_ffi/nimflutter_ffi.dart' as v;
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago18
19import 'mandelbrot_dart.dart';
20
21void main() => runApp(const ExampleApp());
22
23class ExampleApp extends StatelessWidget {
24 const ExampleApp({super.key});
25
26 @override
27 Widget build(BuildContext context) => MaterialApp(
Replace V with Nim 472eb49 nandithebull 8h ago28 title: 'nimflutter_ffi',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago29 debugShowCheckedModeBanner: false,
30 theme: ThemeData.dark(useMaterial3: true).copyWith(
31 scaffoldBackgroundColor: const Color(0xFF12141A),
32 ),
33 home: const ExplorerPage(),
34 );
35}
36
37class ExplorerPage extends StatefulWidget {
38 const ExplorerPage({super.key});
39
40 @override
41 State<ExplorerPage> createState() => _ExplorerPageState();
42}
43
Add drag-to-pan 0ed90f3 nandithebull 10h ago44/// Identifies the interactive canvas, so tests can drive gestures on it
45/// without depending on whether a frame has finished rendering yet.
46const Key canvasKey = Key('fractal-canvas');
47
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago48class _ExplorerPageState extends State<ExplorerPage> {
49 static const int _renderWidth = 720;
50 static const int _renderHeight = 540;
51
52 v.FractalView _view = const v.FractalView(
53 width: _renderWidth,
54 height: _renderHeight,
55 maxIter: 400,
56 );
57
58 ui.Image? _image;
59 bool _rendering = false;
60 bool _renderQueued = false;
61 int _lastMs = 0;
Raise the isolate ceiling to 32, and add the sweep that justifies it 59a731a nandithebull 10h ago62 int _tiles = 16;
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago63 _Benchmark? _benchmark;
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago64 String? _bootError;
65
66 /// Bands run on isolates natively and sequentially on web, so the word the
67 /// UI uses for them has to follow the backend.
68 String get _unit => v.supportsIsolateParallelism ? 'isolate' : 'band';
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago69
70 @override
71 void initState() {
72 super.initState();
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago73 _boot();
74 }
75
76 /// Brings the bridge up and draws the first frame.
77 Future<void> _boot() => _render();
78
79 /// Waits for the bridge, returning false if it could not be brought up.
80 ///
81 /// On native this resolves immediately. On web it is where the wasm module
82 /// is fetched and instantiated, which cannot be synchronous.
83 ///
84 /// Every path that asks for a frame goes through here, not just startup:
85 /// the first layout pass calls _fitToCanvas -> _render before initState's
86 /// future has resolved, so gating only the boot path would still let the
87 /// very first render race the module load. initialize() is idempotent and
88 /// hands back the in-flight future, so the extra calls just wait.
89 Future<bool> _ensureReady() async {
90 if (v.isInitialized) return true;
91 try {
92 await v.initialize();
93 return true;
94 } catch (e) {
95 if (mounted) setState(() => _bootError = '$e');
96 return false;
97 }
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago98 }
99
100 @override
101 void dispose() {
102 _image?.dispose();
103 super.dispose();
104 }
105
106 /// Renders the current view, coalescing requests that arrive mid-frame.
107 ///
108 /// A wheel gesture emits far more events than we can render. Dropping them
109 /// outright would leave the view mutated but never drawn, so instead the
110 /// latest request is remembered and serviced once the in-flight frame lands.
111 Future<void> _render() async {
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago112 if (!await _ensureReady()) return;
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago113 if (_rendering) {
114 _renderQueued = true;
115 return;
116 }
117 setState(() => _rendering = true);
118
119 do {
120 _renderQueued = false;
121 final view = _view;
122
123 final watch = Stopwatch()..start();
124 final pixels = _tiles > 1
125 ? await v.renderParallel(view, tiles: _tiles)
126 : v.render(view);
127 watch.stop();
128
129 final image = await _decode(pixels, view.width, view.height);
130 if (!mounted) {
131 image.dispose();
132 return;
133 }
134 setState(() {
135 _image?.dispose();
136 _image = image;
137 _lastMs = watch.elapsedMilliseconds;
138 });
139 } while (_renderQueued);
140
141 if (!mounted) return;
142 setState(() => _rendering = false);
143 }
144
145 static Future<ui.Image> _decode(Uint8List rgba, int w, int h) {
146 final completer = Completer<ui.Image>();
147 ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888,
148 completer.complete);
149 return completer.future;
150 }
151
Add drag-to-pan 0ed90f3 nandithebull 10h ago152 /// Drags the view by a pixel delta, so the image follows the cursor.
153 void _panBy(Offset delta, Size widgetSize) {
154 if (widgetSize.isEmpty || delta == Offset.zero) return;
155 setState(() {
156 _view = _view.pannedBy(
157 delta.dx / widgetSize.width,
158 delta.dy / widgetSize.height,
159 );
160 });
161 _render();
162 }
163
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago164 /// Zooms about a point, keeping that point under the cursor.
165 void _zoomAt(Offset local, Size widgetSize, double factor) {
166 if (widgetSize.isEmpty) return;
167 setState(() {
168 _view = _view.zoomedAt(
169 local.dx / widgetSize.width,
170 local.dy / widgetSize.height,
171 factor,
172 );
173 });
174 _render();
175 }
176
177 /// Restores the starting framing.
178 ///
179 /// Only the pan/zoom is reset. Width and height belong to the canvas (see
180 /// _fitToCanvas) and maxIter belongs to its slider — clobbering those made
181 /// "Reset view" silently undo settings the button does not own, and briefly
182 /// render at the wrong aspect ratio before the canvas corrected it.
183 void _reset() {
184 const home = v.FractalView(width: 1, height: 1);
185 setState(() {
186 _view = _view.copyWith(
187 centerX: home.centerX,
188 centerY: home.centerY,
189 scale: home.scale,
190 );
191 });
192 _render();
193 }
194
Replace V with Nim 472eb49 nandithebull 8h ago195 /// Renders the identical frame in Nim and in Dart on this isolate, so the
196 /// comparison is like-for-like rather than Nim-plus-parallelism vs Dart.
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago197 Future<void> _runBenchmark() async {
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago198 if (!await _ensureReady()) return;
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago199 setState(() {
200 _rendering = true;
201 _benchmark = null;
202 });
203
204 final swV = Stopwatch()..start();
205 v.render(_view);
206 swV.stop();
207
208 final swP = Stopwatch()..start();
209 await v.renderParallel(_view, tiles: _tiles);
210 swP.stop();
211
212 final swD = Stopwatch()..start();
213 renderDart(_view);
214 swD.stop();
215
216 if (!mounted) return;
217 setState(() {
218 _rendering = false;
219 _benchmark = _Benchmark(
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago220 unit: _unit,
221 parallel: v.supportsIsolateParallelism,
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago222 vSerialMs: swV.elapsedMilliseconds,
223 vParallelMs: swP.elapsedMilliseconds,
224 dartMs: swD.elapsedMilliseconds,
225 tiles: _tiles,
226 );
227 });
228 }
229
230 @override
231 Widget build(BuildContext context) {
232 return Scaffold(
233 body: SafeArea(
234 child: LayoutBuilder(
235 builder: (context, constraints) {
236 final wide = constraints.maxWidth > 900;
237 final canvas = _buildCanvas();
238 final controls = _buildControls();
239 return wide
240 ? Row(
241 crossAxisAlignment: CrossAxisAlignment.stretch,
242 children: [
243 Expanded(child: canvas),
244 SizedBox(
245 width: 340,
246 child: SingleChildScrollView(child: controls),
247 ),
248 ],
249 )
250 : ListView(
251 children: [
252 AspectRatio(
253 aspectRatio: _renderWidth / _renderHeight,
254 child: canvas,
255 ),
256 controls,
257 ],
258 );
259 },
260 ),
261 ),
262 );
263 }
264
265 /// Keeps the rendered buffer at the canvas's aspect ratio.
266 ///
267 /// Without this the image would be letterboxed or cropped, and the
268 /// click-to-zoom maths — which maps widget coordinates straight into the
269 /// complex plane — would point at the wrong place.
270 void _fitToCanvas(Size size) {
271 if (size.isEmpty) return;
272 final height = (_renderWidth * size.height / size.width).round();
273 if ((height - _view.height).abs() <= 2) return;
274 WidgetsBinding.instance.addPostFrameCallback((_) {
275 if (!mounted) return;
276 setState(() => _view = _view.copyWith(height: height));
277 _render();
278 });
279 }
280
281 Widget _buildCanvas() {
282 return LayoutBuilder(
283 builder: (context, constraints) {
284 final size = Size(constraints.maxWidth, constraints.maxHeight);
285 _fitToCanvas(size);
286 return Listener(
Add drag-to-pan 0ed90f3 nandithebull 10h ago287 key: canvasKey,
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago288 // Wheel zoom, anchored at the cursor like the tap zoom. One notch is
289 // a small step so a scroll feels continuous rather than octave-wise;
290 // the render loop coalesces whatever it cannot keep up with.
291 onPointerSignal: (event) {
292 if (event is! PointerScrollEvent) return;
293 final dy = event.scrollDelta.dy;
294 if (dy == 0) return;
295 _zoomAt(event.localPosition, size, math.pow(1.0015, dy).toDouble());
296 },
297 child: GestureDetector(
Add drag-to-pan 0ed90f3 nandithebull 10h ago298 // Zoom on tap up rather than tap down. The gesture arena already
299 // keeps onTapDown from firing once a pan claims the pointer, so
300 // this is not what makes drag-to-pan work — it just means the
301 // zoom commits on a completed tap.
302 onTapUp: (d) => _zoomAt(d.localPosition, size, 0.5),
303 onSecondaryTapUp: (d) => _zoomAt(d.localPosition, size, 2.0),
304 onPanUpdate: (d) => _panBy(d.delta, size),
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago305 child: Stack(
306 fit: StackFit.expand,
307 children: [
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago308 if (_bootError != null)
309 // Most likely on web: the wasm glue did not load, so there is
Replace V with Nim 472eb49 nandithebull 8h ago310 // no Nim to call. Say so rather than spinning forever.
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago311 Center(
312 child: Padding(
313 padding: const EdgeInsets.all(24),
314 child: Text(
Replace V with Nim 472eb49 nandithebull 8h ago315 'Could not start the Nim bridge.\n\n$_bootError',
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago316 textAlign: TextAlign.center,
317 style: Theme.of(context).textTheme.bodySmall,
318 ),
319 ),
320 )
321 else if (_image != null)
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago322 RawImage(image: _image, fit: BoxFit.fill)
323 else
324 const Center(child: CircularProgressIndicator()),
325 Positioned(
326 left: 12,
327 bottom: 12,
328 child: _Chip(
329 'zoom ${(3.0 / _view.scale).toStringAsFixed(1)}x · '
330 '$_lastMs ms · '
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago331 '${_tiles > 1 ? "$_tiles ${_unit}s" : "1 $_unit"}',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago332 ),
333 ),
334 if (_rendering)
335 const Positioned(
336 right: 12,
337 top: 12,
338 child: SizedBox(
339 width: 18,
340 height: 18,
341 child: CircularProgressIndicator(strokeWidth: 2),
342 ),
343 ),
344 ],
345 ),
346 ),
347 );
348 },
349 );
350 }
351
352 /// Returns a non-scrolling column.
353 ///
354 /// The wide layout wraps this in its own scroll view; the narrow layout
355 /// embeds it in the page's ListView. Making this a ListView itself would
356 /// nest two scrollables and give the inner one unbounded height.
357 Widget _buildControls() {
358 final theme = Theme.of(context);
359 return Padding(
360 padding: const EdgeInsets.all(20),
361 child: Column(
362 crossAxisAlignment: CrossAxisAlignment.stretch,
363 children: [
Replace V with Nim 472eb49 nandithebull 8h ago364 Text('Mandelbrot in Nim', style: theme.textTheme.titleLarge),
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago365 const SizedBox(height: 4),
366 Text(
Add drag-to-pan 0ed90f3 nandithebull 10h ago367 'Scroll or tap to zoom in, right-click to zoom out, drag to pan. '
Replace V with Nim 472eb49 nandithebull 8h ago368 'Every pixel is computed by nf_mandelbrot in src/nimflutter.v.',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago369 style: theme.textTheme.bodySmall,
370 ),
371 const SizedBox(height: 20),
372 _Labelled(
373 label: 'iterations',
374 value: '${_view.maxIter}',
375 child: Slider(
376 value: _view.maxIter.toDouble(),
377 min: 50,
378 max: 2000,
379 divisions: 39,
380 onChanged: (n) =>
381 setState(() => _view = _view.copyWith(maxIter: n.round())),
382 onChangeEnd: (_) => _render(),
383 ),
384 ),
385 _Labelled(
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago386 label: '${_unit}s',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago387 value: '$_tiles',
388 child: Slider(
389 value: _tiles.toDouble(),
390 min: 1,
Raise the isolate ceiling to 32, and add the sweep that justifies it 59a731a nandithebull 10h ago391 // Deliberately well past the core count. Bands cost wildly
392 // different amounts — rows through the set's interior run the full
393 // iteration cap, rows in open space escape immediately — so the
394 // frame waits on its slowest band. Over-decomposing lets a free
395 // core pick up the next small band instead of idling. Measured on
396 // an 8-thread machine: 8 tiles 44 ms, 24 tiles 29 ms.
397 max: 32,
398 divisions: 31,
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago399 onChanged: (n) => setState(() => _tiles = n.round()),
400 onChangeEnd: (_) => _render(),
401 ),
402 ),
403 const SizedBox(height: 8),
404 Row(
405 children: [
406 Expanded(
407 child: FilledButton.tonal(
408 onPressed: _rendering ? null : _reset,
409 child: const Text('Reset view'),
410 ),
411 ),
412 const SizedBox(width: 12),
413 Expanded(
414 child: FilledButton(
415 onPressed: _rendering ? null : _runBenchmark,
416 child: const Text('Benchmark'),
417 ),
418 ),
419 ],
420 ),
421 const SizedBox(height: 20),
422 if (_benchmark != null) _BenchmarkCard(result: _benchmark!),
423 const SizedBox(height: 12),
424 Card(
425 color: theme.colorScheme.surfaceContainerHighest,
426 child: Padding(
427 padding: const EdgeInsets.all(14),
428 child: Text(
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago429 v.supportsIsolateParallelism
Replace V with Nim 472eb49 nandithebull 8h ago430 ? 'The Nim kernel fills a buffer Dart allocates, so no Nim '
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago431 'memory crosses the boundary and there is nothing to '
432 'free. Bands are independent, which is what makes the '
433 'isolate slider safe: -gc none means no stop-the-world '
434 'phase to serialise them.'
Replace V with Nim 472eb49 nandithebull 8h ago435 : 'The same Nim kernel, compiled to WebAssembly and reached '
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago436 'over dart:js_interop instead of dart:ffi — it fills a '
437 'buffer in the wasm heap, so nothing needs freeing on '
438 'the Dart side. The module is single-threaded, so the '
439 'bands here run in sequence; they still produce the '
440 'identical frame, byte for byte, as the native build.',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago441 style: theme.textTheme.bodySmall,
442 ),
443 ),
444 ),
445 ],
446 ),
447 );
448 }
449}
450
451class _Benchmark {
452 const _Benchmark({
453 required this.vSerialMs,
454 required this.vParallelMs,
455 required this.dartMs,
456 required this.tiles,
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago457 required this.unit,
458 required this.parallel,
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago459 });
460
461 final int vSerialMs;
462 final int vParallelMs;
463 final int dartMs;
464 final int tiles;
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago465
466 /// 'isolate' natively, 'band' on web.
467 final String unit;
468
469 /// Whether the split actually ran concurrently.
470 final bool parallel;
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago471}
472
473class _BenchmarkCard extends StatelessWidget {
474 const _BenchmarkCard({required this.result});
475
476 final _Benchmark result;
477
478 @override
479 Widget build(BuildContext context) {
480 final theme = Theme.of(context);
481 final ratio = result.dartMs / result.vSerialMs;
482 return Card(
483 child: Padding(
484 padding: const EdgeInsets.all(14),
485 child: Column(
486 crossAxisAlignment: CrossAxisAlignment.start,
487 children: [
488 Text('same frame, three ways',
489 style: theme.textTheme.titleSmall),
490 const SizedBox(height: 10),
Replace V with Nim 472eb49 nandithebull 8h ago491 _Row(label: 'Nim, 1 ${result.unit}', value: '${result.vSerialMs} ms'),
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago492 _Row(
Replace V with Nim 472eb49 nandithebull 8h ago493 label: 'Nim, ${result.tiles} ${result.unit}s',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago494 value: '${result.vParallelMs} ms'),
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago495 _Row(label: 'Dart, 1 ${result.unit}', value: '${result.dartMs} ms'),
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago496 const Divider(height: 20),
497 Text(
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago498 result.parallel
Replace V with Nim 472eb49 nandithebull 8h ago499 ? 'Nim is ${ratio.toStringAsFixed(2)}x Dart on one isolate. '
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago500 'For a scalar FP loop the two are close — the real win '
501 'here is the parallel split, which Dart could also do. '
502 'The bridge is what this demonstrates, not a speed claim.'
Replace V with Nim 472eb49 nandithebull 8h ago503 : 'Nim is ${ratio.toStringAsFixed(2)}x Dart on one thread. '
Add a WebAssembly backend for the example 728acaa nandithebull 8h ago504 'The wasm module is single-threaded, so the '
505 '${result.tiles}-band row above is the same work done '
506 'in sequence, not in parallel — it is here to show the '
507 'split produces identical pixels either way.',
Add a Mandelbrot example app, and a V kernel worth demonstrating 81540bb nandithebull 10h ago508 style: theme.textTheme.bodySmall,
509 ),
510 ],
511 ),
512 ),
513 );
514 }
515}
516
517class _Row extends StatelessWidget {
518 const _Row({required this.label, required this.value});
519
520 final String label;
521 final String value;
522
523 @override
524 Widget build(BuildContext context) => Padding(
525 padding: const EdgeInsets.symmetric(vertical: 2),
526 child: Row(
527 mainAxisAlignment: MainAxisAlignment.spaceBetween,
528 children: [
529 Text(label, style: Theme.of(context).textTheme.bodyMedium),
530 Text(value,
531 style: Theme.of(context)
532 .textTheme
533 .bodyMedium
534 ?.copyWith(fontFeatures: const [ui.FontFeature.tabularFigures()])),
535 ],
536 ),
537 );
538}
539
540class _Labelled extends StatelessWidget {
541 const _Labelled({
542 required this.label,
543 required this.value,
544 required this.child,
545 });
546
547 final String label;
548 final String value;
549 final Widget child;
550
551 @override
552 Widget build(BuildContext context) {
553 final theme = Theme.of(context);
554 return Column(
555 crossAxisAlignment: CrossAxisAlignment.start,
556 children: [
557 Row(
558 mainAxisAlignment: MainAxisAlignment.spaceBetween,
559 children: [
560 Text(label, style: theme.textTheme.labelLarge),
561 Text(value, style: theme.textTheme.labelLarge),
562 ],
563 ),
564 child,
565 ],
566 );
567 }
568}
569
570class _Chip extends StatelessWidget {
571 const _Chip(this.text);
572
573 final String text;
574
575 @override
576 Widget build(BuildContext context) => Container(
577 padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
578 decoration: BoxDecoration(
579 color: Colors.black.withValues(alpha: 0.55),
580 borderRadius: BorderRadius.circular(8),
581 ),
582 child: Text(text,
583 style: const TextStyle(color: Colors.white, fontSize: 12)),
584 );
585}