Lab 11: RepoBrowser (Flutter)
Due Date: December 01
Objectives
- Learn the basics of Flutter Programing from Scratch
- Experience working with Widgets
- Practice building hybrid apps for iOS, Android, and Web (Chrome)
Due Date: December 01
Objectives
If you have not done so already install Flutter Follow the installation instructions. This may take 20-30 minutes, so please be prepared. If you haven't already, you will want to install one of the preferred IDE's like VS Code and associated Flutter & Dart plugins. (Aside: To build for Chrome you will need to switch to Flutter's beta channel.)
Run flutter doctor
to make sure everything is installed correctly. Ideally you should get the following output:
~>
install the plugins in Android Studio, then see this bugfix
~>
start the iOS simulator with flutter emulators --launch ios
flutter_test
. In VS Code, this can be done easily with View > Command Palette > Flutter: New Project
. After the code auto-generates, look at the lower right hand bar and see which device is selected; in these labs I will be choosing an iOS simulator because that's what I have and what I teach in other classes. Then run this generic project by pushing F5
or using VS Code option of Run > Run without debugging
. Assuming everything is set up, you should see something like this:In this first part, we are just going to set up a few things to the project that are good practices when we build our app for the course project. As discussed in class, we will separate out our code into smaller, more manageable and understandable, components.
First thing is to create a new Flutter project as you have previously. To make life a little easier, we are going to just clear out all the contents of main.dart
after the class MyApp
; that is, get rid of the stateful widget and its associated state. This will get rid of the demo code and give us a cleaner slate to work with.
We begin by creating some folders in the lib
directory to contain these components. Create folders for models
, views
, and utils
.
The utils
folder is where we will keep utility-type files that can be used throughout the app to help with certain tasks. In this case we will create two such files. The first is a strings.dart
file with the following content: const appTitle = 'Repo Browser';
. That's it for this file, but if we had other strings in the project that were appearing in multiple places, this is where they would go. The nice thing would be that any modifications we wanted to make to strings here would apply throughout the app, keeping the interface consistent.
The second file we need is styles.dart
and here we want to define two styles -- one larger and bolder for the repo titles and another smaller for the descriptions. Add in the following:
import 'package:flutter/material.dart';
final repoNameFont = const TextStyle(
fontSize: 20.0, fontWeight: FontWeight.bold);
final descriptionFont =
const TextStyle(fontSize: 14.0, fontWeight: FontWeight.normal);
Next up is models
. Here we will add a file called repo.dart
and add the following struct that will define the essential elements of a repository for us:
class Repo {
const Repo(this.name, this.htmlURL, this.description);
final String name;
final String htmlURL;
final String description;
}
Let's go back to main.dart
to make a few quick changes. At the top of the file after the initial import, add the following: import 'package:repo_browser/utils/strings.dart' as strings;
. Now use those strings by changing the title in MyApp
to title: strings.appTitle,
. While we are at it, get rid of the debug banner and change the color to something other than blue. Finally, reset the home to: home: new ListRepos(),
and you're all set.
Oh dear, we have an error. We haven't actually created the ListRepos
class yet, so let's do that in Part 2.
Go to the views
directory and add in the file list_repos.dart
. In that new file, let's add the following skeleton code:
import 'package:flutter/material.dart';
import 'package:repo_browser/utils/strings.dart' as strings;
// The basic widget
class ListRepos extends StatefulWidget {
const ListRepos({Key? key}) : super(key: key);
@override
_ListReposState createState() => _ListReposState();
}
// Actual state for the widget
class _ListReposState extends State<ListRepos> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(strings.appTitle),
),
);
}
}
Notice again we are using our constant string for appTitle
here after we import the file. Now having this skeleton in place, we should be able to go back to main.dart
and import this file to get the class and remove the error message. Run this app and see that all the basics are in place and it runs.
Back to the list_repos.dart
file, we want to go to the _ListReposState
and add in a variable called _repos
which is an empty array (set the type to dynamic with <dynamic>[]
)
Now is the tricky part. We want to create a Future
which we said in class is an object representing a delayed computation and that Future will make a call to the GitHub API to retrieve popular repos and use that data to populate the _repos
we just created. To start that process, we first add the following code to our list of import statements:
import 'dart:convert';
import 'package:http/http.dart' as http;
When we do this, we get an error message because the http package hasn't been added to our dependencies list. To do that, go to pubspec.yaml
and to the list of dependencies, add http: ^0.13.3
(do this after the flutter
dependency). It should look like this:
dependencies:
flutter:
sdk: flutter
http: ^0.13.3
If you are using VS Code and it is configured with the proper extensions, then saving this file will automatically run flutter pub get
, but if not you can run it yourself in the terminal.
Next, we have to use this http
library to actually GET
to get the data from GitHub with the following:
Future<void> _loadData() async {
const dataUrl =
'https://api.github.com/search/repositories?q=language%3Apython&sort=stars&order=desc';
final response = await http.get(Uri.parse(dataUrl));
}
As we said in class, this will make an asynchronous call to GitHub to retrieve some data. Look at this data right now by putting https://api.github.com/search/repositories?q=language%3Apython&sort=stars&order=desc
in your web browser. (Aside: if you don't already have the JSON Formatter extension for Chrome installed, this might be a good time.) We have this data, but now we need to do something with it.
Inside the _loadData()
method, we want to include setState()
and it is here that we will decode and parse the JSON data into something we can use. Let's start with:
setState(() {
final Map<String, dynamic> repoMap = json.decode(response.body);
});
Now we don't want everything, just the data to make a list of repos. Add to this List<dynamic> data
and set it equal to the element of repoMap
that contains the actual data of individual repos. (This will be clear if you did the previous step and looked at the raw data in the browser.)
Having this data
list, we now want to loop through it (for loop works well here) and for each case, we want to get the key data and add it to a Repo
object and then add that object to our own _repos
list. The following can work as part of your for
loop:
final name = item['name'] as String? ?? '';
final url = item['html_url'] as String? ?? '';
final description = item['description'] as String? ?? 'N/A';
final repo = Repo(name, url, description);
_repos.add(repo);
You are getting an error here because you haven't imported the model, so be sure to do that. ;-)
Do you remember why we are using String? ?? ''
or String? ?? 'N/A'
in this code? If so, post your answer in the #labs
channel of our Slack workspace. If someone has posted the fully correct answer, just give it a thumbs up and you will be given the same bonus credit. However, if their answer is not as complete (or just wrong), please add your own answer in to get the bonus credit. (Note: to be clear, you must respond on Slack to get full credit for the lab, but everyone who writes/selects the correct answer gets a bonus.)
Now that we have this code, we actually need to call it. In our state object, we will use the initState()
method that is called on initialization to run our method. Do this with the following:
@override
void initState() {
super.initState();
_loadData();
}
Time to eat cookies.
Now that we have data, we need to display that in a ListView
widget, but first we need a custom widget that will describe what will be in each list item. We can do that with the following:
Widget _buildRow(int i) {
return Stack(children: <Widget>[
// RepoTitle CONTAINER
Container(
padding: EdgeInsets.only(left: 10.0, right: 37.0, top: 5.0),
child: Text('${_repos[i].name}', style: styles.repoNameFont),
),
// RepoDescription CONTAINER
Container(
padding:
EdgeInsets.only(left: 19.0, right: 37.0, top: 51.0, bottom: 5.0),
child: Text('${_repos[i].description}', style: styles.descriptionFont),
),
]);
}
Oops, this is giving us an error because we haven't imported styles.dart
as styles
(similar to what we did with strings
). Fix that now.
With that custom widget in place, go to the state's build()
method and after the appBar
add in a body which creates the list view and uses our custom widget:
body: ListView.separated(
itemCount: _repos.length,
itemBuilder: (BuildContext context, int position) {
return _buildRow(position);
},
separatorBuilder: (context, index) {
return const Divider();
}),
If you run the project, you should have a working list of popular Python repos. ::fistbump::
While this is nice, it'd be even nicer to click on the repo title and go to a page showing the repo on GitHub. First step is to convert the repo title container from Text
to a TextButton
. This can be done by replacing that container with the following:
Container(
padding: EdgeInsets.only(left: 10.0, right: 37.0, top: 5.0),
child: TextButton(
child: Text('${_repos[i].name}', style: styles.repoNameFont),
onPressed: () {
String htmlURL = _repos[i].htmlURL;
Navigator.push(
context,
new MaterialPageRoute(
builder: (BuildContext context) =>
new ShowRepo(htmlURL)));
},
)
),
Of course, once more, we have an error because there is no ShowRepo()
class. Let's add a new file to the views
called show_repo.dart
and add the following base code:
import 'package:flutter/material.dart';
class ShowRepo extends StatelessWidget {
final String htmlURL;
ShowRepo(this.htmlURL);
@override
Widget build(BuildContext context) {
print("Show Repo Details");
return new Scaffold(
appBar: new AppBar(
title: new Text("Repository Details"),
),
);
}
}
Now go back to list_repos.dart
and import this new file and the error should go away. Reload the project and clicking on the title should take us to a mostly empty page. One more thing, let's make the repo titles stand out more and signal to the user they are clickable by changing their color to the same color as the app header. (Now where would I go to change the repo title style so that anywhere the style was used, it would be changed? Hmmm...)
Back to our ShowRepo()
class to add in a web view in the body. Before we can do this, we need to add in the web view plugin from Flutter. Go back to pubspec.yaml
and add to the dependencies after 'http', webview_flutter: ^2.0.12
and save (or rerun flutter pub get
) to install.
Gotcha with Macs: to use this web view plugin, you need to install the Cocoapods gem (many Flutter plugins use cocoapods, so good to have installed). Do that with gem install cocoapods
. After that comes the trick. You need to go to the ios/Runner
directory, open Info.plist
and near the end (after UIViewControllerBasedStatusBarAppearance
is set to false, but before </dict>
) and the following:
<key>io.flutter.embedded_view_preview</key>
<string>YES</string>
Sadly, this is poorly documented, but necessary for this to work on a Mac.
With this plugin installed, add it to show_repo.dart
with import 'package:webview_flutter/webview_flutter.dart';
Now we can add to the Scaffold
after the appTitle
the following to create the web view:
body: Container(
width: double.infinity,
// the most important part of this example
child: WebView(
initialUrl: htmlURL,
// Enable Javascript on WebView
javascriptMode: JavascriptMode.unrestricted,
)),
Test it out and see that it is working. Qapla'