Handling User Input — Flutter Tutorial Part 4

Hoarfroster
9 min readJan 14, 2021

In the Landscape application, a user can flag their favorite places, and filter the list to show just their favorites. He / She can also give rating to the landscape. To create this feature, you’ll start by adding a switch to the list so users can focus on just their favorites, and then you’ll add a heart-shaped button that a user taps to flag a landscape as a favorite, and you will later add five star-shaped buttons that the user can tap to give his / her rating.

Let’s simply use the completed project from the previous tutorial to get started.

Mark the User’s Favorite Landscapes

Begin by enhancing the list to show users their favorites at a glance. Add two properties to the LandscapeModel to read the initial state of a landscape as a favorite, and then add a heart-shaped button to each LandscapeItem that shows a favorite landscape.

Add properties to LandscapeModel

Open the starting point Xcode project or the project you finished in the previous tutorial, and select landscapeModel.dart in the Project navigator.

final double rating;
final bool favorite;

And then add these two final fields to the constructor:

LandscapeModel(this.name, this.country, this.park, this.assetPath, this.rating, this.favorite);

Then add these properties to the instance getModels :

static List<LandscapeModel> get getModels {
return [
LandscapeModel("Lake Tekapo", "New Zealand", "Lake Tekapo Regional Park",
"assets/LakeTekapo.JPG", 4.8, false),
LandscapeModel("Mt Cook", "New Zealand", "Aoraki/Mount Cook National Park",
"assets/MtCook.JPG", 4.4, false),
LandscapeModel("Seville Plaza de España", "Spain", "Plaza de España",
"assets/Seville-Plaza-de-España.JPG", 4.4, false),
LandscapeModel("Whampoa Pagoda", "China", "Whampoa Pagoda",
"assets/WhampoaPagoda.JPG", 4.9, true),
];
}

Add rating bar to the item

In order to show the rating of the landscape, we need to add a rating bar containing 5 star-shaped icons.

A graphical icon widget drawn with a glyph from a font described in an IconData such as material’s predefined IconDatas in Icons. Icons are not interactive. For an interactive icon, consider material’s IconButton.

There must be an ambient Directionality widget when using Icon. Typically this is introduced automatically by the WidgetsApp or MaterialApp. This widget assumes that the rendered icon is squared. Non-squared icons may render incorrectly.

Here we are going to use the Material Icons to display the ratings:

Now create a new file ratingStar.dart :

class RatingStar extends StatelessWidget {
final Rating state;

const RatingStar(this.state);

@override
Widget build(BuildContext context) {
return Icon(
state == Rating.zero
? Icons.star_outline
: state == Rating.half
? Icons.star_half
: Icons.star,
color: Colors.amber);
}
}

enum Rating { zero, half, full }

Enums are an essential part of programming languages. They help developers define a small set of predefined set of values that will be used across the logics they develop. Here we use a enum to return a specific star shape.

And the ratingBar.dart :

class RatingBar extends StatelessWidget {
final double rating;

const RatingBar(this.rating);

@override
Widget build(BuildContext context) {
List<Widget> w = [];
double r = rating;
int i = 0;
while (i < 5) {
if (r >= 0.75)
w.add(RatingStar(Rating.full));
else if (r >= 0.25)
w.add(RatingStar(Rating.half));
else
w.add(RatingStar(Rating.zero));
r--;
i++;
}
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: w);
}
}

Let’s add the RatingBar into LandscapeItem:

...

Column(children: [
Text(model.name),
Text("${model.park} - ${model.country}"),
RatingBar(model.rating)
], crossAxisAlignment: CrossAxisAlignment.start)
...

Here we go. But the result is not so satisfied… We need to make some adjustments to beautify the layout. We will use TextStyle class to change the size and weight of the text and we will change the size of the icons.

return Icon(
state == Rating.zero
? Icons.star_outline
: state == Rating.half
? Icons.star_half
: Icons.star,
color: Colors.amber, size: 12);

And change the size and the weight of the Text widgets:

@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(4),
child: Row(children: [
RoundImage(
img: AssetImage(model.assetPath),
width: 48,
height: 48,
radius: 8),
SizedBox(width: 8),
Column(children: [
Text(model.name,
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
Row(children: [
Text("${model.park} - ${model.country}",
style: TextStyle(fontSize: 12)),
SizedBox(width: 4),
RatingBar(model.rating)
]),
], crossAxisAlignment: CrossAxisAlignment.start)
]));
}

That’s awesome!

And let’s add some widgets in the detail page:)

We are going to add a heart-shaped IconButton for users to favourite the landscape. We will also add the RatingBar into the detail page.

Bottom Container

We decide to make a bottom container in our detail page. Let’s start with creating bottomContainer.dart . Because the heart-shaped button can be toggle we need to make this widget Stateful.

