Introduction

I've recently stumbled into Flutter. It's a cross-platform native development framework written in the Dart programming language. It's a Google project, and it's honestly been an amazing experience building mobile apps in it.

We'll use it to build a mobile client for the GraphQL backend.

You'll want to install Flutter. I have been using VSCodium for Flutter development - it has good plugin support, and I like it more than Android Studio.

Starting a new project

Let's start a new project:

flutter create firestorm_flutter
cd firestorm_flutter

I'll open the project in my editor and start a debug session. This will build the app and load it on an emulator. There might be some setup to add an emulator, if you haven't built native apps before.

The starter app is a Material Design themed counter app. Here's the important bit:

// lib/main.dart
import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        // This is the theme of your application.
        //
        // Try running your application with "flutter run". You'll see the
        // application has a blue toolbar. Then, without quitting the app, try
        // changing the primarySwatch below to Colors.green and then invoke
        // "hot reload" (press "r" in the console where you ran "flutter run",
        // or simply save your changes to "hot reload" in a Flutter IDE).
        // Notice that the counter didn't reset back to zero; the application
        // is not restarted.
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  MyHomePage({Key key, this.title}) : super(key: key);

  // This widget is the home page of your application. It is stateful, meaning
  // that it has a State object (defined below) that contains fields that affect
  // how it looks.

  // This class is the configuration for the state. It holds the values (in this
  // case the title) provided by the parent (in this case the App widget) and
  // used by the build method of the State. Fields in a Widget subclass are
  // always marked "final".

  final String title;

  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      // This call to setState tells the Flutter framework that something has
      // changed in this State, which causes it to rerun the build method below
      // so that the display can reflect the updated values. If we changed
      // _counter without calling setState(), then the build method would not be
      // called again, and so nothing would appear to happen.
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    // This method is rerun every time setState is called, for instance as done
    // by the _incrementCounter method above.
    //
    // The Flutter framework has been optimized to make rerunning build methods
    // fast, so that you can just rebuild anything that needs updating rather
    // than having to individually change instances of widgets.
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text(widget.title),
      ),
      body: Center(
        // Center is a layout widget. It takes a single child and positions it
        // in the middle of the parent.
        child: Column(
          // Column is also layout widget. It takes a list of children and
          // arranges them vertically. By default, it sizes itself to fit its
          // children horizontally, and tries to be as tall as its parent.
          //
          // Invoke "debug painting" (press "p" in the console, choose the
          // "Toggle Debug Paint" action from the Flutter Inspector in Android
          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
          // to see the wireframe for each widget.
          //
          // Column has various properties to control how it sizes itself and
          // how it positions its children. Here we use mainAxisAlignment to
          // center the children vertically; the main axis here is the vertical
          // axis because Columns are vertical (the cross axis would be
          // horizontal).
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.display1,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

Let's do what they suggest - click the counter a few times, then change the color on the theme, to see hot reloading in action.

import 'package:flutter/material.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      // ...
      theme: ThemeData(
        // ...
        primarySwatch: Colors.red,
      ),
      // ...
    );
  }
}
// ...

The app hot reloads and immediately shows the new color.

Building the Categories page

Let's go ahead and imagine what a categories listing would look like. We'll use a normal list view and pass in some dummy data:

import 'package:flutter/material.dart';

class CategoriesPage extends StatelessWidget {
  final List<dynamic> categories = [
    {"title": "Category 1"},
    {"title": "Category 2"},
    {"title": "Category 3"},
    {"title": "Category 4"},
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Categories'), actions: [
          IconButton(
              icon: Icon(Icons.person), tooltip: 'Login', onPressed: () {}),
        ]),
        body: CategoriesList(categories: categories));
  }
}

class CategoriesList extends StatelessWidget {
  const CategoriesList({Key key, @required this.categories}) : super(key: key);

  final List categories;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: categories.length,
        itemBuilder: (context, index) {
          final category = categories[index];

          return ListTile(title: Text(category['title']));
        });
  }
}
import 'package:firestorm_flutter/pages/CategoriesPage.dart';
// ...
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        title: 'Firestorm',
        // ...
        home: CategoriesPage());
  }
}

Now we have a listing of categories.

Navigating to the Category page

Now we want to make a page that you see when you view a Category. Let's make the simplest page possible to start with, and focus on navigating to it:

import 'package:flutter/material.dart';

class CategoryPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Category')), body: Text('Some category'));
  }
}

So how do we navigate to this page? Let's make clicking any category in the CategoriesPage navigate here:

import 'package:firestorm_flutter/pages/CategoryPage.dart';
import 'package:flutter/material.dart';

class CategoriesPage extends StatelessWidget {
  final List<dynamic> categories = [
    {"title": "Category 1"},
    {"title": "Category 2"},
    {"title": "Category 3"},
    {"title": "Category 4"},
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Categories'), actions: [
          IconButton(
              icon: Icon(Icons.person), tooltip: 'Login', onPressed: () {}),
        ]),
        body: CategoriesList(categories: categories));
  }
}

class CategoriesList extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        // ...
        itemBuilder: (context, index) {
          final category = categories[index];

          return ListTile(
              // ...
              onTap: () {
                Navigator.of(context).push(new MaterialPageRoute(
                    builder: (context) => CategoryPage()));
              });
        });
  }
}

Here we're just fetching the Navigator from our BuildContext and pushing a new MaterialPageRoute onto it, which will change screens and automatically apply a platform-appropriate transition.

We'll want to pass the categoryId in to this page. That means it will be a parameter on the stateless widget:

import 'package:flutter/material.dart';

class CategoryPage extends StatelessWidget {
  final String categoryId;

  CategoryPage({final Key key, this.categoryId}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Category')),
        body: Text('Some category: $categoryId'));
  }
}

Here we've used the constructor to say "when someone gives us the named argument categoryId (which must be a String), that becomes our final value for the categoryId field."

