Handling User Input — Flutter Tutorial Part 4

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

Add properties to LandscapeModel

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

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

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

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

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

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

2. Save data

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

Thanks for reading.

Cheers ψ(`∇´)ψ~ 这里是苏苏的企鹅~