class BottomContainer extends StatefulWidget {
final LandscapeModel model;

const BottomContainer(this.model);

@override
State<StatefulWidget> createState() => BottomContainerState();
}

class BottomContainerState extends State<BottomContainer> {
@override
Widget build(BuildContext context) {
...
}
}

And add a Container to the BottomContainer:

return Container(
height: 120,
padding: EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.model.name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Row(children: [
Text(widget.model.park + ", " + widget.model.country),
SizedBox(width: 8),
RatingBar(widget.model.rating)
])
]));

Awesome, but let’s give the Container a backgroundColor and radiusCorners with BoxDecoration :

decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(8))),

And we need to add a heart-shaped button to the right side of the information. Before adding any widgets to the page, we need to generate an instance for changing the value of favourite in the LandscapeModel :

void setFavorite() {
this.favorite = !this.favorite;
}

Wrap the Column above with a Row and add a Spacer (Spacer creates an adjustable, empty spacer that can be used to tune the spacing between widgets in a Flex container, like Row or Column.) and a IconButton :

return Container(
height: 120,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(4))),
child: Column(children: [
Row(children: [
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.model.name,
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Row(children: [
Text(widget.model.park + ", " + widget.model.country),
SizedBox(width: 8),
RatingBar(widget.model.rating)
])
]),
Spacer(),
Expanded(
child: IconButton(
icon: Icon(
widget.model.favorite
? Icons.favorite
: Icons.favorite_border,
color: Colors.redAccent),
onPressed: () {
setState(() {
widget.model.setFavorite();
});
}))
])
]));

The instance setState is called here to notify the framework that the internal state of this object has changed. Whenever you change the internal state of a State object, make the change in a function that you pass to setState:

setState(() { _myState = newValue; });

The provided callback is immediately called synchronously. It must not return a future (the callback cannot be async), since then it would be unclear when the state was actually being set.

Calling setState notifies the framework that the internal state of this object has changed in a way that might impact the user interface in this subtree, which causes the framework to schedule a build for this State object.

If you just change the state directly without calling setState, the framework might not schedule a build and the user interface for this subtree might not be updated to reflect the new state.

Ok, so let’s refresh our page:

Now when you press the IconButton , the icon will automatically change because of the page is a StatefulWidget .

Filter the List View

You can customize the list view so that it shows all of the landmarks, or just the user’s favorites. To do this, you will need to use .filter instance of the List .

First open landscapeModel.dart and add a property index :

...
int index;
...
LandscapeModel(this.index, this.name, this.country, this.park, this.assetPath, this.rating, this.favorite);

static List<LandscapeModel> get getModels {
return [
LandscapeModel(0, "Lake Tekapo", "New Zealand",
"Lake Tekapo Regional Park", "assets/LakeTekapo.JPG", 4.8, false),
LandscapeModel(1, "Mt Cook", "New Zealand",
"Aoraki/Mount Cook National Park", "assets/MtCook.JPG", 4.4, false),
LandscapeModel(2, "Seville Plaza de España", "Spain", "Plaza de España",
"assets/Seville-Plaza-de-España.JPG", 4.4, false),
LandscapeModel(3, "Whampoa Pagoda", "China", "Whampoa Pagoda",
"assets/WhampoaPagoda.JPG", 4.9, true),
];
}

Get back to listPage.dart and change the widget ListPage to StatefulWidget . Add a ListPageState class and move all the widgets inside the ListPage and ListPageContent widget to the ListPageState.

// ListPageState
...
bool favoriteOnly = false;
List<LandscapeModel> models;

@override
Widget build(BuildContext context) {
models = LandscapeModel.getModels.where((e) {
return favoriteOnly ? e.favorite : true;
}).toList();
...
}
...

and add a property actions in the Scaffold’s appBar :

appBar: AppBar(
title:
Text("Landscape List", style: TextStyle(color: Colors.black)),
actions: [
IconButton(
icon: Icon(
this.favoriteOnly ? Icons.clear_all : Icons.filter_alt,
color: Colors.black54),
onPressed: () {
setState(() {
this.favoriteOnly = !this.favoriteOnly;
});
},
)
],
brightness: Brightness.light,
backgroundColor: Colors.white,
centerTitle: false),

Then run the application:

We make it!

But we still need to store the data our users input. We need to determined whether the landscape is favorited the last time user open the application.

Store key-value data on disk

If you have a relatively small collection of key-values to save, you can use the shared_preferences plugin.

Normally, you would have to write native platform integrations for storing data on both iOS and Android. Fortunately, the shared_preferences plugin can be used to persist key-value data on disk. The shared preferences plugin wraps NSUserDefaults on iOS and SharedPreferences on Android, providing a persistent store for simple data.