Then we show it in the body to prove we got it. Let's add that parameter when we tap a category:

import 'package:firestorm_flutter/pages/CategoryPage.dart';
import 'package:flutter/material.dart';

class CategoriesPage extends StatelessWidget {
  final List<dynamic> categories = [
    {"id": "1", "title": "Category 1"},
    {"id": "2", "title": "Category 2"},
    {"id": "3", "title": "Category 3"},
    {"id": "4", "title": "Category 4"},
  ];
  // ...
}

class CategoriesList extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        // ...
        itemBuilder: (context, index) {
          // ...
          return ListTile(
              title: // ...
              onTap: () {
                Navigator.of(context).push(new MaterialPageRoute(
                    builder: (context) =>
                        CategoryPage(categoryId: category['id'])));
              });
        });
  }
}

Now when we tap a category, we can use its ID in the CategoryPage.

Let's list the category's threads on this page:

import 'package:flutter/material.dart';

class CategoryPage extends StatelessWidget {
  CategoryPage({this.categoryId});

  final String categoryId;

  final List threads = [
    {"id": "1", "title": "Thread 1"},
    {"id": "2", "title": "Thread 2"},
    {"id": "3", "title": "Thread 3"},
    {"id": "4", "title": "Thread 4"},
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Category')),
        body: ThreadsList(threads: threads));
  }
}

class ThreadsList extends StatelessWidget {
  ThreadsList({@required this.threads});

  final List threads;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: threads.length,
        itemBuilder: (context, index) {
          final thread = threads[index];

          return ListTile(title: Text(thread['title']), onTap: () {});
        });
  }
}

Now we can, in theory, list threads. Let's also add a floating action button, for creating a new thread on this page:

// ...
class CategoryPage extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        // ...
        floatingActionButton: FloatingActionButton(
            tooltip: 'Add', child: new Icon(Icons.add), onPressed: () {}));
  }
}
// ...

Mocking up the Thread page

Let's continue by mocking up the thread page. First, we'll make clicking a thread on the category page navigate to that thread:

import 'package:firestorm_flutter/pages/ThreadPage.dart';
// ...
class ThreadsList extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        // ...
        itemBuilder: (context, index) {
          // ...
          return ListTile(
              // ...
              onTap: () {
                Navigator.of(context)
                    .push(MaterialPageRoute(builder: (context) {
                  return ThreadPage(threadId: thread['id']);
                }));
              });
        });
  }
}
import 'package:flutter/material.dart';

class ThreadPage extends StatelessWidget {
  final String threadId;

  ThreadPage({final Key key, this.threadId}) : super(key: key);

  final List posts = [
    {"id": "1", "body": "First post"},
    {"id": "2", "body": "Second post"},
    {"id": "3", "body": "Third post"},
    {"id": "4", "body": "Fourth post"},
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(title: Text('Thread')),
        body: PostsList(posts: posts),
        floatingActionButton: FloatingActionButton(
            tooltip: 'Add', child: new Icon(Icons.add), onPressed: () {}));
  }
}

class PostsList extends StatelessWidget {
  PostsList({this.posts});

  final List posts;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: posts.length,
        itemBuilder: (context, index) {
          final post = posts[index];

          return ListTile(title: Text(post['body']));
        });
  }
}

Here we're starting with a page that looks just like the other two pages, but we'll spend some time making it look nice. First, let's break out a Post widget so we can focus on what we're doing:

// ...
class PostsList extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
        itemCount: // ...
        itemBuilder: (context, index) {
          // ...
          return Post(post: post);
        });
  }
}

class Post extends StatelessWidget {
  Post({this.post});

  final Map post;

  @override
  Widget build(BuildContext context) {
    return ListTile(title: Text(post['body']));
  }
}

Then let's put each post in its own card:

// lib/pages/ThreadPage.dart
// ...
class Post extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Card(
        child: Container(
            padding: EdgeInsets.all(16.0), child: Text(post["body"])));
  }
}

Now our posts are in cards. Let's bring in our first package, to render markdown:

# ...
dependencies:
  # ...
  # We want to render posts with markdown
  flutter_markdown: ^0.2.0
  # ...
# ...

My editor went ahead and installed the new package when we changed the file, so we can use it immediately.

// lib/pages/ThreadPage.dart
// ...
import 'package:flutter_markdown/flutter_markdown.dart';
// ...
class Post extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Card(
        child: Container(
            // ...
            child: MarkdownBody(data: post["body"])));
  }
}

That's how easy it is to get markdown rendering - pretty nice! If you try to click the link, you'll notice it doesn't do anything. Link behaviour is configurable - we'll allow links to visit the URL in the browser. There's a package to launch URLs:

# pubspec.yaml
# ...
dependencies:
  # ...
  # We want to launch URLs in the browser
  url_launcher: ^4.0.1
# ...
// ...
import 'package:url_launcher/url_launcher.dart';
// ...
class Post extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Card(
        child: Container(
            // ...
            child: MarkdownBody(onTapLink: _onTapLink, data: post["body"])));
  }

  _onTapLink(String url) async {
    if (await canLaunch(url)) {
      await launch(url);
    } else {
      throw 'Could not launch $url';
    }
  }
}

We have to stop the app and rebuild it, since this brings in some native plugins. Now when we click a link, it will be launched in the device's browser.

Posts are posted by people though. Let's mock out usernames and avatars on posts, and wire those in:

