Xcoding with Alfian

Software Development Videos & Tutorials

Building GitHub Flutter App — Part 2: Search Repositories List

Alt text

This article is a continuation of Building GitHub Flutter App series from Part 1 where we have built list of trending GitHub Repositories Widget using Flutter connecting to GitHub API to query the repositories with most stars in 1 week. In this part 2, we will continue to build the Search List Widget where user can search the latest trending repositories based on the keyword they enter to the textfield.

What we will build:

  1. Search List Widget is a widget where user can type their search query and display the results.
  2. API Class to provide interface for querying and returning results from GitHub API.
  3. Debounce using Dart Timer Class to delay making request to GitHub API as user is typing.

Search List Widget

Alt text

Search List Widget is a Stateful Widget based on Scaffold Material Widget consists of AppBar with a TextField Widget for the user to type and Body with a ListView Widget that lists the repositories result that is returned from GitHub API based on the search query from the user.

Search List Widget have 4 states property that is used to determine the widget that will be rendered in the screen. The 4 states will render the body based on their precedence by number are:

  1. _isSearching is a boolean object that represents the request to GitHub API is in progress and waiting for the response. If it is set to true, the body will render a Container Text Widget that displays “Searching Github…”.
  2. _error is a String object that represents the error we received when making request to GitHubAPI. If it is set to true, the body will render a Container Text Widget that displays the error text.
  3. _searchQuery is a TextController Object that acts as the state for the search query text and also as a listener for the TextField Widget that will be called when the user types, here we will use the debounce technique to delay making call to Github API as user types (More on this at the debounce section below). If the search query text is empty, the body will render a Container Text Widget that displays the placeholder text “Begin search by typing on search bar”.
  4. _results is a list of Repo Object that we get from making request to GitHub API. If all the conditions above are not met. The body will render a Dynamic ListView that renders the GitHubItem Widget representing the Repo details.
import 'package:flutter/material.dart';
import 'package:github_search/api.dart';
import 'package:github_search/repo.dart';
import 'package:github_search/item.dart';
import 'dart:async';

class SearchList extends StatefulWidget {
  SearchList({Key key}) : super(key: key);

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

class _SearchState extends State<SearchList> {
  final FocusNode myFocusNode = new FocusNode();

  final key = GlobalKey<ScaffoldState>();
  final TextEditingController _searchQuery = TextEditingController();
  bool _isSearching = false;
  String _error;
  List<Repo> _results = List();

  Timer debounceTimer;

  _SearchState() {
    _searchQuery.addListener(() {
      if (debounceTimer != null) {
        debounceTimer.cancel();
      }
      debounceTimer = Timer(Duration(milliseconds: 500), () {
        if (this.mounted) {
          performSearch(_searchQuery.text);
        }
      });
    });
  }

  void performSearch(String query) async {
    if (query.isEmpty) {
      setState(() {
        _isSearching = false;
        _error = null;
        _results = List();
      });
      return;
    }

    setState(() {
      _isSearching = true;
      _error = null;
      _results = List();
    });

    final repos = await Api.getRepositoriesWithSearchQuery(query);
    if (this._searchQuery.text == query && this.mounted) {
      setState(() {
        _isSearching = false;
        if (repos != null) {
          _results = repos;
        } else {
          _error = 'Error searching repos';
        }
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        key: key,
        appBar: AppBar(
          centerTitle: true,
          title: TextField(
            autofocus: true,
            controller: _searchQuery,
            style: TextStyle(color: Colors.white),
            decoration: InputDecoration(
                border: InputBorder.none,
                prefixIcon: Padding(
                    padding: EdgeInsetsDirectional.only(end: 16.0),
                    child: Icon(
                      Icons.search,
                      color: Colors.white,
                    )),
                hintText: "Search repositories...",
                hintStyle: TextStyle(color: Colors.white)),
          ),
        ),
        body: buildBody(context));
  }

  Widget buildBody(BuildContext context) {
    if (_isSearching) {
      return CenterTitle('Searching Github...');
    } else if (_error != null) {
      return CenterTitle(_error);
    } else if (_searchQuery.text.isEmpty) {
      return CenterTitle('Begin Search by typing on search bar');
    } else {
      return ListView.builder(
          padding: EdgeInsets.symmetric(vertical: 8.0),
          itemCount: _results.length,
          itemBuilder: (BuildContext context, int index) {
            return GithubItem(_results[index]);
          });
    }
  }
}

class CenterTitle extends StatelessWidget {
  final String title;

  CenterTitle(this.title);

  @override
  Widget build(BuildContext context) {
    return Container(
        padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0),
        alignment: Alignment.center,
        child: Text(
          title,
          style: Theme.of(context).textTheme.headline,
          textAlign: TextAlign.center,
        ));
  }
}

API Class — Github Search Query

Based on API Class from Part 1, now we will add additional method that provide the interface that accepts query string, then making call to query the GitHub API using the supplied parameter, and return the results as a list of Repo Dart object. The results are sorted by most stars in descending and limit of 25 repos per page.

import 'package:github_search/repo.dart';
import 'package:date_format/date_format.dart';

import 'dart:convert' show json, utf8;
import 'dart:io';
import 'dart:async';

class Api {
  static final HttpClient _httpClient = HttpClient();
  static final String _url = "api.github.com";