This recipe uses the following steps:

  1. Add the dependency.
  2. Save data.
  3. Read data.
  4. Remove data.

1. Add the dependency

Before starting, add the shared_preferences plugin to the pubspec.yaml file:

dependencies:
flutter:
sdk: flutter
shared_preferences: "<newest version>"

2. Save data

To persist data, use the setter methods provided by the SharedPreferences class. Setter methods are available for various primitive types, such as setInt, setBool, and setString.

Setter methods do two things: First, synchronously update the key-value pair in-memory. Then, persist the data to disk.

// obtain shared preferences
final prefs = await SharedPreferences.getInstance();
// set value
prefs.setInt('counter', counter);

Here we will apply changes to the data once our user updates his / her preferences:

void setFavorite() {
this.favorite = !this.favorite;
final prefs = await SharedPreferences.getInstance();
prefs.setString('landscapes', ...);
}

Wait… how to save a List<LandscapeModel> ?

LandscapeModel.fromJson(Map<String, dynamic> json)
: index = json['index'],
name = json['name'],
country = json['country'],
park = json['park'],
assetPath = json['assetPath'],
rating = json['rating'],
favorite = json['favorite'];

Map<String, dynamic> toJson() => {
"index": index,
"name": name,
"country": country,
"park": park,
"assetPath": assetPath,
"rating": rating,
"favorite": favorite
};

static Future<List<LandscapeModel>> get getModels async {
List<LandscapeModel> models = [];
jsonDecode((await SharedPreferences.getInstance()).getString("landscapes"))
.forEach((e) {
models.add(LandscapeModel.fromJson(e));
});
return models;
}

@override
String toString() {
return '{"index": $index, "name": "$name", "country": "$country", "park": "$park", "assetPath": "$assetPath", "rating": $rating, "favorite": $favorite}';
}

And it works properly…? Ohh we need to set the default data into the SharedPreferences .

class LandscapeApplication extends StatelessWidget {
@override
Widget build(BuildContext context) {
() async {
SharedPreferences sp = await SharedPreferences.getInstance();
if (sp.getString("landscapes") == null)
sp.setString(
"landscapes",
[
LandscapeModel(0, "Lake Tekapo", "New Zealand", "Lake Tekapo Regional Park", "assets/LakeTekapo.JPG", 4.8, false),
LandscapeModel(1, "Mt Cook", "New Zealand", "Aoraki/Mount Cook National Park", "assets/MtCook.JPG", 4.4, false),
LandscapeModel(2, "Seville Plaza de España", "Spain", "Plaza de España", "assets/Seville-Plaza-de-España.JPG", 4.4, false),
LandscapeModel(3, "Whampoa Pagoda", "China", "Whampoa Pagoda", "assets/WhampoaPagoda.JPG", 4.9, true),
].toString());
}();
...
}

And we have to change the build of the ListPageState and the DetailPage

ListPageState

return FutureBuilder<List<LandscapeModel>>(
future: LandscapeModel.getModels,
builder: (BuildContext context, AsyncSnapshot<List<LandscapeModel>> snapshot) {
if (snapshot.hasData) {
models = snapshot.data.where((e) {
return favoriteOnly ? e.favorite : true;
}).toList();
return Scaffold(
appBar: AppBar(
title: Text("Landscape List", style: TextStyle(color: Colors.black)),
actions: [
IconButton(
icon: Icon(this.favoriteOnly ? Icons.clear_all : Icons.filter_alt, color: Colors.black54),
onPressed: () {
setState(() {
this.favoriteOnly = !this.favoriteOnly;
});
},
)
],
brightness: Brightness.light,
backgroundColor: Colors.white,
centerTitle: false),
body: Container(
width: double.infinity,
height: double.infinity,
padding: EdgeInsets.symmetric(vertical: 8),
child: ListView.builder(
itemCount: models.length,
itemBuilder: (BuildContext context, int index) {
return InkWell(
child: LandscapeItem(models[index]),
onTap: () {
openDetailPage(context, models[index].index);
});
})));
} else
return
Scaffold(body: SizedBox());
});

DetailPage

return FutureBuilder<List<LandscapeModel>>(
future: LandscapeModel.getModels,
builder: (BuildContext context,
AsyncSnapshot<List<LandscapeModel>> snapshot) {
if (snapshot.hasData) {
LandscapeModel model = snapshot.data[index];
return Scaffold(
body: Stack(children: [
Image(
width: double.infinity,
height: double.infinity,
image: AssetImage(model.assetPath),
fit: BoxFit.cover),
Positioned(
bottom: 0, left: 0, right: 0, child: BottomContainer(model))
]));
} else
return
Scaffold(body: SizedBox());
});

It works properly!

Conclusion

Yes the application now can handle user’s input and save them!

Thanks for reading.

--

--