// ...
class ThreadPage extends StatelessWidget {
  // ...
  final List posts = [
    {
      "id": "1",
      "body": "First **post**",
      "user": {
        "name": "Josh Adams",
        "avatarUrl": "http://placekitten.com/300/300"
      }
    },
    {
      "id": "2",
      "body": "Second _post_",
      "user": {
        "name": "Joe Armstrong, Probably",
        "avatarUrl": "http://placekitten.com/200/200"
      }
    },
    {
      "id": "3",
      "body": "## Third post",
      "user": {
        "name": "Josh Adams",
        "avatarUrl": "http://placekitten.com/300/300"
      }
    },
    {
      "id": "4",
      "body": "[SmoothTerminal](https://www.smoothterminal.com)",
      "user": {
        "name": "Joe Armstrong, Probably",
        "avatarUrl": "http://placekitten.com/200/200"
      }
    },
  ];
  // ...
}
// ...
class Post extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Card(
        child: Container(
            padding: EdgeInsets.all(16.0),
            child: Row(children: [
              Container(
                  width: 40,
                  height: 40,
                  decoration: BoxDecoration(
                      shape: BoxShape.circle,
                      image: DecorationImage(
                          fit: BoxFit.fill,
                          image: NetworkImage(post['user']['avatarUrl'])))),
              SizedBox(width: 20),
              Expanded(
                  child: Column(
                      mainAxisAlignment: MainAxisAlignment.start,
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                    Row(children: [
                      Text(post['user']['name'],
                          style: TextStyle(fontWeight: FontWeight.bold)),
                      Expanded(
                          child: Align(
                              alignment: Alignment.topRight,
                              child: Text("2018-12-01",
                                  style: TextStyle(
                                      color: Theme.of(context).hintColor))))
                    ]),
                    MarkdownBody(onTapLink: _onTapLink, data: post['body'])
                  ]))
            ])));
  }
  // ...
}

Now we can see who made the posts!

Creating the "New Thread" form

We want to create a new thread. This means we'll need to introduce a text field for the title and a text area for the body, as well as a preview.

We'll start by adding the page and routing to it when you click the floatingActionButton on the CategoryPage.

// lib/pages/NewThreadPage.dart
import 'package:flutter/material.dart';

class NewThreadPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('New Thread')),
      body: Text('new thread form here')
    );
  }
}
// lib/pages/CategoryPage.dart
import 'package:firestorm_flutter/pages/NewThreadPage.dart';
// ...

class CategoryPage extends StatelessWidget {
    // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        // ...
        floatingActionButton: FloatingActionButton(
            // ...
            onPressed: () {
              Navigator.of(context).push(
                  MaterialPageRoute(builder: (builder) => NewThreadPage()));
            }));
  }
}
// ...

Now when we click the fab we're routed to the new page. Let's introduce a TabBar to switch between the editor and a preview:

import 'package:flutter/material.dart';

class NewThreadPage extends StatelessWidget {
  final List<Widget> _myTabs = [Tab(text: "Edit"), Tab(text: "Preview")];

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        length: _myTabs.length,
        child: Scaffold(
          appBar:
              AppBar(title: Text('New Thread'), bottom: TabBar(tabs: _myTabs)),
          body: Text('new thread form here')
        ));
  }
}

Let's add a different screen in each tab:

// ...
class NewThreadPage extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        length: _myTabs.length,
        child: Scaffold(
          appBar:
              AppBar(title: Text('New Thread'), bottom: TabBar(tabs: _myTabs)),
          body: TabBarView(children: [_Edit(), _Preview()]),
        ));
  }
}

class _Edit extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('editor goes here');
  }
}

class _Preview extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Text('preview goes here');
  }
}

Now switching tabs shows the corresponding screen in the TabBarView.

Let's introduce a couple of text fields to input the title and body:

// ...
class _Edit extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView(padding: EdgeInsets.all(32.0), children: [
      TextField(
        decoration: InputDecoration(labelText: 'Title'),
      ),
      TextField(
        decoration: InputDecoration(labelText: 'Create the first post'),
        maxLines: null,
      )
    ]);
  }
}
// ...

We use a ListView because as we open the keyboard it will change the size of the viewport, and the ListView will react appropriately to these changes. Any ScrollView would work for this purpose.

We also padded the screen a bit since it was running up against the edge of the screen and that didn't look great.

We'd like to have access to the values in these two text fields, both to show a preview now and to send those values to the backend later. This means we need to first make our widget stateful. We will introduce a State class that handles our internal state and move the rendering function there, and our StatefulWidget will just initialize the state:

// ...
class NewThreadPage extends StatefulWidget {
  // We create state when initializing the NewThreadPage
  @override
  _NewThreadPageState createState() {
    return _NewThreadPageState();
  }
}

class _NewThreadPageState extends State<NewThreadPage> {
  // this contains the same code that was previously in the NewThreadPage StatelessWidget
}
// ...

We'll introduce state for the title and body:

// ...
import 'package:flutter_markdown/flutter_markdown.dart';
// ...
class _NewThreadPageState extends State<NewThreadPage> {
  // ...
  String _title;
  String _body;

  @override
  initState() {
    super.initState();
    _title = "some title";
    _body = "some body";
  }

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        // ...
        child: Scaffold(
          // ...
          body: TabBarView(
              children: [_Edit(), _Preview(title: _title, body: _body)]),
        ));
  }
}
// ...
class _Preview extends StatelessWidget {
  final String title;
  final String body;

  _Preview({Key key, @required this.title, @required this.body})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Markdown(data: "## $title\n$body");
  }
}

Here we're tracking state but never changing it, so it's not quite state yet. To allow the changes to the text field to affect our state, we'll introduce a couple of TextEditingController instances and bind them to change our state when the text fields are changed:

import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';

class NewThreadPage extends StatefulWidget {
  @override
  _NewThreadPageState createState() {
    return _NewThreadPageState();
  }
}

class _NewThreadPageState extends State<NewThreadPage> {
  // ...
  final TextEditingController _titleController = TextEditingController();
  final TextEditingController _bodyController = TextEditingController();
  String _title = "";
  String _body = "";

  @override
  initState() {
    super.initState();

    _titleController.addListener(_updateTitle);
    _bodyController.addListener(_updateBody);
  }

  @override
  dispose() {
    _titleController.removeListener(_updateTitle);
    _bodyController.removeListener(_updateBody);
    _titleController.dispose();
    _bodyController.dispose();
    super.dispose();
  }

