flutter doctor -v
flutter create vaxxometer
cd vaxxometer
Acceptance Criteria:
- Vaccination Data is fetched from RKI servers: https://rki-vaccination-data.vercel.app/api
- If data is loaded display it as a list
- If data is being fetched display loading spinner
- If data failed to load, display error message
- Add button allowing toggling between alphabetical order and order based on vaccination progress
- By default sort list in alphabetical order
- Tapping on an item should display a tapped item in detail view (see screenshot)
"Design spec":
Hot reload and behold.
https://flutter.dev/docs/cookbook/networking/fetch-data
Visit pub.dev, and copy-paste latest version of http package into your pubspec file.
Don't forget about necessary permissions for the platform you're developing for:
You might need to cold restart the app after chaning platform stuff.
Copy code snippet from this web page (including import)
https://flutter.dev/docs/cookbook/networking/fetch-data
Modify it to use with the RKI endpoint:
Future<http.Response> fetchData() {
return http.get('https://rki-vaccination-data.vercel.app/api');
}
void main() {
fetchData().then((value) => print(value.body));
runApp(MyApp());
}
HOT RESTART the app and check debug console if the request response is logged:
First inspect the response in json editor to see what you will need:
we need a class, e.g. VaccineStatus that has following fields
- total, integer, number of people
- rs, unsure, let's ignore this one
- vaccinated, integer, number of people
- difference_to_the_previous_day, integer, number of people
- quote, float, looks like vaccinations per 100 people
This is what this class looks like in dart:
class VaccineStatus {
const VaccineStatus(
{this.total,
this.vaccinated,
this.difference_to_the_previous_day,
this.quote});
final int total;
final int vaccinated;
final int difference_to_the_previous_day;
final double quote;
}
We need to add a factory method to parse the following json:
{"total": 3644826, "rs": "11", "vaccinated": 24159, "difference_to_the_previous_day": 2204, "vaccinations_per_1000_inhabitants": 6.583746901136969, "quote": 0.66}
Let's quickly add that factory and then a test method:
factory VaccineStatus.fromJson(Map<String, dynamic> json) {
return VaccineStatus(
total: json['total'],
vaccinated: json['vaccinated'],
difference_to_the_previous_day: json['difference_to_the_previous_day'],
quote: json['quote'],
);
}
test('json parsing Berlin', () {
final mock_data_berlin_decoded = jsonDecode(mock_data_berlin);
final berlinStatus = VaccineStatus.fromJson(mock_data_berlin_decoded);
expect(berlinStatus.total, 3644826);
expect(berlinStatus.vaccinated, 24159);
expect(berlinStatus.quote, 0.66);
});
The VaccineStatus
doesn't contain name of the state, instead the name of the state is used as a key in the json map. This is rather inconvienent but nothing that we cannot handle.
Let's create a class for that:
class StateEntry {
StateEntry({this.status, this.name});
final VaccineStatus status;
final String name;
}
List<StateEntry> parseResponse(String jsonStr) {
throw UnimplementedError();
}
test('make a list from json', () {
final states = parseResponse(full_json);
expect(states.isNotEmpty, true);
});
List<StateEntry> parseResponse(String jsonStr) {
final json = jsonDecode(jsonStr);
final statesMap = json["states"] as Map<String, dynamic>;
return statesMap.keys.map((key) {
final vaccineStatusJson = statesMap[key];
return StateEntry(
status: VaccineStatus.fromJson(vaccineStatusJson),
name: key,
);
}).toList();
}
Future<List<StateEntry>> fetchData() async {
final response =
await http.get('https://rki-vaccination-data.vercel.app/api');
if (response.statusCode == 200) {
// If the server did return a 200 OK response,
// then parse the JSON.
return parseResponse(response.body);
} else {
// If the server did not return a 200 OK response,
// then throw an exception.
throw Exception('Failed to load vaccination data');
}
}
We want to display a loading state for our list, the list or error. We'll use FutureBuilder widget for this (explainer in video below):
body: FutureBuilder<List<StateEntry>>(
future: fetchData(),
builder: (context, snapshot) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headline4,
),
],
),
);
}
),
...and remove other fetch from main
FutureBuilder<List<StateEntry>>(
future: fetchData(),
builder: (context, snapshot) {
// an error occured
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'An error occured ${snapshot.error}',
),
],
),
);
}
// there's no data yet
if (!snapshot.hasData) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
CircularProgressIndicator(),
Text(
'Loading',
),
],
),
);
}
// we have data
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'We have data ${snapshot.data}',
),
],
),
);
})
We have a list of elements, it would be cool if we displayed them as a scrollable list.
// we have data
final items = snapshot.data;
return ListView.builder(
itemBuilder: (context, index) => Text(items[index].name),
itemCount: items.length,
);
class StateEntryWidget extends StatelessWidget {
final StateEntry entry;
const StateEntryWidget({Key key, this.entry}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(entry.name);
}
}
class StateEntryWidget extends StatelessWidget {
final StateEntry entry;
const StateEntryWidget({Key key, this.entry}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: [
Column(
children: [
Text(entry.name),
Text(
"${entry.status.vaccinated} out of ${entry.status.total} vaccinted"),
],
),
Text("${entry.status.quote}")
],
);
}
}
class StateEntryWidget extends StatelessWidget {
final StateEntry entry;
const StateEntryWidget({Key key, this.entry}) : super(key: key);
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.name,
style: Theme.of(context).textTheme.headline5,
),
Text(
"${entry.status.vaccinated} out of ${entry.status.total} vaccinted"),
],
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"${entry.status.quote}%",
style: Theme.of(context).textTheme.headline4,
),
)
],
);
}
}
We want to be able to sort:
- byName
- byQuota
- byVaccinatedCount
List<StateEntry> sortByQuotaDesc(List<StateEntry> input) {
final output = List<StateEntry>.from(input);
output.sort((a, b) => b.status.quote.compareTo(a.status.quote));
return output;
}
List<StateEntry> sortByVaccinatedDesc(List<StateEntry> input) {
final output = List<StateEntry>.from(input);
output.sort((a, b) => b.status.vaccinated.compareTo(a.status.vaccinated));
return output;
}
List<StateEntry> sortByNameAsc(List<StateEntry> input) {
final output = List<StateEntry>.from(input);
output.sort((a, b) => a.name.compareTo(b.name));
return output;
}
https://dart.dev/guides/language/extension-methods
extension StateEntrySortingExtensions on List<StateEntry> {
List<StateEntry> sortedByQuotaDesc() {
final output = List<StateEntry>.from(this);
output.sort((a, b) => b.status.quote.compareTo(a.status.quote));
return output;
}
List<StateEntry> sortedByVaccinatedDesc() {
final output = List<StateEntry>.from(this);
output.sort((a, b) => b.status.vaccinated.compareTo(a.status.vaccinated));
return output;
}
List<StateEntry> sortedByNameAsc() {
final output = List<StateEntry>.from(this);
output.sort((a, b) => a.name.compareTo(b.name));
return output;
}
}
We need different icons
We'll use:
sort_by_alpha
for for name sortingaccessibility/family_restroom
for vaccinated count sortingtrending_up
for quota sorting
Check with hot reload if the icons are there!
enum SortingType { byQuota, byVaccinated, byName }
Replace counter
value with sortingType, add extension method accepting enum:
List<StateEntry> sortedBy(SortingType sortingType) {
switch (sortingType) {
case SortingType.byQuota:
return sortedByQuotaDesc();
case SortingType.byVaccinated:
return sortedByVaccinatedDesc();
case SortingType.byName:
return sortedByNameAsc();
}
}
Add extension method on enum to display proper tooltip and icon for the floating action button
extension SortingTypeExt on SortingType {
IconData get iconData {
switch (this) {
case SortingType.byQuota:
return Icons.trending_up;
case SortingType.byVaccinated:
return Icons.family_restroom;
case SortingType.byName:
return Icons.sort_by_alpha;
}
}
String get tooltip {
switch (this) {
case SortingType.byQuota:
return "Sort by Percentage";
case SortingType.byVaccinated:
return "Sort by Vaccinated Count";
case SortingType.byName:
return "Sort by Name";
}
}
}
Finally make the floating action button callback update widget's state:
void _switchSortingType() {
setState(() {
final nextIndex = SortingType.values.indexOf(sortingType) + 1;
sortingType = SortingType.values[nextIndex % SortingType.values.length];
});
}
floatingActionButton: FloatingActionButton(
onPressed: _switchSortingType,
tooltip: sortingType.tooltip,
child: Icon(sortingType.iconData),
)
Wrap item with InkWell and display message saying e.g. Hello from Berlin!
using SnackBar:
class StateEntryWidget extends StatelessWidget {
final StateEntry entry;
const StateEntryWidget({Key key, this.entry}) : super(key: key);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
Scaffold.of(context)
.showSnackBar(SnackBar(content: Text("Hello from ${entry.name}!")));
},
child: Row(
/// ...
Create a screen and navigate to it instead of displaying snack bar: https://flutter.dev/docs/cookbook/navigation/navigation-basics
class SecondRoute extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Second Route"),
),
body: Center(
child: ElevatedButton(
onPressed: () {
// Navigate back to first route when tapped.
},
child: Text('Go back!'),
),
),
);
}
}
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => SecondRoute()),
);
}
Pass argument to your new route
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SecondRoute(
entry: entry,
),
),
);
},
Display place name in the app bar:
class SecondRoute extends StatelessWidget {
const SecondRoute({Key key, this.entry}) : super(key: key);
final StateEntry entry;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(entry.name),
),
/// put any kind of widget you wish here
body: Placeholder(),
);
}
}
https://docs.codemagic.io/publishing/publishing-to-codemagic-static-pages/
- Use json_serializable
- Add widget test & mock API call with a repository and get_it
- Use BLoC pattern
- Add Theme switching