Skip to main content

Hacker News

In this example we will rely on the ObservableFuture to keep track of the fetch calls to the HackerNews API. The entire state of the application will be defined in terms of the ObservableFuture.

info

The complete example can be seen here

Observable State

We will wrap our calls to the HackerNews API within a simple HNApi wrapper. For the observable state, we will take a different perspective.

The hacker-news client essentially shows a list of items for the latest and the top news items submitted on the HackerNews website. Instead of defining the observable state as two separate lists of items, we will just create two separate ObservableFutures. The result field of an ObservableFuture will point to the List<FeedItem>, which is the first page of the latest and top news feed.

Thus the reactive state of this example looks like so:

import 'package:mobx/mobx.dart';
import 'package:mobx_examples/hackernews/hn_api.dart';
import 'package:url_launcher/url_launcher.dart';

part 'news_store.g.dart';

enum FeedType { latest, top }

class HackerNewsStore = _HackerNewsStore with _$HackerNewsStore;

abstract class _HackerNewsStore with Store {
final _hnApi = HNApi();


ObservableFuture<List<FeedItem>>? latestItemsFuture;


ObservableFuture<List<FeedItem>>? topItemsFuture;


Future fetchLatest() => latestItemsFuture = ObservableFuture(_hnApi.newest());


Future fetchTop() => topItemsFuture = ObservableFuture(_hnApi.top());

void loadNews(FeedType type) {
if (type == FeedType.latest && latestItemsFuture == null) {
fetchLatest();
} else if (type == FeedType.top && topItemsFuture == null) {
fetchTop();
}
}

// ignore: avoid_void_async
void openUrl(String? url) async {
if (await canLaunch(url ?? '')) {
await launch(url!);
} else {
print('Could not open $url');
}
}
}

Note that in the background we are running the build_runner to generate the *.g.dart file. This command is run in the folder containing the project.flutter pub run build_runner watch --delete-conflicting-outputs> ```

The two actions fetchLatest() and fetchTop() create a new instance of the ObservableFuture, fetching the refreshed results. Note that the result field of each stores the list: List<FeedItem>.

Observer Widgets

The root widgets is simple a tab-container of two tabs, one of Latest and one for Top news:

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:mobx_examples/hackernews/hn_api.dart';

import 'package:mobx_examples/hackernews/news_store.dart';

class HackerNewsExample extends StatefulWidget {
const HackerNewsExample();


_HackerNewsExampleState createState() => _HackerNewsExampleState();
}

class _HackerNewsExampleState extends State<HackerNewsExample>
with SingleTickerProviderStateMixin {
final HackerNewsStore store = HackerNewsStore();

late TabController _tabController;
final _tabs = [FeedType.latest, FeedType.top];


void initState() {
_tabController = TabController(length: 2, vsync: this)
..addListener(_onTabChange);

store.loadNews(_tabs.first);
super.initState();
}


Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: const Text('Hacker News'),
bottom: TabBar(
controller: _tabController,
tabs: const [Tab(text: 'Newest'), Tab(text: 'Top')],
),
),
body: SafeArea(
child: TabBarView(controller: _tabController, children: [
FeedItemsView(store, FeedType.latest),
FeedItemsView(store, FeedType.top),
]),
));

void _onTabChange() {
store.loadNews(_tabs[_tabController.index]);
}
}

The most interesting Widget above is the FeedItemsView, an Observer-Widget that tracks the load of the specific feed type.

FeedItemsView

FeedItemsView tracks the specific observable-future depending on the FeedType. An ObservableFuture has three distinct states:

Thus for visual feedback, it is important to show each of these three states separately. Pending is normally shown with some loading-indicator. Fulfilled will be shown as as ListView of FeedItems and finally Rejected is the error state. You can see each of these in the following snippet:

class FeedItemsView extends StatelessWidget {
const FeedItemsView(this.store, this.type);

final HackerNewsStore store;
final FeedType type;


// ignore: missing_return
Widget build(BuildContext context) => Observer(builder: (_) {
final future = type == FeedType.latest
? store.latestItemsFuture
: store.topItemsFuture;

if (future == null) {
return const CircularProgressIndicator();
}

switch (future.status) {
case FutureStatus.pending:
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
Text('Loading items...'),
],
);

case FutureStatus.rejected:
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'Failed to load items.',
style: TextStyle(color: Colors.red),
),
ElevatedButton(
child: const Text('Tap to try again'),
onPressed: _refresh,
)
],
);

case FutureStatus.fulfilled:
final List<FeedItem> items = future.result;
return RefreshIndicator(
onRefresh: _refresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (_, index) {
final item = items[index];
return ListTile(
leading: Text(
'${item.score}',
style: const TextStyle(fontSize: 20),
),
title: Text(
item.title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text('- ${item.author}'),
onTap: () => store.openUrl(item.url),
);
}),
);
}
});

Future _refresh() =>
(type == FeedType.latest) ? store.fetchLatest() : store.fetchTop();
}

We have also added the Pull to Refresh behavior for fetching the latest updates to the news. This can be seen with the use of RefreshIndicator.

Summary

The HackerNews example makes good use of the ObservableFuture and uses it to show the different states of fetching the news. Since we are not really storing the news for anything else, we could pull if off with a just the ObservableFuture<List<FeedItem>>. As the use cases change, we may have to store it in a List<FeedItem>, or even an ObservableList<FeedItem>.

info

The complete example can be seen here