  void _updateTitle() => setState(() => _title = _titleController.text);
  void _updateBody() => setState(() => _body = _bodyController.text);

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        // ...
        child: Scaffold(
          // ...
          body: TabBarView(children: [
            _Edit(
                titleController: _titleController,
                bodyController: _bodyController),
            _Preview(title: _title, body: _body)
          ]),
        ));
  }
}

class _Edit extends StatelessWidget {
  final TextEditingController titleController;
  final TextEditingController bodyController;

  _Edit(
      {Key key, @required this.titleController, @required this.bodyController})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(padding: EdgeInsets.all(32.0), children: [
      TextField(
        controller: titleController,
        // ...
      ),
      TextField(
        controller: bodyController,
        // ...
      )
    ]);
  }
}
// ...

Here we're creating a couple of TextEditingController instances and passing them into the _Edit widget, which wires them into the corresponding text inputs. We then listen to changes on each of them and update the corresponding bit of our state when they change. Since we already wired that state into the preview, we can see the preview update as we change the data in these fields.

We also want to introduce a floatingActionButton that can be pressed to submit the form:

// ...
class _NewThreadPageState extends State<NewThreadPage> {
  // ...
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        // ...
        child: Scaffold(
          // ...
          floatingActionButton: FloatingActionButton(
              tooltip: 'Send', child: new Icon(Icons.send), onPressed: () {}),
          // ...
        ));
  }
}
// ...

This is what we want our NewThreadPage to look like.

New Post Page

We can do the same general thing for the NewPostPage:

import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';

class NewPostPage extends StatefulWidget {
  @override
  _NewPostPageState createState() {
    return _NewPostPageState();
  }
}

class _NewPostPageState extends State<NewPostPage> {
  final List<Widget> _myTabs = [Tab(text: "Edit"), Tab(text: "Preview")];
  final TextEditingController _bodyController = TextEditingController();
  String _body = "";

  @override
  initState() {
    super.initState();

    _bodyController.addListener(_updateBody);
  }

  @override
  dispose() {
    _bodyController.removeListener(_updateBody);
    _bodyController.dispose();
    super.dispose();
  }

  void _updateBody() => setState(() => _body = _bodyController.text);

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
        length: _myTabs.length,
        child: Scaffold(
          appBar:
              AppBar(title: Text('New Post'), bottom: TabBar(tabs: _myTabs)),
          floatingActionButton: FloatingActionButton(
              tooltip: 'Send', child: new Icon(Icons.send), onPressed: () {}),
          body: TabBarView(children: [
            _Edit(bodyController: _bodyController),
            _Preview(body: _body)
          ]),
        ));
  }
}

class _Edit extends StatelessWidget {
  final TextEditingController bodyController;

  _Edit({Key key, @required this.bodyController}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(padding: EdgeInsets.all(32.0), children: [
      TextField(
        controller: bodyController,
        maxLines: null,
        decoration: null,
      )
    ]);
  }
}

class _Preview extends StatelessWidget {
  final String body;

  _Preview({Key key, @required this.body}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Markdown(data: body);
  }
}
// ...
class ThreadPage extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    return Scaffold(
        // ...
        // ...
        floatingActionButton: FloatingActionButton(
            tooltip: 'Add',
            child: new Icon(Icons.add),
            onPressed: () {
              Navigator.of(context)
                  .push(MaterialPageRoute(builder: (builder) => NewPostPage()));
            }));
  }
}
// ...

This is almost identical to the new thread page, though we removed the decoration for the input since it's the only thing on the screen.

GraphQL introduction

Now that we've got all of the screens mocked out with fake data, we'd like to wire in real data from the backend. To do this, we'll install the graphql_flutter package:

# pubspec.yaml
# ...
dependencies:
  # ...
  # We want to talk to a GraphQL backend
  graphql_flutter: ^1.0.0-alpha
# ...

Then we need to set it up to talk to our backend:

// lib/main.dart
// ...
import 'package:graphql_flutter/graphql_flutter.dart';
// ...
class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    HttpLink link = HttpLink(uri: 'http://10.0.2.2:4000/graphql');

    ValueNotifier<GraphQLClient> client = ValueNotifier(
      GraphQLClient(
        cache: InMemoryCache(),
        link: link,
      ),
    );

    return GraphQLProvider(
        client: client,
        child: MaterialApp(
            // ...
            ));
  }
}

That's all it takes to set up the GraphQL client. We can do a little bit better though. Let's set up the cache to store data so it persists between app launches:

// ...
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ...
    return GraphQLProvider(
        client: client,
        child: CacheProvider(
            child: MaterialApp(
                // ...
                )));
  }
}

Now data will be cached. We can use a NormalizedInMemoryCache to do apollo-client style cache normalization, which will make the app a bit snappier with minimal extra work:

// ...
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ...
    ValueNotifier<GraphQLClient> client = ValueNotifier(
      GraphQLClient(
        cache: NormalizedInMemoryCache(
            dataIdFromObject: _typenameDataIdFromObject),
        link: link,
      ),
    );
    // ...
  }

  String _typenameDataIdFromObject(Object object) {
    if (object is Map<String, Object> &&
        object.containsKey('__typename') &&
        object.containsKey('id')) {
      return "${object['__typename']}/${object['id']}";
    }
    return null;
  }
}

To take advantage of this cache, we need to ensure that each query requests a given resource's __typename and id.

Fetching categories

Now we'd like to fetch the categories and use them on the CategoriesPage. We'll use the Query widget for this. It finds the client by looking up the tree for the GraphQLProvider we just wired up.

import 'package:firestorm_flutter/pages/CategoryPage.dart';
import 'package:flutter/material.dart';
import 'package:graphql_flutter/graphql_flutter.dart';

class CategoriesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // We'll write a query to fetch the first page of categories.
    String categoriesQuery = """
    query {
      categories {
        entries {
          id
          title
          __typename
        }
      }
    }
    """;

    return Query(
        options: QueryOptions(document: categoriesQuery),
        builder: (QueryResult result) {
          // We'll write a function that returns our body based upon the result of the query
          Widget body = _resultBody(result);

          return Scaffold(
              appBar: AppBar(title: Text('Categories'), actions: [
                IconButton(
                    icon: Icon(Icons.person),
                    tooltip: 'Login',
                    onPressed: () {}),
              ]),
              body: body);
        });
  }

  Widget _resultBody(QueryResult result) {
    if (result.errors != null) {
      return Text('Error');
    }
    if (result.loading) {
      return Text('Loading...');
    }
    return CategoriesList(categories: result.data['categories']['entries']);
  }
}
// ...

Now we're listing the categories from the backend.

Fetching a category and its threads

Let's fetch a category from the backend next. We already passed the category id to the page, now we just need to construct a query to fetch it.

// lib/pages/CategoryPage.dart
// ...
import 'package:graphql_flutter/graphql_flutter.dart';

class CategoryPage extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    String categoryQuery = """
    query CategoryQuery(\$id: ID!){
      category(id: \$id){
        id
        title
        threads {
          id
          title
          __typename
        }
        __typename
      }
    }
    """;
    return Query(
        options: QueryOptions(
            document: categoryQuery, variables: {"id": categoryId}),
        builder: (QueryResult result) {
          Widget body = _resultBody(result);
          Widget title = _resultTitle(result);

          return Scaffold(
              appBar: AppBar(title: title),
              body: body,
              // ...
          );
        });
  }

  Widget _resultBody(QueryResult result) {
    if (result.errors != null) {
      return Text('Error');
    }
    if (result.loading) {
      return Text('Loading...');
    }

    return ThreadsList(threads: result.data['category']['threads']);
  }

  Widget _resultTitle(QueryResult result) {
    if (result.errors != null) {
      return Text('Error');
    }
    if (result.loading) {
      return Text('Loading...');
    }
    return Text(result.data['category']['title']);
  }
}
// ...

Now we see a category's thrads.

Fetching a thread and its posts

Let's finish the queries by fetching a thread and its posts:

// ...
import 'package:graphql_flutter/graphql_flutter.dart';
// ...
class ThreadPage extends StatelessWidget {
  ThreadPage({this.threadId});

  final String threadId;

  @override
  Widget build(BuildContext context) {
    String threadQuery = """
    query ThreadQuery(\$id: ID!){
      thread(id: \$id){
        id
        title
        posts {
          id
          body
          insertedAt
          user {
            id
            name
            avatarUrl
            __typename
          }
        }
        __typename
      }
    }
    """;

    return Query(
        options:
            QueryOptions(document: threadQuery, variables: {"id": threadId}),
        builder: (QueryResult result) {
          Widget body = _resultBody(result);
          Widget title = _resultTitle(result);

          return Scaffold(
              appBar: AppBar(title: title),
              body: body,
              // ...
          );
        });
  }

  Widget _resultBody(QueryResult result) {
    if (result.errors != null) {
      return Text('Error');
    }
    if (result.loading) {
      return Text('Loading...');
    }
    return PostsList(posts: result.data['thread']['posts']);
  }

  Widget _resultTitle(QueryResult result) {
    if (result.errors != null) {
      return Text('Error');
    }
    if (result.loading) {
      return Text('Loading...');
    }
    return Text(result.data['thread']['title']);
  }
}
// ...

This isn't surprising, it's just the same general pattern we followed before. It went particularly easily because our mock data perfectly matched the shape of data coming back from these queries. The one thing we haven't wired in yet is the insertedAt field - let's do that:

// ...
String _basicDateString(DateTime datetime) {
  String year = datetime.year.toString();
  String month = datetime.month.toString().padLeft(2, '0');
  String day = datetime.day.toString().padLeft(2, '0');
  String hour = datetime.hour.toString().padLeft(2, '0');
  String minute = datetime.minute.toString().padLeft(2, '0');
  String second = datetime.second.toString().padLeft(2, '0');
  return '$year-$month-$day $hour:$minute:$second';
}

// ...
class Post extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    DateTime insertedAt = DateTime.parse(post['insertedAt']);

    return Card(
        child: Container(
            padding: EdgeInsets.all(16.0),
            child: Row(children: [
              // ...
              Expanded(
                  child: Column(
                      // ...
                      children: [
                    Row(children: [
                      // ...
                      Expanded(
                          child: Align(
                              // ...
                              child: Text(_basicDateString(insertedAt),
                                  style: TextStyle(
                                      color: Theme.of(context).hintColor))))
                    ]),
                    // ...
                  ]))
            ])));
  }
  // ...
}

Now we can see when posts were created.

Logging in

We've wired up all of the read data. Now we should add the ability to mutate data. Before we can do that, we'll need to authenticate the user. Let's create a LoginPage:

import 'package:flutter/material.dart';

class LoginPageState extends State<LoginPage> {
  String _email = "";
  String _password = "";
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _emailController.addListener(_updateEmail);
    _passwordController.addListener(_updatePassword);
  }

  _updateEmail() => setState(() => _email = _emailController.text);
  _updatePassword() => setState(() => _password = _passwordController.text);

  _body() {
    return Stack(children: [
      Positioned(
        right: 24.0,
        bottom: 24.0,
        child: Container(child: _logInButton()),
      ),
      _loginForm()
    ]);
  }

  _logInButton() {
    return RaisedButton(
        color: Theme.of(context).primaryColor,
        textTheme: ButtonTextTheme.primary,
        onPressed: () {},
        child: Text('Log in'));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
            backgroundColor: Theme.of(context).canvasColor,
            iconTheme: IconThemeData(color: Theme.of(context).primaryColor),
            elevation: 0.0),
        body: Builder(builder: (BuildContext context) {
          return _body();
        }));
  }

  Widget _loginForm() {
    return Padding(
        padding: EdgeInsets.all(20.0),
        child: Center(
            child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            TextField(
                controller: _emailController,
                decoration: InputDecoration(labelText: 'Email')),
            TextField(
                obscureText: true,
                controller: _emailController,
                decoration: InputDecoration(labelText: 'Password')),
          ],
        )));
  }
}

