Github Search example
A simple Github Search example that shows the usage of a Resource
and the Solid
widget.
See the code here.
Let's start seeing how the services are strucuted:
import 'dart:async';
import 'dart:convert';
import 'package:github_search/models/models.dart';
import 'package:http/http.dart' as http;
class GithubClient {
GithubClient({
http.Client? httpClient,
this.baseUrl = 'https://api.github.com/search/repositories?q=',
}) : httpClient = httpClient ?? http.Client();
final String baseUrl;
final http.Client httpClient;
Future<SearchResult> search(String term) async {
final response = await httpClient.get(Uri.parse('$baseUrl$term'));
final results = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 200) {
return SearchResult.fromJson(results);
} else {
throw SearchResultError.fromJson(results);
}
}
}
The GithubClient has a search
Future that calls the baseUrl
and converts the JSON response into a SearchResult
.
import 'package:meta/meta.dart';
/// Provides an inmemory cache used to avoid repeating an async callback within a
/// certain [duration]
@immutable
class InMemoryCache<T> {
InMemoryCache(this.duration);
final Duration duration;
final Map<Object, _CacheEntry<T>?> _cache = {};
void _cacheValue(T value, Object key) {
_cache[key] = _CacheEntry(
timestamp: DateTime.now(),
data: value,
);
}
/// Returns a cached value from a previous call to [fetch], or runs [callback]
/// to compute a new one.
///
/// If [fetch] has been called recently enough, returns its previous return
/// value. Otherwise, runs [callback] and returns its new return value.
Future<T> fetch(
Future<T> Function() callback, {
String? key,
}) async {
final effectiveKey = key ?? callback;
final entry = _cache[effectiveKey];
Future<T> fetchAndCache() async {
final value = await callback();
_cacheValue(value, effectiveKey);
return value;
}
if (entry == null) {
return fetchAndCache();
} else {
final now = DateTime.now();
final difference = now.difference(entry.timestamp);
if (difference > duration) {
return fetchAndCache();
}
}
return entry.data;
}
}
class _CacheEntry<T> {
_CacheEntry({
required this.timestamp,
required this.data,
});
final DateTime timestamp;
final T data;
}
The InMemoryCache
class caches the future being passed to the fetch
method for a given duration
.
Let's see the repository:
import 'package:github_search/models/models.dart';
import 'package:github_search/service/client.dart';
import 'package:github_search/service/in_memory_cache.dart';
class GithubRepository {
GithubRepository({
required this.client,
required this.cache,
});
final InMemoryCache cache;
final GithubClient client;
Future<SearchResult> search(String term) async {
return await cache.fetch(() => client.search(term));
}
}
The GithubRepository
, given a cache
and a client
provides the search
future which value is cached by default.
Now let's see the GithubBloc
, the core of this example:
import 'package:flutter_solidart/flutter_solidart.dart';
import 'package:github_search/models/models.dart';
import 'package:github_search/repo/repository.dart';
import 'package:github_search/service/client.dart';
import 'package:github_search/service/in_memory_cache.dart';
import 'package:meta/meta.dart';
@immutable
class GithubSearchBloc {
GithubSearchBloc({GithubRepository? repository})
: _repository = repository ??
GithubRepository(
client: GithubClient(),
cache: InMemoryCache(const Duration(minutes: 5)),
);
final GithubRepository _repository;
final _searchTerm = Signal('');
late final searchResult =
Resource(fetcher: _search, source: _searchTerm);
void search(String term) => _searchTerm.set(term);
Future<SearchResult> _search() async {
if (_searchTerm().isEmpty) return SearchResult.empty();
return _repository.search(_searchTerm());
}
void dispose() {
_searchTerm.dispose();
searchResult.dispose();
}
}
The GithubSearchBloc
has a searchResult
resource.
This resource is responsible for managing the state of the future _search
.
The Resource takes a _searchTerm
source, so the Future will be repeated every time it changes.
The relevant parts of the UI are:
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context) {
return Solid(
providers: [
Provider<GithubSearchBloc>(
create: () => GithubSearchBloc(),
dispose: (bloc) => bloc.dispose(),
),
],
child: const SearchPageBody(),
);
}
}
Here we're creating and providing to descendants the GithubSearchBloc
.
class _SearchBar extends StatefulWidget {
// ignore: unused_element
const _SearchBar({super.key});
@override
State<_SearchBar> createState() => __SearchBarState();
}
class __SearchBarState extends State<_SearchBar> {
final textController = TextEditingController();
late final bloc = context.get<GithubSearchBloc>();
@override
void dispose() {
textController.dispose();
super.dispose();
}
void onClear() {
textController.clear();
bloc.search('');
}
@override
Widget build(BuildContext context) {
return TextField(
controller: textController,
decoration: InputDecoration(
hintText: 'Enter a search term',
suffixIcon: IconButton(
onPressed: onClear,
icon: const Icon(Icons.clear),
),
),
onSubmitted: (value) {
bloc.search(value);
},
);
}
}
The _SearchBar
gets access to the GithubSearchBloc
ancestor by using late final bloc = context.get<GithubSearchBloc>
.
Additionally, when the TextField value is submitted or cleared, the bloc.search(value)
sets the current search value.
Finally the _SearchBody
:
class _SearchBody extends StatelessWidget {
// ignore: unused_element
const _SearchBody({super.key});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: SignalBuilder(
builder: (context, child) {
final searchResultState = context.get<GithubSearchBloc>().searchResult.state;
return Stack(
children: [
searchResultState.on(
ready: (searchResult) {
if (searchResult.items.isEmpty) {
return const Text('No Results');
}
return _SearchResults(items: searchResult.items);
},
error: (error, _) => Text(error.toString()),
loading: () => const CircularProgressIndicator(),
),
if (searchResultState.isRefreshing)
Positioned.fill(
child: Container(
alignment: Alignment.center,
color: Colors.black.withOpacity(0.3),
child: const CircularProgressIndicator(),
),
),
],
);
},
),
);
}
}
Here we're accessing the searchResult
resource by using context.get<GithubSearchBloc>().searchResult
and passing it to a SignalBuilder
.
Whenever the searchResult
state changes, the SignalBuilder will rebuild and will render the current state correcly.
In addition, we're displaying a CircularProgressIndicator
with some background opacity when the state of the resource is refreshing.