  static Future<List<Repo>> getRepositoriesWithSearchQuery(String query) async {
    final uri = Uri.https(_url, '/search/repositories', {
      'q': query,
      'sort': 'stars',
      'order': 'desc',
      'page': '0',
      'per_page': '25'
    });

    final jsonResponse = await _getJson(uri);
    if (jsonResponse == null) {
      return null;
    }
    if (jsonResponse['errors'] != null) {
      return null;
    }
    if (jsonResponse['items'] == null) {
      return List();
    }

    return Repo.mapJSONStringToList(jsonResponse['items']);
  }

  static Future<Map<String, dynamic>> _getJson(Uri uri) async {
    try {
      final httpRequest = await _httpClient.getUrl(uri);
      final httpResponse = await httpRequest.close();
      if (httpResponse.statusCode != HttpStatus.OK) {
        return null;
      }

      final responseBody = await httpResponse.transform(utf8.decoder).join();
      return json.decode(responseBody);
    } on Exception catch (e) {
      print('$e');
      return null;
    }
  }
}

Debouncing Before Making Call to API

Debounce using Dart Timer Class is a technique we will use to delay the request to GitHub API by 500 milliseconds as users typing, so we don’t make to many request to the GitHub API as users are typing the keyword they want to search.

In Search List Widget we also have additional property called debounceTimer which is a Timer type Dart Object, this state will be used to track, cancel, and perform the debounce delay of the request to the API.

_SearchState() {
    _searchQuery.addListener(() {  
        if (debounceTimer != null) {    
        debounceTimer.cancel();    
        } 
        debounceTimer = Timer(Duration(milliseconds: 500), () {    
            if (this.mounted) {       
                performSearch(_searchQuery.text);     
            }
        });  
    });
}

When the SearchState object is initialised in SearchList Widget, we add listener to the _searchQuery TextController object that will listen for the input event as the user typing in the TextField. When the event fires, we check if there is a current Timer event and cancel the event. Then we set a new Timer with a duration of 500 milliseconds and pass a performSearch function that will be called when the timer fires. We also check the widget mounted property to check if the widget is currently mounted so we don’t call setState on unmounted widget.

void performSearch(String query) async { 
    if (query.isEmpty) {  
        setState(() {   
            _isSearching = false; 
            _error = null;    
            _results = List();  
        });   
        return;  
    }
    
    setState(() {  
        _isSearching = true;
        _error = null;  
        _results = List();  });
        final repos = await Api.getRepositoriesWithSearchQuery(query); 
        if (this._searchQuery.text == query && this.mounted) {  
            setState(() {    
            _isSearching = false;  
            if (repos != null) {     
                _results = repos;   
            } else {     
                _error = 'Error searching repos'; 
            } 
        });
    }
}

performSearch is an async method that acts a method that set the search widget list _isSearching state before making a request to the GitHub API. We use await when calling API Class getRepositoriesWithSearchQuery that returns a Future object to make the the method more readable.

After the results is received, we check the current _searchQuery text is match with the query we pass to the method in case of user update the keyword to search. Then if match and Widget is mounted, we update the state.

This time we check if the repos result from the response exists we assign it to the results state and if not we set the error state text to “error searching repos” indicating an error has occured in the request. We also set the _isSearching state to false to indicate we have finished the search request.

Conclusion

This part 2 of the series concludes our Building Github Flutter App series. In this part we have successfully create the Search List Widget where user can search the latest repositories by keyword from GitHub API. To see the completed code you can visit the GitHub Repository for this project. Keep on Fluttering and stay tune for more building app guide for Flutter in the near future!.