class LoginPage extends StatefulWidget {
  @override
  LoginPageState createState() {
    return LoginPageState();
  }
}

We've got an action in the AppBar on the CategoriesPage that should link to the login page:

// ...
import 'package:firestorm_flutter/pages/LoginPage.dart';
// ...
class CategoriesPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // ...
    return Query(
        options: QueryOptions(document: categoriesQuery),
        builder: (QueryResult result) {
          // ...
          return Scaffold(
              appBar: AppBar(title: Text('Categories'), actions: [
                IconButton(
                    // ...
                    onPressed: () {
                      Navigator.of(context).push(MaterialPageRoute(
                          builder: (BuildContext context) => LoginPage()));
                    }),
              ]),
              // ...
        });
  }
  // ...

Now if we click the icon in the AppBar on the CategoriesPage, we're taken to the login page and we're tracking the state for the form fields. We'll add a mutation to authenticate, and print the token we get back for now:

// ...
import 'package:graphql_flutter/graphql_flutter.dart';

class LoginPageState extends State<LoginPage> {
  String _email = "";
  String _password = "";
  final TextEditingController _emailController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  void initState() {
    super.initState();
    _emailController.addListener(_updateEmail);
    _passwordController.addListener(_updatePassword);
  }

  @override
  dispose() {
    _emailController.removeListener(_updateEmail);
    _emailController.dispose();
    _passwordController.removeListener(_updatePassword);
    _passwordController.dispose();
    super.dispose();
  }

  _updateEmail() => setState(() => _email = _emailController.text);
  _updatePassword() => setState(() => _password = _passwordController.text);

  // ...
  _logInButton() {
    String authenticateMutation = """
    mutation Authenticate(\$email: String!, \$password: String!){
      authenticate(email: \$email, password: \$password)
    }
    """;
    return Mutation(
        options: MutationOptions(document: authenticateMutation),
        onCompleted: _onCompleted,
        builder: (RunMutation authenticate, QueryResult result) {
          return RaisedButton(
              color: Theme.of(context).primaryColor,
              textTheme: ButtonTextTheme.primary,
              onPressed: () =>
                  authenticate({"email": _email, "password": _password}),
              child: Text('Log in'));
        });
  }

  _onCompleted(QueryResult result) {
    _onTokenReceived(result.data['authenticate']);
  }

  _onTokenReceived(String token) {
    debugPrint("Token: $token");
  }

  // ...
  Widget _loginForm() {
    return Padding(
        // ...
        child: Center(
            child: Column(
          // ...
          children: [
            TextField(
                controller: _emailController,
                // ...
            TextField(
                // ...
                controller: _passwordController,
                // ...
          ],
        )));
  }
}
// ...

Now if you log in with a valid email and password, you'll see the token printed in the debug console in your editor.

When we get the authentication token, we'll need to place it into some higher-level application state so that it can be used in the GraphQL client's authorization header.

To store shared state, we'll use the scopedmodel package.

# ...
dependencies:
  # ...
  # We want easy shared state management
  scoped_model: ^0.3.0

We'll introduce an AppModel that contains shared state - since I expect this would become broader over time, we'll make a models directory to hold it:

// lib/models/AppModel.dart
import 'package:scoped_model/scoped_model.dart';

class AppModel extends Model {
  String _token;

  String get token => _token;

  void setToken(String t) {
    _token = t;

    notifyListeners();
  }
}

Then we'll wrap our app in ScopedModel using this model:

import 'package:firestorm_flutter/models/AppModel.dart';
// ...
import 'package:scoped_model/scoped_model.dart';

void main() => runApp(ScopedModel<AppModel>(model: AppModel(), child: MyApp()));
// ...

Now anywhere in the tree, we can ask for data from the ScopedModel and get it. Since we're configuring our GraphQL clint, which sits at the root, we need to immediately use ScopedModelDescendant to pass the token into MyApp:

// ...
void main() => runApp(ScopedModel<AppModel>(
    model: AppModel(),
    child: ScopedModelDescendant<AppModel>(
        builder: (context, child, model) => MyApp(token: model.token))));

class MyApp extends StatelessWidget {
  final String token;
  MyApp({this.token});
  // ...
  @override
  Widget build(BuildContext context) {
    HttpLink link;
    String graphqlUri = 'http://10.0.2.2:4000/graphql';
    if (token == null) {
      link = HttpLink(uri: graphqlUri);
    } else {
      link = HttpLink(
          uri: graphqlUri, headers: {"authorization": "Bearer $token"});
    }
    // ...
}

Now when we log in, we'll set the token in the AppModel:

import 'package:firestorm_flutter/models/AppModel.dart';
// ...
import 'package:scoped_model/scoped_model.dart';

class LoginPageState extends State<LoginPage> {
  // ...
  _logInButton() {
    // ...
    return ScopedModelDescendant<AppModel>(
        builder: (context, child, model) => Mutation(
            options: MutationOptions(document: authenticateMutation),
            onCompleted: _onCompleted(model.setToken),
            builder: // ...
            ));
  }

  _onCompleted(Function setToken) => (QueryResult result) {
        if (result.hasErrors) {
          return null;
        }
        setToken(result.data['authenticate']);
        Navigator.of(context).pop();
      };
  // ...
}
// ...

We also pop this page off of the stack when we successfully authenticate. Now our GraphQL queries will be authenticated with our token.

Creating a thread

Let's take advantage of our newfound ability to make authenticated mutations to create a thread when we submit the NewThreadPage form. First, we should pass a categoryId when we construct a NewThreadPage:

// ...
class CategoryPage extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    // ...
    return Query(
        // ...
        builder: (QueryResult result) {
          // ...
          return Scaffold(
              // ...
              floatingActionButton: FloatingActionButton(
                  // ...
                  onPressed: () {
                    Navigator.of(context).push(MaterialPageRoute(
                        builder: (builder) =>
                            NewThreadPage(categoryId: categoryId)));
                  }));
        });
  }
  // ...
}
// ...

Then we'll require the categoryId parameter, send a mutation when we submit, and replace the current page with the ThreadPage on success:

// ...
import 'package:graphql_flutter/graphql_flutter.dart';

class NewThreadPage extends StatefulWidget {
  final String categoryId;
  NewThreadPage({final Key key, @required this.categoryId}) : super(key: key);

