1603417404
SliverAppBar may sound like a familiar widget, but in this episode we show you how to use the new FlexibleSpaceBar’s expandedHeight and flexibleSpace properties to create an area at the top of a list that can grow, shrink, and more!
#flutter #mobile-apps #web-development #developer #programming
1624422526
Declarative UI in Flutter is pretty nice, easy to use and it is very enticing to use it as much as possible. But very often developers just go overboard with it — writing everything in a declarative style even when sometimes task can be done much more efficiently and easier to understand in a more imperative way.
What everyone should understand — there always must be a balance between declarative and imperative programming. Each has its own uses and each shines at some tasks brighter than other.
In this series of articles I’ll describe how to solve different problems by creating custom Widgets from scratch. Each one is a little more complicated than a previous one.
There some basic things we need to know before looking at the code.
Widget
— is just an immutable (preferably const) class that contains configuration properties for Elements
and RenderObjects
. It is also responsible for creating said Elements
and RenderObjects
. Important thing to understand — Widgets never contain state nor any business logic, only pass them.
Element
— is an entity responsible for the actual UI tree. It has references to all children and (unlike Widget
) to its parent. Elements
are reused most of the time, unless key
or Widget
are changed. So if onlyWidget
properties are changed, even though new Widget
is allocated, Element
will remain the same.
State
— is nothing more than a user-defined class inside Element
that also has some callbacks from itsElement
exposed.
#custom-widget #ellipsis #flutter #widget
1670340528
responsive_table
Responsive Data table is a highly flexible tool built upon the foundations of progressive enhancement, that adds all of these advanced features to any flutter table.
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:responsive_table/responsive_table.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
initialRoute: '/',
routes: {
'/': (_) => DataPage(),
},
);
}
}
class DataPage extends StatefulWidget {
DataPage({Key? key}) : super(key: key);
@override
_DataPageState createState() => _DataPageState();
}
class _DataPageState extends State<DataPage> {
late List<DatatableHeader> _headers;
List<int> _perPages = [10, 20, 50, 100];
int _total = 100;
int? _currentPerPage = 10;
List<bool>? _expanded;
String? _searchKey = "id";
int _currentPage = 1;
bool _isSearch = false;
List<Map<String, dynamic>> _sourceOriginal = [];
List<Map<String, dynamic>> _sourceFiltered = [];
List<Map<String, dynamic>> _source = [];
List<Map<String, dynamic>> _selecteds = [];
// ignore: unused_field
String _selectableKey = "id";
String? _sortColumn;
bool _sortAscending = true;
bool _isLoading = true;
bool _showSelect = true;
var random = new Random();
List<Map<String, dynamic>> _generateData({int n: 100}) {
final List source = List.filled(n, Random.secure());
List<Map<String, dynamic>> temps = [];
var i = 1;
print(i);
// ignore: unused_local_variable
for (var data in source) {
temps.add({
"id": i,
"sku": "$i\000$i",
"name": "Product $i",
"category": "Category-$i",
"price": i * 10.00,
"cost": "20.00",
"margin": "${i}0.20",
"in_stock": "${i}0",
"alert": "5",
"received": [i + 20, 150]
});
i++;
}
return temps;
}
_initializeData() async {
_mockPullData();
}
_mockPullData() async {
_expanded = List.generate(_currentPerPage!, (index) => false);
setState(() => _isLoading = true);
Future.delayed(Duration(seconds: 3)).then((value) {
_sourceOriginal.clear();
_sourceOriginal.addAll(_generateData(n: random.nextInt(10000)));
_sourceFiltered = _sourceOriginal;
_total = _sourceFiltered.length;
_source = _sourceFiltered.getRange(0, _currentPerPage!).toList();
setState(() => _isLoading = false);
});
}
_resetData({start: 0}) async {
setState(() => _isLoading = true);
var _expandedLen =
_total - start < _currentPerPage! ? _total - start : _currentPerPage;
Future.delayed(Duration(seconds: 0)).then((value) {
_expanded = List.generate(_expandedLen as int, (index) => false);
_source.clear();
_source = _sourceFiltered.getRange(start, start + _expandedLen).toList();
setState(() => _isLoading = false);
});
}
_filterData(value) {
setState(() => _isLoading = true);
try {
if (value == "" || value == null) {
_sourceFiltered = _sourceOriginal;
} else {
_sourceFiltered = _sourceOriginal
.where((data) => data[_searchKey!]
.toString()
.toLowerCase()
.contains(value.toString().toLowerCase()))
.toList();
}
_total = _sourceFiltered.length;
var _rangeTop = _total < _currentPerPage! ? _total : _currentPerPage!;
_expanded = List.generate(_rangeTop, (index) => false);
_source = _sourceFiltered.getRange(0, _rangeTop).toList();
} catch (e) {
print(e);
}
setState(() => _isLoading = false);
}
@override
void initState() {
super.initState();
/// set headers
_headers = [
DatatableHeader(
text: "ID",
value: "id",
show: true,
sortable: true,
textAlign: TextAlign.center),
DatatableHeader(
text: "Name",
value: "name",
show: true,
flex: 2,
sortable: true,
editable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "SKU",
value: "sku",
show: true,
sortable: true,
textAlign: TextAlign.center),
DatatableHeader(
text: "Category",
value: "category",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Price",
value: "price",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Margin",
value: "margin",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "In Stock",
value: "in_stock",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Alert",
value: "alert",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Received",
value: "received",
show: true,
sortable: false,
sourceBuilder: (value, row) {
List list = List.from(value);
return Container(
child: Column(
children: [
Container(
width: 85,
child: LinearProgressIndicator(
value: list.first / list.last,
),
),
Text("${list.first} of ${list.last}")
],
),
);
},
textAlign: TextAlign.center),
];
_initializeData();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("RESPONSIVE DATA TABLE"),
),
drawer: Drawer(
child: ListView(
children: [
ListTile(
leading: Icon(Icons.home),
title: Text("home"),
onTap: () {},
),
ListTile(
leading: Icon(Icons.storage),
title: Text("data"),
onTap: () {},
)
],
),
),
body: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Container(
margin: EdgeInsets.all(10),
padding: EdgeInsets.all(0),
constraints: BoxConstraints(
maxHeight: 700,
),
child: Card(
elevation: 1,
shadowColor: Colors.black,
clipBehavior: Clip.none,
child: ResponsiveDatatable(
title: TextButton.icon(
onPressed: () => {},
icon: Icon(Icons.add),
label: Text("new item"),
),
reponseScreenSizes: [ScreenSize.xs],
actions: [
if (_isSearch)
Expanded(
child: TextField(
decoration: InputDecoration(
hintText: 'Enter search term based on ' +
_searchKey!
.replaceAll(new RegExp('[\\W_]+'), ' ')
.toUpperCase(),
prefixIcon: IconButton(
icon: Icon(Icons.cancel),
onPressed: () {
setState(() {
_isSearch = false;
});
_initializeData();
}),
suffixIcon: IconButton(
icon: Icon(Icons.search), onPressed: () {})),
onSubmitted: (value) {
_filterData(value);
},
)),
if (!_isSearch)
IconButton(
icon: Icon(Icons.search),
onPressed: () {
setState(() {
_isSearch = true;
});
})
],
headers: _headers,
source: _source,
selecteds: _selecteds,
showSelect: _showSelect,
autoHeight: false,
dropContainer: (data) {
if (int.tryParse(data['id'].toString())!.isEven) {
return Text("is Even");
}
return _DropDownContainer(data: data);
},
onChangedRow: (value, header) {
/// print(value);
/// print(header);
},
onSubmittedRow: (value, header) {
/// print(value);
/// print(header);
},
onTabRow: (data) {
print(data);
},
onSort: (value) {
setState(() => _isLoading = true);
setState(() {
_sortColumn = value;
_sortAscending = !_sortAscending;
if (_sortAscending) {
_sourceFiltered.sort((a, b) =>
b["$_sortColumn"].compareTo(a["$_sortColumn"]));
} else {
_sourceFiltered.sort((a, b) =>
a["$_sortColumn"].compareTo(b["$_sortColumn"]));
}
var _rangeTop = _currentPerPage! < _sourceFiltered.length
? _currentPerPage!
: _sourceFiltered.length;
_source = _sourceFiltered.getRange(0, _rangeTop).toList();
_searchKey = value;
_isLoading = false;
});
},
expanded: _expanded,
sortAscending: _sortAscending,
sortColumn: _sortColumn,
isLoading: _isLoading,
onSelect: (value, item) {
print("$value $item ");
if (value!) {
setState(() => _selecteds.add(item));
} else {
setState(
() => _selecteds.removeAt(_selecteds.indexOf(item)));
}
},
onSelectAll: (value) {
if (value!) {
setState(() => _selecteds =
_source.map((entry) => entry).toList().cast());
} else {
setState(() => _selecteds.clear());
}
},
footers: [
Container(
padding: EdgeInsets.symmetric(horizontal: 15),
child: Text("Rows per page:"),
),
if (_perPages.isNotEmpty)
Container(
padding: EdgeInsets.symmetric(horizontal: 15),
child: DropdownButton<int>(
value: _currentPerPage,
items: _perPages
.map((e) => DropdownMenuItem<int>(
child: Text("$e"),
value: e,
))
.toList(),
onChanged: (dynamic value) {
setState(() {
_currentPerPage = value;
_currentPage = 1;
_resetData();
});
},
isExpanded: false,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 15),
child:
Text("$_currentPage - $_currentPerPage of $_total"),
),
IconButton(
icon: Icon(
Icons.arrow_back_ios,
size: 16,
),
onPressed: _currentPage == 1
? null
: () {
var _nextSet = _currentPage - _currentPerPage!;
setState(() {
_currentPage = _nextSet > 1 ? _nextSet : 1;
_resetData(start: _currentPage - 1);
});
},
padding: EdgeInsets.symmetric(horizontal: 15),
),
IconButton(
icon: Icon(Icons.arrow_forward_ios, size: 16),
onPressed: _currentPage + _currentPerPage! - 1 > _total
? null
: () {
var _nextSet = _currentPage + _currentPerPage!;
setState(() {
_currentPage = _nextSet < _total
? _nextSet
: _total - _currentPerPage!;
_resetData(start: _nextSet - 1);
});
},
padding: EdgeInsets.symmetric(horizontal: 15),
)
],
),
),
),
])),
floatingActionButton: FloatingActionButton(
onPressed: _initializeData,
child: Icon(Icons.refresh_sharp),
),
);
}
}
class _DropDownContainer extends StatelessWidget {
final Map<String, dynamic> data;
const _DropDownContainer({Key? key, required this.data}) : super(key: key);
@override
Widget build(BuildContext context) {
List<Widget> _children = data.entries.map<Widget>((entry) {
Widget w = Row(
children: [
Text(entry.key.toString()),
Spacer(),
Text(entry.value.toString()),
],
);
return w;
}).toList();
return Container(
/// height: 100,
child: Column(
/// children: [
/// Expanded(
/// child: Container(
/// color: Colors.red,
/// height: 50,
/// )),
/// ],
children: _children,
),
);
}
}
onSelect( bool selected, dynamic key);
onSelectAll( bool selected );
onTabRow( dynamic row );
onSort( dynamic value );
title: Widget
headers: List<DatatableHeader>
actions: List<Widget>
source: List<Map<String, dynamic>>
selecteds: List<Map<String, dynamic>>
showSelect: bool
footers: List<Widget>
sortColumn: String
sortAscending: bool
isLoading: bool,
autoHeight: bool,
Run this command:
With Flutter:
$ flutter pub add responsive_table
This will add a line like this to your package's pubspec.yaml (and run an implicit flutter pub get
):
dependencies:
responsive_table: ^0.2.0+2
Alternatively, your editor might support flutter pub get
. Check the docs for your editor to learn more.
Now in your Dart code, you can use:
import 'package:responsive_table/responsive_table.dart';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:responsive_table/responsive_table.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
primarySwatch: Colors.blue,
visualDensity: VisualDensity.adaptivePlatformDensity,
),
initialRoute: '/',
routes: {
'/': (_) => DataPage(),
},
);
}
}
class DataPage extends StatefulWidget {
DataPage({Key? key}) : super(key: key);
@override
_DataPageState createState() => _DataPageState();
}
class _DataPageState extends State<DataPage> {
late List<DatatableHeader> _headers;
List<int> _perPages = [10, 20, 50, 100];
int _total = 100;
int? _currentPerPage = 10;
List<bool>? _expanded;
String? _searchKey = "id";
int _currentPage = 1;
bool _isSearch = false;
List<Map<String, dynamic>> _sourceOriginal = [];
List<Map<String, dynamic>> _sourceFiltered = [];
List<Map<String, dynamic>> _source = [];
List<Map<String, dynamic>> _selecteds = [];
// ignore: unused_field
String _selectableKey = "id";
String? _sortColumn;
bool _sortAscending = true;
bool _isLoading = true;
bool _showSelect = true;
var random = new Random();
List<Map<String, dynamic>> _generateData({int n: 100}) {
final List source = List.filled(n, Random.secure());
List<Map<String, dynamic>> temps = [];
var i = 1;
print(i);
// ignore: unused_local_variable
for (var data in source) {
temps.add({
"id": i,
"sku": "$i\000$i",
"name": "Product $i",
"category": "Category-$i",
"price": i * 10.00,
"cost": "20.00",
"margin": "${i}0.20",
"in_stock": "${i}0",
"alert": "5",
"received": [i + 20, 150]
});
i++;
}
return temps;
}
_initializeData() async {
_mockPullData();
}
_mockPullData() async {
_expanded = List.generate(_currentPerPage!, (index) => false);
setState(() => _isLoading = true);
Future.delayed(Duration(seconds: 3)).then((value) {
_sourceOriginal.clear();
_sourceOriginal.addAll(_generateData(n: random.nextInt(10000)));
_sourceFiltered = _sourceOriginal;
_total = _sourceFiltered.length;
_source = _sourceFiltered.getRange(0, _currentPerPage!).toList();
setState(() => _isLoading = false);
});
}
_resetData({start: 0}) async {
setState(() => _isLoading = true);
var _expandedLen =
_total - start < _currentPerPage! ? _total - start : _currentPerPage;
Future.delayed(Duration(seconds: 0)).then((value) {
_expanded = List.generate(_expandedLen as int, (index) => false);
_source.clear();
_source = _sourceFiltered.getRange(start, start + _expandedLen).toList();
setState(() => _isLoading = false);
});
}
_filterData(value) {
setState(() => _isLoading = true);
try {
if (value == "" || value == null) {
_sourceFiltered = _sourceOriginal;
} else {
_sourceFiltered = _sourceOriginal
.where((data) => data[_searchKey!]
.toString()
.toLowerCase()
.contains(value.toString().toLowerCase()))
.toList();
}
_total = _sourceFiltered.length;
var _rangeTop = _total < _currentPerPage! ? _total : _currentPerPage!;
_expanded = List.generate(_rangeTop, (index) => false);
_source = _sourceFiltered.getRange(0, _rangeTop).toList();
} catch (e) {
print(e);
}
setState(() => _isLoading = false);
}
@override
void initState() {
super.initState();
/// set headers
_headers = [
DatatableHeader(
text: "ID",
value: "id",
show: true,
sortable: true,
textAlign: TextAlign.center),
DatatableHeader(
text: "Name",
value: "name",
show: true,
flex: 2,
sortable: true,
editable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "SKU",
value: "sku",
show: true,
sortable: true,
textAlign: TextAlign.center),
DatatableHeader(
text: "Category",
value: "category",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Price",
value: "price",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Margin",
value: "margin",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "In Stock",
value: "in_stock",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Alert",
value: "alert",
show: true,
sortable: true,
textAlign: TextAlign.left),
DatatableHeader(
text: "Received",
value: "received",
show: true,
sortable: false,
sourceBuilder: (value, row) {
List list = List.from(value);
return Container(
child: Column(
children: [
Container(
width: 85,
child: LinearProgressIndicator(
value: list.first / list.last,
),
),
Text("${list.first} of ${list.last}")
],
),
);
},
textAlign: TextAlign.center),
];
_initializeData();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("RESPONSIVE DATA TABLE"),
actions: [
IconButton(
onPressed: _initializeData,
icon: Icon(Icons.refresh_sharp),
),
],
),
drawer: Drawer(
child: ListView(
children: [
ListTile(
leading: Icon(Icons.home),
title: Text("home"),
onTap: () {},
),
ListTile(
leading: Icon(Icons.storage),
title: Text("data"),
onTap: () {},
)
],
),
),
body: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
children: [
Container(
margin: EdgeInsets.all(10),
padding: EdgeInsets.all(0),
constraints: BoxConstraints(
maxHeight: 700,
),
child: Card(
elevation: 1,
shadowColor: Colors.black,
clipBehavior: Clip.none,
child: ResponsiveDatatable(
title: TextButton.icon(
onPressed: () => {},
icon: Icon(Icons.add),
label: Text("new item"),
),
reponseScreenSizes: [ScreenSize.xs],
actions: [
if (_isSearch)
Expanded(
child: TextField(
decoration: InputDecoration(
hintText: 'Enter search term based on ' +
_searchKey!
.replaceAll(new RegExp('[\\W_]+'), ' ')
.toUpperCase(),
prefixIcon: IconButton(
icon: Icon(Icons.cancel),
onPressed: () {
setState(() {
_isSearch = false;
});
_initializeData();
}),
suffixIcon: IconButton(
icon: Icon(Icons.search), onPressed: () {})),
onSubmitted: (value) {
_filterData(value);
},
)),
if (!_isSearch)
IconButton(
icon: Icon(Icons.search),
onPressed: () {
setState(() {
_isSearch = true;
});
})
],
headers: _headers,
source: _source,
selecteds: _selecteds,
showSelect: _showSelect,
autoHeight: false,
dropContainer: (data) {
if (int.tryParse(data['id'].toString())!.isEven) {
return Text("is Even");
}
return _DropDownContainer(data: data);
},
onChangedRow: (value, header) {
/// print(value);
/// print(header);
},
onSubmittedRow: (value, header) {
/// print(value);
/// print(header);
},
onTabRow: (data) {
print(data);
},
onSort: (value) {
setState(() => _isLoading = true);
setState(() {
_sortColumn = value;
_sortAscending = !_sortAscending;
if (_sortAscending) {
_sourceFiltered.sort((a, b) =>
b["$_sortColumn"].compareTo(a["$_sortColumn"]));
} else {
_sourceFiltered.sort((a, b) =>
a["$_sortColumn"].compareTo(b["$_sortColumn"]));
}
var _rangeTop = _currentPerPage! < _sourceFiltered.length
? _currentPerPage!
: _sourceFiltered.length;
_source = _sourceFiltered.getRange(0, _rangeTop).toList();
_searchKey = value;
_isLoading = false;
});
},
expanded: _expanded,
sortAscending: _sortAscending,
sortColumn: _sortColumn,
isLoading: _isLoading,
onSelect: (value, item) {
print("$value $item ");
if (value!) {
setState(() => _selecteds.add(item));
} else {
setState(
() => _selecteds.removeAt(_selecteds.indexOf(item)));
}
},
onSelectAll: (value) {
if (value!) {
setState(() => _selecteds =
_source.map((entry) => entry).toList().cast());
} else {
setState(() => _selecteds.clear());
}
},
footers: [
Container(
padding: EdgeInsets.symmetric(horizontal: 15),
child: Text("Rows per page:"),
),
if (_perPages.isNotEmpty)
Container(
padding: EdgeInsets.symmetric(horizontal: 15),
child: DropdownButton<int>(
value: _currentPerPage,
items: _perPages
.map((e) => DropdownMenuItem<int>(
child: Text("$e"),
value: e,
))
.toList(),
onChanged: (dynamic value) {
setState(() {
_currentPerPage = value;
_currentPage = 1;
_resetData();
});
},
isExpanded: false,
),
),
Container(
padding: EdgeInsets.symmetric(horizontal: 15),
child:
Text("$_currentPage - $_currentPerPage of $_total"),
),
IconButton(
icon: Icon(
Icons.arrow_back_ios,
size: 16,
),
onPressed: _currentPage == 1
? null
: () {
var _nextSet = _currentPage - _currentPerPage!;
setState(() {
_currentPage = _nextSet > 1 ? _nextSet : 1;
_resetData(start: _currentPage - 1);
});
},
padding: EdgeInsets.symmetric(horizontal: 15),
),
IconButton(
icon: Icon(Icons.arrow_forward_ios, size: 16),
onPressed: _currentPage + _currentPerPage! - 1 > _total
? null
: () {
var _nextSet = _currentPage + _currentPerPage!;
setState(() {
_currentPage = _nextSet < _total
? _nextSet
: _total - _currentPerPage!;
_resetData(start: _nextSet - 1);
});
},
padding: EdgeInsets.symmetric(horizontal: 15),
)
],
headerDecoration: BoxDecoration(
color: Colors.grey,
border: Border(
bottom: BorderSide(color: Colors.red, width: 1))),
selectedDecoration: BoxDecoration(
border: Border(
bottom:
BorderSide(color: Colors.green[300]!, width: 1)),
color: Colors.green,
),
headerTextStyle: TextStyle(color: Colors.white),
rowTextStyle: TextStyle(color: Colors.green),
selectedTextStyle: TextStyle(color: Colors.white),
),
),
),
])),
);
}
}
class _DropDownContainer extends StatelessWidget {
final Map<String, dynamic> data;
const _DropDownContainer({Key? key, required this.data}) : super(key: key);
@override
Widget build(BuildContext context) {
List<Widget> _children = data.entries.map<Widget>((entry) {
Widget w = Row(
children: [
Text(entry.key.toString()),
Spacer(),
Text(entry.value.toString()),
],
);
return w;
}).toList();
return Container(
/// height: 100,
child: Column(
/// children: [
/// Expanded(
/// child: Container(
/// color: Colors.red,
/// height: 50,
/// )),
/// ],
children: _children,
),
);
}
}
Download Details:
Author:
Source Code: https://pub.dev/packages/responsive_table
1626913620
In this video I have discussed about Scaffold widget and its different properties.
It is a widget which implements the basic material design structure for the flutter app.
#flutter #exploring flutter widgets #flutter scaffold widget #its properties
1626909900
In this video I have discussed about Container widget and its different properties.
If the container widget does not have a child it will fill up the the given area on screen.
If it contains child it will have the same width and height as of its child.
It should not be rendered directly on screen without parent widget. We can use Center widget, Padding Widget, Column Widget, Row Widget or Scaffold Widget as parent.
#flutter #exploring flutter widgets #flutter container widget
1603368206
https://www.youtube.com/watch?v=pgMI5nmAem0
#flutter #widget #tutorial #testing #dart