1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5/// @docImport 'card_theme.dart';
6/// @docImport 'data_table_theme.dart';
7/// @docImport 'text_button.dart';
8library;
9
10import 'dart:math' as math;
11
12import 'package:flutter/gestures.dart' show DragStartBehavior;
13import 'package:flutter/widgets.dart';
14
15import 'card.dart';
16import 'constants.dart';
17import 'data_table.dart';
18import 'data_table_source.dart';
19import 'debug.dart';
20import 'dropdown.dart';
21import 'icon_button.dart';
22import 'icons.dart';
23import 'ink_decoration.dart';
24import 'material_localizations.dart';
25import 'material_state.dart';
26import 'progress_indicator.dart';
27import 'theme.dart';
28
29/// A table that follows the
30/// [Material 2](https://material.io/go/design-data-tables)
31/// design specification, using multiple pages to display data.
32///
33/// A paginated data table shows [rowsPerPage] rows of data per page and
34/// provides controls for showing other pages.
35///
36/// Data is read lazily from a [DataTableSource]. The widget is presented
37/// as a [Card].
38///
39/// If the [key] is a [PageStorageKey], the [initialFirstRowIndex] is persisted
40/// to [PageStorage].
41///
42/// {@tool dartpad}
43///
44/// This sample shows how to display a [DataTable] with three columns: name,
45/// age, and role. The columns are defined by three [DataColumn] objects. The
46/// table contains three rows of data for three example users, the data for
47/// which is defined by three [DataRow] objects.
48///
49/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.0.dart **
50/// {@end-tool}
51///
52/// {@tool dartpad}
53///
54/// This example shows how paginated data tables can supported sorted data.
55///
56/// ** See code in examples/api/lib/material/paginated_data_table/paginated_data_table.1.dart **
57/// {@end-tool}
58///
59/// See also:
60///
61/// * [DataTable], which is not paginated.
62/// * `TableView` from the
63/// [two_dimensional_scrollables](https://pub.dev/packages/two_dimensional_scrollables)
64/// package, for displaying large amounts of data without pagination.
65/// * <https://material.io/go/design-data-tables>
66class PaginatedDataTable extends StatefulWidget {
67 /// Creates a widget describing a paginated [DataTable] on a [Card].
68 ///
69 /// The [header] should give the card's header, typically a [Text] widget.
70 ///
71 /// The [columns] argument must be a list of as many [DataColumn] objects as
72 /// the table is to have columns, ignoring the leading checkbox column if any.
73 /// The [columns] argument must have a length greater than zero and cannot be
74 /// null.
75 ///
76 /// If the table is sorted, the column that provides the current primary key
77 /// should be specified by index in [sortColumnIndex], 0 meaning the first
78 /// column in [columns], 1 being the next one, and so forth.
79 ///
80 /// The actual sort order can be specified using [sortAscending]; if the sort
81 /// order is ascending, this should be true (the default), otherwise it should
82 /// be false.
83 ///
84 /// The [source] should be a long-lived [DataTableSource]. The same source
85 /// should be provided each time a particular [PaginatedDataTable] widget is
86 /// created; avoid creating a new [DataTableSource] with each new instance of
87 /// the [PaginatedDataTable] widget unless the data table really is to now
88 /// show entirely different data from a new source.
89 ///
90 /// Themed by [DataTableTheme]. [DataTableThemeData.decoration] is ignored.
91 /// To modify the border or background color of the [PaginatedDataTable], use
92 /// [CardTheme], since a [Card] wraps the inner [DataTable].
93 PaginatedDataTable({
94 super.key,
95 this.header,
96 this.actions,
97 required this.columns,
98 this.sortColumnIndex,
99 this.sortAscending = true,
100 this.onSelectAll,
101 @Deprecated(
102 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. '
103 'This feature was deprecated after v3.7.0-5.0.pre.',
104 )
105 double? dataRowHeight,
106 double? dataRowMinHeight,
107 double? dataRowMaxHeight,
108 this.headingRowHeight = 56.0,
109 this.horizontalMargin = 24.0,
110 this.columnSpacing = 56.0,
111 this.showCheckboxColumn = true,
112 this.showFirstLastButtons = false,
113 this.initialFirstRowIndex = 0,
114 this.onPageChanged,
115 this.rowsPerPage = defaultRowsPerPage,
116 this.availableRowsPerPage = const <int>[defaultRowsPerPage, defaultRowsPerPage * 2, defaultRowsPerPage * 5, defaultRowsPerPage * 10],
117 this.onRowsPerPageChanged,
118 this.dragStartBehavior = DragStartBehavior.start,
119 this.arrowHeadColor,
120 required this.source,
121 this.checkboxHorizontalMargin,
122 this.controller,
123 this.primary,
124 this.headingRowColor,
125 this.showEmptyRows = true,
126 }) : assert(actions == null || (header != null)),
127 assert(columns.isNotEmpty),
128 assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)),
129 assert(dataRowMinHeight == null || dataRowMaxHeight == null || dataRowMaxHeight >= dataRowMinHeight),
130 assert(dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null),
131 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.'),
132 dataRowMinHeight = dataRowHeight ?? dataRowMinHeight,
133 dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight,
134 assert(rowsPerPage > 0),
135 assert(() {
136 if (onRowsPerPageChanged != null) {
137 assert(availableRowsPerPage.contains(rowsPerPage));
138 }
139 return true;
140 }()),
141 assert(!(controller != null && (primary ?? false)),
142 'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. '
143 'You cannot both set primary to true and pass an explicit controller.',
144 );
145
146 /// The table card's optional header.
147 ///
148 /// This is typically a [Text] widget, but can also be a [Row] of
149 /// [TextButton]s. To show icon buttons at the top end side of the table with
150 /// a header, set the [actions] property.
151 ///
152 /// If items in the table are selectable, then, when the selection is not
153 /// empty, the header is replaced by a count of the selected items. The
154 /// [actions] are still visible when items are selected.
155 final Widget? header;
156
157 /// Icon buttons to show at the top end side of the table. The [header] must
158 /// not be null to show the actions.
159 ///
160 /// Typically, the exact actions included in this list will vary based on
161 /// whether any rows are selected or not.
162 ///
163 /// These should be size 24.0 with default padding (8.0).
164 final List<Widget>? actions;
165
166 /// The configuration and labels for the columns in the table.
167 final List<DataColumn> columns;
168
169 /// The current primary sort key's column.
170 ///
171 /// See [DataTable.sortColumnIndex] for details.
172 ///
173 /// The direction of the sort is specified using [sortAscending].
174 final int? sortColumnIndex;
175
176 /// Whether the column mentioned in [sortColumnIndex], if any, is sorted
177 /// in ascending order.
178 ///
179 /// See [DataTable.sortAscending] for details.
180 final bool sortAscending;
181
182 /// Invoked when the user selects or unselects every row, using the
183 /// checkbox in the heading row.
184 ///
185 /// See [DataTable.onSelectAll].
186 final ValueSetter<bool?>? onSelectAll;
187
188 /// The height of each row (excluding the row that contains column headings).
189 ///
190 /// This value is optional and defaults to kMinInteractiveDimension if not
191 /// specified.
192 @Deprecated(
193 'Migrate to use dataRowMinHeight and dataRowMaxHeight instead. '
194 'This feature was deprecated after v3.7.0-5.0.pre.',
195 )
196 double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null;
197
198 /// The minimum height of each row (excluding the row that contains column headings).
199 ///
200 /// This value is optional and defaults to [kMinInteractiveDimension] if not
201 /// specified.
202 final double? dataRowMinHeight;
203
204 /// The maximum height of each row (excluding the row that contains column headings).
205 ///
206 /// This value is optional and defaults to [kMinInteractiveDimension] if not
207 /// specified.
208 final double? dataRowMaxHeight;
209
210 /// The height of the heading row.
211 ///
212 /// This value is optional and defaults to 56.0 if not specified.
213 final double headingRowHeight;
214
215 /// The horizontal margin between the edges of the table and the content
216 /// in the first and last cells of each row.
217 ///
218 /// When a checkbox is displayed, it is also the margin between the checkbox
219 /// the content in the first data column.
220 ///
221 /// This value defaults to 24.0 to adhere to the Material Design specifications.
222 ///
223 /// If [checkboxHorizontalMargin] is null, then [horizontalMargin] is also the
224 /// margin between the edge of the table and the checkbox, as well as the
225 /// margin between the checkbox and the content in the first data column.
226 final double horizontalMargin;
227
228 /// The horizontal margin between the contents of each data column.
229 ///
230 /// This value defaults to 56.0 to adhere to the Material Design specifications.
231 final double columnSpacing;
232
233 /// {@macro flutter.material.dataTable.showCheckboxColumn}
234 final bool showCheckboxColumn;
235
236 /// Flag to display the pagination buttons to go to the first and last pages.
237 final bool showFirstLastButtons;
238
239 /// The index of the first row to display when the widget is first created.
240 final int? initialFirstRowIndex;
241
242 /// Invoked when the user switches to another page.
243 ///
244 /// The value is the index of the first row on the currently displayed page.
245 final ValueChanged<int>? onPageChanged;
246
247 /// The number of rows to show on each page.
248 ///
249 /// See also:
250 ///
251 /// * [onRowsPerPageChanged]
252 /// * [defaultRowsPerPage]
253 final int rowsPerPage;
254
255 /// The default value for [rowsPerPage].
256 ///
257 /// Useful when initializing the field that will hold the current
258 /// [rowsPerPage], when implemented [onRowsPerPageChanged].
259 static const int defaultRowsPerPage = 10;
260
261 /// The options to offer for the rowsPerPage.
262 ///
263 /// The current [rowsPerPage] must be a value in this list.
264 ///
265 /// The values in this list should be sorted in ascending order.
266 final List<int> availableRowsPerPage;
267
268 /// Invoked when the user selects a different number of rows per page.
269 ///
270 /// If this is null, then the value given by [rowsPerPage] will be used
271 /// and no affordance will be provided to change the value.
272 final ValueChanged<int?>? onRowsPerPageChanged;
273
274 /// The data source which provides data to show in each row.
275 ///
276 /// This object should generally have a lifetime longer than the
277 /// [PaginatedDataTable] widget itself; it should be reused each time the
278 /// [PaginatedDataTable] constructor is called.
279 final DataTableSource source;
280
281 /// {@macro flutter.widgets.scrollable.dragStartBehavior}
282 final DragStartBehavior dragStartBehavior;
283
284 /// Horizontal margin around the checkbox, if it is displayed.
285 ///
286 /// If null, then [horizontalMargin] is used as the margin between the edge
287 /// of the table and the checkbox, as well as the margin between the checkbox
288 /// and the content in the first data column. This value defaults to 24.0.
289 final double? checkboxHorizontalMargin;
290
291 /// Defines the color of the arrow heads in the footer.
292 final Color? arrowHeadColor;
293
294 /// {@macro flutter.widgets.scroll_view.controller}
295 final ScrollController? controller;
296
297 /// {@macro flutter.widgets.scroll_view.primary}
298 final bool? primary;
299
300 /// {@macro flutter.material.dataTable.headingRowColor}
301 final MaterialStateProperty<Color?>? headingRowColor;
302
303 /// Controls the visibility of empty rows on the last page of a
304 /// [PaginatedDataTable].
305 ///
306 /// Defaults to `true`, which means empty rows will be populated on the
307 /// last page of the table if there is not enough content.
308 /// When set to `false`, empty rows will not be created.
309 final bool showEmptyRows;
310
311 @override
312 PaginatedDataTableState createState() => PaginatedDataTableState();
313}
314
315/// Holds the state of a [PaginatedDataTable].
316///
317/// The table can be programmatically paged using the [pageTo] method.
318class PaginatedDataTableState extends State<PaginatedDataTable> {
319 late int _firstRowIndex;
320 late int _rowCount;
321 late bool _rowCountApproximate;
322 int _selectedRowCount = 0;
323 final Map<int, DataRow?> _rows = <int, DataRow?>{};
324
325 @override
326 void initState() {
327 super.initState();
328 _firstRowIndex = PageStorage.maybeOf(context)?.readState(context) as int? ?? widget.initialFirstRowIndex ?? 0;
329 widget.source.addListener(_handleDataSourceChanged);
330 _handleDataSourceChanged();
331 }
332
333 @override
334 void didUpdateWidget(PaginatedDataTable oldWidget) {
335 super.didUpdateWidget(oldWidget);
336 if (oldWidget.source != widget.source) {
337 oldWidget.source.removeListener(_handleDataSourceChanged);
338 widget.source.addListener(_handleDataSourceChanged);
339 _updateCaches();
340 }
341 }
342
343 @override
344 void reassemble() {
345 super.reassemble();
346 // This function is called during hot reload.
347 //
348 // Normally, if the data source changes, it would notify its listeners and
349 // thus trigger _handleDataSourceChanged(), which clears the row cache and
350 // causes the widget to rebuild.
351 //
352 // During a hot reload, though, a data source can change in ways that will
353 // invalidate the row cache (e.g. adding or removing columns) without ever
354 // triggering a notification, leaving the PaginatedDataTable in an invalid
355 // state. This method handles this case by clearing the cache any time the
356 // widget is involved in a hot reload.
357 _updateCaches();
358 }
359
360 @override
361 void dispose() {
362 widget.source.removeListener(_handleDataSourceChanged);
363 super.dispose();
364 }
365
366 void _handleDataSourceChanged() {
367 setState(_updateCaches);
368 }
369
370 void _updateCaches() {
371 _rowCount = widget.source.rowCount;
372 _rowCountApproximate = widget.source.isRowCountApproximate;
373 _selectedRowCount = widget.source.selectedRowCount;
374 _rows.clear();
375 }
376
377 /// Ensures that the given row is visible.
378 void pageTo(int rowIndex) {
379 final int oldFirstRowIndex = _firstRowIndex;
380 setState(() {
381 final int rowsPerPage = widget.rowsPerPage;
382 _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage;
383 });
384 if ((widget.onPageChanged != null) &&
385 (oldFirstRowIndex != _firstRowIndex)) {
386 widget.onPageChanged!(_firstRowIndex);
387 }
388 }
389
390 DataRow _getBlankRowFor(int index) {
391 return DataRow.byIndex(
392 index: index,
393 cells: widget.columns.map<DataCell>((DataColumn column) => DataCell.empty).toList(),
394 );
395 }
396
397 DataRow _getProgressIndicatorRowFor(int index) {
398 bool haveProgressIndicator = false;
399 final List<DataCell> cells = widget.columns.map<DataCell>((DataColumn column) {
400 if (!column.numeric) {
401 haveProgressIndicator = true;
402 return const DataCell(CircularProgressIndicator());
403 }
404 return DataCell.empty;
405 }).toList();
406 if (!haveProgressIndicator) {
407 haveProgressIndicator = true;
408 cells[0] = const DataCell(CircularProgressIndicator());
409 }
410 return DataRow.byIndex(
411 index: index,
412 cells: cells,
413 );
414 }
415
416 List<DataRow> _getRows(int firstRowIndex, int rowsPerPage) {
417 final List<DataRow> result = <DataRow>[];
418 final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage;
419 bool haveProgressIndicator = false;
420 for (int index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) {
421 DataRow? row;
422 if (index < _rowCount || _rowCountApproximate) {
423 row = _rows.putIfAbsent(index, () => widget.source.getRow(index));
424 if (row == null && !haveProgressIndicator) {
425 row ??= _getProgressIndicatorRowFor(index);
426 haveProgressIndicator = true;
427 }
428 }
429
430 if (widget.showEmptyRows) {
431 row ??= _getBlankRowFor(index);
432 }
433
434 if (row != null) {
435 result.add(row);
436 }
437 }
438 return result;
439 }
440
441 void _handleFirst() {
442 pageTo(0);
443 }
444
445 void _handlePrevious() {
446 pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0));
447 }
448
449 void _handleNext() {
450 pageTo(_firstRowIndex + widget.rowsPerPage);
451 }
452
453 void _handleLast() {
454 pageTo(((_rowCount - 1) / widget.rowsPerPage).floor() * widget.rowsPerPage);
455 }
456
457 bool _isNextPageUnavailable() => !_rowCountApproximate &&
458 (_firstRowIndex + widget.rowsPerPage >= _rowCount);
459
460 final GlobalKey _tableKey = GlobalKey();
461
462 @override
463 Widget build(BuildContext context) {
464 // TODO(ianh): This whole build function doesn't handle RTL yet.
465 assert(debugCheckHasMaterialLocalizations(context));
466 final ThemeData themeData = Theme.of(context);
467 final MaterialLocalizations localizations = MaterialLocalizations.of(context);
468 // HEADER
469 final List<Widget> headerWidgets = <Widget>[];
470 if (_selectedRowCount == 0 && widget.header != null) {
471 headerWidgets.add(Expanded(child: widget.header!));
472 } else if (widget.header != null) {
473 headerWidgets.add(Expanded(
474 child: Text(localizations.selectedRowCountTitle(_selectedRowCount)),
475 ));
476 }
477 if (widget.actions != null) {
478 headerWidgets.addAll(
479 widget.actions!.map<Widget>((Widget action) {
480 return Padding(
481 // 8.0 is the default padding of an icon button
482 padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0),
483 child: action,
484 );
485 }).toList(),
486 );
487 }
488
489 // FOOTER
490 final TextStyle? footerTextStyle = themeData.textTheme.bodySmall;
491 final List<Widget> footerWidgets = <Widget>[];
492 if (widget.onRowsPerPageChanged != null) {
493 final List<Widget> availableRowsPerPage = widget.availableRowsPerPage
494 .where((int value) => value <= _rowCount || value == widget.rowsPerPage)
495 .map<DropdownMenuItem<int>>((int value) {
496 return DropdownMenuItem<int>(
497 value: value,
498 child: Text('$value'),
499 );
500 })
501 .toList();
502 footerWidgets.addAll(<Widget>[
503 // Match trailing padding, in case we overflow and end up scrolling.
504 const SizedBox(width: 14.0),
505 Text(localizations.rowsPerPageTitle),
506 ConstrainedBox(
507 constraints: const BoxConstraints(minWidth: 64.0), // 40.0 for the text, 24.0 for the icon
508 child: Align(
509 alignment: AlignmentDirectional.centerEnd,
510 child: DropdownButtonHideUnderline(
511 child: DropdownButton<int>(
512 items: availableRowsPerPage.cast<DropdownMenuItem<int>>(),
513 value: widget.rowsPerPage,
514 onChanged: widget.onRowsPerPageChanged,
515 style: footerTextStyle,
516 ),
517 ),
518 ),
519 ),
520 ]);
521 }
522 footerWidgets.addAll(<Widget>[
523 const SizedBox(width: 32.0),
524 Text(
525 localizations.pageRowsInfoTitle(
526 _firstRowIndex + 1,
527 math.min(_firstRowIndex + widget.rowsPerPage, _rowCount),
528 _rowCount,
529 _rowCountApproximate,
530 ),
531 ),
532 const SizedBox(width: 32.0),
533 if (widget.showFirstLastButtons)
534 IconButton(
535 icon: Icon(Icons.skip_previous, color: widget.arrowHeadColor),
536 padding: EdgeInsets.zero,
537 tooltip: localizations.firstPageTooltip,
538 onPressed: _firstRowIndex <= 0 ? null : _handleFirst,
539 ),
540 IconButton(
541 icon: Icon(Icons.chevron_left, color: widget.arrowHeadColor),
542 padding: EdgeInsets.zero,
543 tooltip: localizations.previousPageTooltip,
544 onPressed: _firstRowIndex <= 0 ? null : _handlePrevious,
545 ),
546 const SizedBox(width: 24.0),
547 IconButton(
548 icon: Icon(Icons.chevron_right, color: widget.arrowHeadColor),
549 padding: EdgeInsets.zero,
550 tooltip: localizations.nextPageTooltip,
551 onPressed: _isNextPageUnavailable() ? null : _handleNext,
552 ),
553 if (widget.showFirstLastButtons)
554 IconButton(
555 icon: Icon(Icons.skip_next, color: widget.arrowHeadColor),
556 padding: EdgeInsets.zero,
557 tooltip: localizations.lastPageTooltip,
558 onPressed: _isNextPageUnavailable()
559 ? null
560 : _handleLast,
561 ),
562 const SizedBox(width: 14.0),
563 ]);
564
565 // CARD
566 return Card(
567 semanticContainer: false,
568 child: LayoutBuilder(
569 builder: (BuildContext context, BoxConstraints constraints) {
570 return Column(
571 crossAxisAlignment: CrossAxisAlignment.stretch,
572 children: <Widget>[
573 if (headerWidgets.isNotEmpty)
574 Semantics(
575 container: true,
576 child: DefaultTextStyle(
577 // These typographic styles aren't quite the regular ones. We pick the closest ones from the regular
578 // list and then tweak them appropriately.
579 // See https://material.io/design/components/data-tables.html#tables-within-cards
580 style: _selectedRowCount > 0 ? themeData.textTheme.titleMedium!.copyWith(color: themeData.colorScheme.secondary)
581 : themeData.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w400),
582 child: IconTheme.merge(
583 data: const IconThemeData(
584 opacity: 0.54,
585 ),
586 child: Ink(
587 height: 64.0,
588 color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null,
589 child: Padding(
590 padding: const EdgeInsetsDirectional.only(start: 24, end: 14.0),
591 child: Row(
592 mainAxisAlignment: MainAxisAlignment.end,
593 children: headerWidgets,
594 ),
595 ),
596 ),
597 ),
598 ),
599 ),
600 SingleChildScrollView(
601 scrollDirection: Axis.horizontal,
602 primary: widget.primary,
603 controller: widget.controller,
604 dragStartBehavior: widget.dragStartBehavior,
605 child: ConstrainedBox(
606 constraints: BoxConstraints(minWidth: constraints.minWidth),
607 child: DataTable(
608 key: _tableKey,
609 columns: widget.columns,
610 sortColumnIndex: widget.sortColumnIndex,
611 sortAscending: widget.sortAscending,
612 onSelectAll: widget.onSelectAll,
613 // Make sure no decoration is set on the DataTable
614 // from the theme, as its already wrapped in a Card.
615 decoration: const BoxDecoration(),
616 dataRowMinHeight: widget.dataRowMinHeight,
617 dataRowMaxHeight: widget.dataRowMaxHeight,
618 headingRowHeight: widget.headingRowHeight,
619 horizontalMargin: widget.horizontalMargin,
620 checkboxHorizontalMargin: widget.checkboxHorizontalMargin,
621 columnSpacing: widget.columnSpacing,
622 showCheckboxColumn: widget.showCheckboxColumn,
623 showBottomBorder: true,
624 rows: _getRows(_firstRowIndex, widget.rowsPerPage),
625 headingRowColor: widget.headingRowColor,
626 ),
627 ),
628 ),
629 if (!widget.showEmptyRows)
630 SizedBox(
631 height: (widget.dataRowMaxHeight ?? kMinInteractiveDimension) * (widget.rowsPerPage - _rowCount + _firstRowIndex).clamp(0, widget.rowsPerPage)),
632 DefaultTextStyle(
633 style: footerTextStyle!,
634 child: IconTheme.merge(
635 data: const IconThemeData(
636 opacity: 0.54,
637 ),
638 child: SizedBox(
639 // TODO(bkonyi): this won't handle text zoom correctly,
640 // https://github.com/flutter/flutter/issues/48522
641 height: 56.0,
642 child: SingleChildScrollView(
643 dragStartBehavior: widget.dragStartBehavior,
644 scrollDirection: Axis.horizontal,
645 reverse: true,
646 child: Row(
647 children: footerWidgets,
648 ),
649 ),
650 ),
651 ),
652 ),
653 ],
654 );
655 },
656 ),
657 );
658 }
659}
660

Provided by KDAB

Privacy Policy
Learn more about Flutter for embedded and desktop on industrialflutter.com