  @override
  _NewThreadPageState createState() {
    return _NewThreadPageState();
  }
}

class _NewThreadPageState extends State<NewThreadPage> {
  // ...
  @override
  Widget build(BuildContext context) {
    String createThreadMutation = """
    mutation CreateThread(\$categoryId: ID!, \$title: String!, \$body: String!){
      createThread(categoryId: \$categoryId, title: \$title, body: \$body) {
        id
        title
        posts {
          id
          body
          insertedAt
          user {
            id
            name
            __typename
          }
          __typename
        }
        __typename
      }
    }
    """;
    return Mutation(
        options: MutationOptions(document: createThreadMutation),
        onCompleted: _onCompleted,
        builder: (RunMutation createThread, QueryResult result) =>
            DefaultTabController(
                // ...
                child: Scaffold(
                  // ...
                  floatingActionButton: FloatingActionButton(
                      // ...
                      onPressed: () {
                        createThread({
                          "categoryId": widget.categoryId,
                          "title": _title,
                          "body": _body
                        });
                      }),
                  // ...
                )));
  }

  _onCompleted(QueryResult result) {
    if (result.hasErrors) {
      return null;
    }
    Navigator.of(context).pushReplacement(MaterialPageRoute(builder: (context) {
      return ThreadPage(threadId: result.data["createThread"]["id"]);
    }));
  }
}
// ...

Now we can create new threads.

Creating a post

We can do the same thing when creating a post. First, we need to pass the threadId to the NewPostPage:

// ...
class ThreadPage extends StatelessWidget {
  // ...
  @override
  Widget build(BuildContext context) {
    // ...
    return Query(
        // ...
        builder: (QueryResult result) {
          // ...
          return Scaffold(
              // ...
              floatingActionButton: FloatingActionButton(
                  // ...
                  onPressed: () {
                    Navigator.of(context).push(MaterialPageRoute(
                        builder: (builder) => NewPostPage(threadId: threadId)));
                  }));
        });
  }
  // ...
}
// ...

Then we'll combine it and the body to create a new post:

import 'package:firestorm_flutter/pages/ThreadPage.dart';
// ...
import 'package:graphql_flutter/graphql_flutter.dart';

class NewPostPage extends StatefulWidget {
  final String threadId;
  NewPostPage({final Key key, @required this.threadId}) : super(key: key);
  // ...
}

class _NewPostPageState extends State<NewPostPage> {
  // ...
  @override
  Widget build(BuildContext context) {
    String createPostMutation = """
    mutation CreatePost(\$threadId: ID!, \$body: String!){
      createPost(threadId: \$threadId, body: \$body){
        id
        body
        insertedAt
        user {
          id
          name
          avatarUrl
          __typename
        }
        __typename
      }
    }
    """;

    return Mutation(
        options: MutationOptions(document: createPostMutation),
        onCompleted: _onCompleted,
        builder: (RunMutation createPost, QueryResult result) =>
            DefaultTabController(
                // ...
                child: Scaffold(
                  // ...
                  floatingActionButton: FloatingActionButton(
                      tooltip: 'Send',
                      child: new Icon(Icons.send),
                      onPressed: () {
                        createPost(
                            {"threadId": widget.threadId, "body": _body});
                      }),
                  // ...
                )));
  }

  _onCompleted(QueryResult result) {
    if (result.hasErrors) {
      debugPrint(result.errors.toString());
      return null;
    }
    Navigator.of(context).pop();
  }
}
// ...

Now we can create new posts in a thread.

Subscriptions

Now we have a mostly full-featured forum. GraphQL subscriptions will allow us to introduce realtime features, like seeing new posts and threads created in realtime. Let's add support for subscriptions.

We'll begin by adding a package. I wrote this package explicitly to support this feature, and it's my first Dart package, so be nice :) It's a rough port of the npm package @absinthe/socket.

# pubspec.yaml
# ...
dependencies:
  # ...
  # Use absinthe subscriptions
  absinthe_socket: ^0.0.2
  # ...
# ...

On the categories screen, we'd like to subscribe to new categories. We'll set this up when we initialize the component:

import 'package:absinthe_socket/absinthe_socket.dart';
// ...
class _CategoriesPageState extends State<CategoriesPage> {
  // We create a list to store categories we receive from subscriptions in.
  List<Map> _subscriptionCategories = [];
  // We'll track all subscription notifiers so we can unsubscribe when we dispose of this component.
  List<Notifier> _notifiers = [];
  // We track our socket.
  AbsintheSocket _socket;

  @override
  void initState() {
    super.initState();
    // When we initialize our state, we'll create a new socket connection.
    // Ideally, this would be handled in a more Dart-y manner, but this works for now.
    _socket = AbsintheSocket("ws://10.0.2.2:4000/socket/websocket");

    // Then we call `subscribeToCreateCategory` which sets up our subscription
    subscribeToCreateCategory();
  }

  @override
  void dispose() {
    // When we dispose of the component, we'll cancel all of our subscriptions.
    _notifiers.forEach((Notifier notifier) => _socket.cancel(notifier));
    _notifiers = [];
    super.dispose();
  }

  // We set up some callbacks to handle various events on the subscription
  _onAbort() {
    print("onAbort");
  }

