In this second post of the series, we continue our journey in creating an item tree with one level of children using Flutter.
Recap
To recap briefly, our goal is to display a list of Items, such as User Stories or Bugs, associated with a Sprint. Each Item has a list of Task Items as children.
The list includes three types of items:
- User Stories: These items are represented by a blue icon, an ID, attributes, and an expand button.
- Bugs: Similar to User Stories, but with a red bug icon.
- Tasks: These yellow-icon items are primarily found in the children lists of the previous items.
A drop-down list above the items allows you to select either the current or the previous sprint.
Previous Post Focus
In the previous post, we delved into the implementation of the ListView
using FutureBuilder
, data structures, and the backend call to retrieve data.
Focus of This Post
In this post, our focus shifts to the implementation of the ItemTile
, where we dive deeper into its structure and functionalities.
The ItemTile
, as the name suggests, acts as a tile containing the attributes of an individual item within the list. It serves as a generic tile that displays attributes related to the item types we described earlier.
Observing the animated GIF at the beginning of this post, we notice that an ItemTile
can be hidden. Due to this behavior, the widget is implemented as a StatefulWidget
, as we need to manage the visibility attribute.
Defining the Widget
Let’s define the widget with the following code:
class ItemTile extends StatefulWidget {
final Item item;
const ItemTile({super.key, required this.item});
@override
State<StatefulWidget> createState() => _ItemTileState();
}
As we can see, the class has the Item
as a parameter in the constructor, allowing access to the item properties. In Flutter, a stateful widget must extend the StatefulWidget
class and provide a state that, in our case, is managed by the _ItemTileState
class below:
class _ItemTileState extends State<ItemTile> {
late bool isVisible;
@override
void initState() {
isVisible = false;
super.initState();
}
@override
Widget build(BuildContext context) {
return Container(
color: widget.item.remaining < 0 ? Colors.redAccent : null,
child: Column(
children: [
ItemTileAttribs(item: widget.item, expandCallback: expandCallback, listExpanded: isVisible,),
ItemChildrenList(isVisible: isVisible, item: widget.item)
],
)
);
}
void expandCallback(){
setState(() {
isVisible =!isVisible;
});
}
}
As we can observe from the code, the class extends the Flutter generic State
class. The class manages a state composed of the isVisible
variable and implements the build
method, similarly to what we discussed about the StatelessWidget
in the previous post.
In the build
method, there’s the logic to render the widget. It returns a Container
whose color is chosen based on a parameter of the item contained in the widget. The Container
has a child, a Flutter Column
widget that defines the layout, rendering the ItemTileAttribs
and ItemChildrenList
widgets.
Before we delve into the custom widget classes, let’s focus on the expandCallback
method (row 22). This method relies on the use of the setState
function, a fundamental mechanism in Flutter for notifying the framework that the widget’s state has changed and that the user interface should be redrawn.
Now, let’s take a closer look at the main components of the expandCallback
:
- The method doesn’t return any value (
void
) but affects the widget’s state. setState(() { ... });
: This is where the core action takes place.setState
takes a callback function that should be executed when you need to modify the widget’s state. Within this callback, you can make state changes.isVisible = !isVisible;
: Inside the callback function, this line toggles the value of the boolean variableisVisible
.
The ItemTileAttribs
can be implemented as a StatelessWidget
as follows:
class ItemTileAttribs extends StatelessWidget {
final Item item;
final Function expandCallback;
final bool listExpanded;
const ItemTileAttribs({super.key, required this.item,
required this.expandCallback, required this.listExpanded});
@override
Widget build(BuildContext context) {
return Row(
children: [
if (item.type == 'Task') const SizedBox(width: 32.0),
const SizedBox(width: 16.0),
Column(
children: [
InkWell(
child: Text("${item.id}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18,
decoration: TextDecoration.underline,
color: Colors.blue,
),
),
onTap: () => _launchUrl(item.link)
),
]),
const SizedBox(width: 16.0),
Column(
children: [
_itemIcon(item.type),
]),
const SizedBox(width: 16.0),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
_keyValueText("Assigned to: ", item.assignedTo),
_keyValueText("Sprint: ", item.sprint),
],
),
),
Expanded(
child: Column(
children: [
Text("State: ${item.state}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
item.type != 'Task' ?_keyValueText("Story Points: ", item.storyPoints) :
const SizedBox(height: 14.0),
_keyValueText("Completed Work: ", item.completedWork),
_keyValueText("Remaining: ", item.remaining),
_keyValueText("Percentage Remaining: ", item.percentageRemaining),
],
),
),
if (item.type != 'Task') _expandButton(item, listExpanded),
],
);
}
RichText _keyValueText(key, value) {
bool isNegative = false;
if (value is double || value is int) {
if (value < 0) {
isNegative = true;
}
}
return RichText(
text: TextSpan(
children: <TextSpan>[
TextSpan(
text: key,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
TextSpan(
text: value.toString(),
style: TextStyle(
fontWeight: isNegative ? FontWeight.bold : null,
),
),
])
);
}
_itemIcon(String type) {
IconData icon;
Color color;
if (type == 'Bug') {
icon = Icons.bug_report;
color = Colors.red;
}
else if (type == 'User Story') {
icon = Icons.notes;
color = Colors.lightBlue;
}
else if (type == 'Task') {
icon = Icons.task;
color = Colors.amber;
}
else {
return null;
}
return Icon(
icon,
color: color,
size: 30.0,
);
}
Widget _expandButton(Item item, bool listExpanded) {
return SizedBox(
width: 72.0,
child: Row(
children: [
if (item.priority == 0) const Icon(Icons.warning_amber_rounded,
size: 24,
) else const SizedBox(width: 24.0),
if (item.children.isNotEmpty) IconButton(
icon: listExpanded ? const Icon(Icons.arrow_drop_up_outlined)
: const Icon(Icons.arrow_drop_down_outlined),
tooltip: 'Expand children',
iconSize: 24.0,
onPressed: () {
expandCallback();
})
else const SizedBox(width: 24.0),
],
),
);
}
_launchUrl(String url) async {
if (!await launchUrl(Uri.parse(url))) {
throw Exception('Could not launch $url');
}
}
}
It might seem like a large piece of code, but it’s not overly complicated. In fact, the code accomplishes the following:
- Renders the item attributes using various Flutter widgets.
- Displays different icons based on the item type.
- Defines the behavior of the small button that allows users to expand the item children, using the provided callback.
The code for ItemChildrenList
is as follows. It’s shorter than the previous one and relatively straightforward. The AnimatedContainer
within the SingleChildScrollView
has the visible
attribute, set by the ItemTile
and updated by the expandCallback
. This means that when you load the page, it’s not visible, and when you click the button, it appears if the item has children.
class ItemChildrenList extends StatelessWidget {
final bool isVisible;
final Item item;
const ItemChildrenList({super.key, required this.isVisible, required this.item});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: AnimatedContainer(
duration: const Duration(milliseconds: 500),
curve: Curves.easeInOut,
child: Visibility(
visible: isVisible,
child: Column(
children: [
const Divider(
color: Colors.grey,
thickness: 1.0,
),
ListView.separated(
itemBuilder: (context, index) {
return ItemTile(
item: item.children[index],
);
},
separatorBuilder: (BuildContext context, int index) {
return const Divider(
color: Colors.grey,
thickness: 1.0,
);
},
itemCount: item.children.length,
scrollDirection: Axis.vertical,
shrinkWrap: true,
),
],
),
)
)
);
}
}
The widgets presented in this post and the previous one are easily usable within a page, similar to how you would use other widgets in Flutter. You can download the code at the following links:
- user_stories.dart: Contains the page that loads the widget defined in this series.
- items_list.dart: Contains the code shown in this series.
- sprint_selector.dart: Contains the code to generate the
DropdownButton
that allows switching between current and previous sprints.
I hope you enjoyed this post, if you want to share it to your contacts on the social media, you can do it by clicking on the social icons at the beginning of this post, otherwise you can copy the url and share it wherever you like.
If you want to reach me, you will find all my contacts in the About page. Sooner or later I will enable comments on my posts, in the meantime, if you have questions, contact me, I’ll be happy to answer.