  _onCancel() {
    print("onCancel");
  }

  _onError(error) {
    print("onError");
  }

  // When we get a result, we'll insert the new category into our `_subscriptionCategories`
  _onResult(result) {
    setState(() {
      _subscriptionCategories.insert(0, result["data"]["categoryAdded"]);
    });
  }

  _onStart() {
    print("onStart");
  }

  void subscribeToCreateCategory() {
    // We set up an observer for the subscription
    Observer _categoryObserver = Observer(
        onAbort: _onAbort,
        onCancel: _onCancel,
        onError: _onError,
        onResult: _onResult,
        onStart: _onStart);

    // We send a request for our subscription, which returns a notifier that can be observed.
    Notifier notifier = _socket.send(GqlRequest(
        operation:
            "subscription CategoryAdded { categoryAdded { id, title } }"));
    // We observe the notifier
    notifier.observe(_categoryObserver);
    // And we add the notifier to the list that we'll cancel on dispose.
    _notifiers.add(notifier);
  }

  // ...

  Widget _resultBody(QueryResult result) {
    // ...
    // When we create the CategoriesList, we append the subscription categories to the front of the list.
    return CategoriesList(
        categories: result.data['categories']['entries']
          ..insertAll(0, _subscriptionCategories));
  }
}
// ...

Now when new categories are created, they should show up in the list. Let's test this from the GraphQL Playground by adding a category.

Next, let's subscribe to new threads when we're viewing a given category:

import 'package:absinthe_socket/absinthe_socket.dart';
// ...
// The code is nearly identical to the last page. One thing to notice is that each page presently has its own socket. This is a bad design and unnecessary, but Ι haven't yet taken the time to fix it.
class _CategoryPageState extends State<CategoryPage> {
  List<Map> _subscriptionThreads = [];
  List<Notifier> _notifiers = [];
  AbsintheSocket _socket;

  @override
  void initState() {
    super.initState();
    _socket = AbsintheSocket("ws://10.0.2.2:4000/socket/websocket");

    subscribeToCreateThread();
  }

  @override
  void dispose() {
    _notifiers.forEach((Notifier notifier) => _socket.cancel(notifier));
    _notifiers = [];
    super.dispose();
  }

  _onAbort() {
    print("onAbort");
  }

  _onCancel() {
    print("onCancel");
  }

  _onError(error) {
    print("onError");
  }

  _onResult(result) {
    setState(() {
      _subscriptionThreads.insert(0, result["data"]["threadAdded"]);
    });
  }

  _onStart() {
    print("onStart");
  }

  void subscribeToCreateThread() {
    Observer _threadObserver = Observer(
        onAbort: _onAbort,
        onCancel: _onCancel,
        onError: _onError,
        onResult: _onResult,
        onStart: _onStart);

    // One difference from the previous page is that we wire in the ~categoryId~ for the subscription.
    Notifier notifier = _socket.send(GqlRequest(
        operation:
            "subscription ThreadAdded { threadAdded(categoryId: \"${widget.categoryId}\") { id, title } }"));
    notifier.observe(_threadObserver);
    _notifiers.add(notifier);
  }
  // ...
  Widget _resultBody(QueryResult result) {
    // ...
    // We add the data from the subscription to the front of our list of threads.
    return ThreadsList(
        threads: result.data['category']['threads']
          ..insertAll(0, _subscriptionThreads));
  }
  // ...
}
// ...

We can add a thread to this category and see it show up at the top of the list immediately.

Finally, let's add subscriptions on a ThreadPage for new posts to the thread. Again, it's very similar to the previous page's subscription.

import 'package:absinthe_socket/absinthe_socket.dart';
// ...
class _ThreadPageState extends State<ThreadPage> {
  List<Map> _subscriptionPosts = [];
  List<Notifier> _notifiers = [];
  AbsintheSocket _socket;

  @override
  void initState() {
    super.initState();
    _socket = AbsintheSocket("ws://10.0.2.2:4000/socket/websocket");

    subscribeToCreatePost();
  }

  @override
  void dispose() {
    _notifiers.forEach((Notifier notifier) => _socket.cancel(notifier));
    _notifiers = [];
    super.dispose();
  }

  _onAbort() {
    print("onAbort");
  }

  _onCancel() {
    print("onCancel");
  }

  _onError(error) {
    print("onError");
  }

  _onResult(result) {
    setState(() {
      _subscriptionPosts.insert(0, result["data"]["postAdded"]);
    });
  }

  _onStart() {
    print("onStart");
  }

  void subscribeToCreatePost() {
    Observer _postObserver = Observer(
        onAbort: _onAbort,
        onCancel: _onCancel,
        onError: _onError,
        onResult: _onResult,
        onStart: _onStart);

    Notifier notifier = _socket.send(GqlRequest(operation: """
            subscription PostAdded { postAdded(threadId: \"${widget.threadId}\") {
              id
              body
              insertedAt
              __typename
              user {
                id
                name
                avatarUrl
                __typename
              }
            } }
            """));
    notifier.observe(_postObserver);
    _notifiers.add(notifier);
  }
  // ...
  Widget _resultBody(QueryResult result) {
    // ...
    return PostsList(
        posts: (result.data['thread']['posts'] ?? []) + _subscriptionPosts);
  }
  // ...
}
// ...

Now, new posts will show up at the bottom of the list of posts in the thread when we receive them from the subscription.

It was pretty simple to add support for subscriptions to each of our pages. Hopefully I'll update the package and make it a bit more idiomatic and efficient. It was pleasantly straightforward to build the package, and I expect adding features to make it nice will go fairly quickly as well.

Summary

Hey, we just built a realtime forum client in Flutter from scratch! I'm a huge fan of Flutter from a pragmatic perspective. When I can use Elm without hacks to build cross-platform applications, I'll probably do that. Until then, Flutter feels extremely nice and the development experience is fantastic.

I hope you enjoyed it. See you soon!

Resources