Did you know? All Video & Audio API plans include a $100 free usage credit each month so you can build and test risk-free. View Plans ->

Flutter Feed SDK for Activity Feeds

In this tutorial, we’re going to build an app using Stream’s Activity Feed Client with Dart. By the end of this tutorial, you'll learn how to setup a timeline feed, follow other feeds and post new activities.

example of flutter feeds sdk

Application Overview

The sample Flutter application you will build in this tutorial lets users post text and photos, view other users' posts in a timeline, and follow/unfollow specific users. The user experience of our application is very similar to that of Twitter or Instagram.

Let's look at the structure of the sample application. It'll contain the following pages:

  • Select User Page: displays a list of demo users to use.
  • Home Page: provides navigation to the rest of the app through a PageView.
  • Timeline Page: shows a scrolling list of posts sent by the current user and the users they follow. This is the hub of our application. Users can view or react to posts from this screen.
  • Profile Page: shows the current users' posts.
  • People Page: shows all demo users and enables the current user to follow/unfollow them.
  • Comments Page: displays all comments on a post and allows a user to add a new comment. Each comment can also be liked by the current user.
  • Compose Activity Page: a page to create and post new activities.

Flutter Project Setup

Before you can supercharge your Flutter application using Stream's Activity Feeds, there are a few things you must first do.

Install Flutter

Developers are encouraged to code along while learning about Stream's Activity Feeds. If you haven't already, please make sure you are using the latest version of Flutter from the stable channel.

bash
1
2
flutter channel stable flutter upgrade

See the official Flutter installation instructions.

Complete Sample Source Code

The full example code for this tutorial can be found on Github.

Clone the repository and run the following:

bash
1
2
3
4
git clone https://github.com/GetStream/stream-feed-flutter/ cd packages/stream_feed_flutter_core/example flutter pub get flutter run

This should be all that is needed to run the sample application. However, we recommend following this tutorial by creating your own Flutter and Stream Feed project.

Create a New Flutter Project

Create a new Flutter project; you can call it whatever you want.

bash
1
flutter create stream_feed_example

Add Dependencies

Add the following dependencies:

yaml
1
2
3
dependencies: stream_feed_flutter_core: ^0.8.0 image_picker: ^0.8.4+4

You can delete everything in main.dart and add the following imports:

dart
1
2
3
import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

Stream Setup

Creating an app on Stream is easy and doesn’t take long. It’s also free and doesn’t require a credit card 💸, or you can register for a Maker Account and access Stream for free indefinitely for qualifying teams and individuals.

Click on this link to create a new account.

Awesome! Now that you’ve successfully created an account, you should see the Stream Dashboard.

You can think of the dashboard as the control center for all your projects. From the dashboard, you have full access to create new projects, configure restrictions, and gain insight into your project stats and usage.

Create Stream App

To create a new Stream app, hit "Create App" on the Navigation Bar's top right to launch the project wizard.

Give your project a name and fill in the other required information; you can set the environment to Development. Once you're happy with your project, hit create.

Make sure your Dashboard is set to show for Feeds:

Select your newly created app from the application dropdown menu at the top.

On this page you will find the API information for the project. Take note of your app key and secret, you'll need these later.

Stream Feeds Introduction

See our Feed 101 documentation for a good introduction to feeds.

As a quick summary, a feed can be seen as a stack of activities, and activities are pushed to a feed. An activity can be a tweet or a post, for example.

A single application may have multiple feeds. For example, you might have a user's feed (what they posted), their timeline feed (what the people they follow posted), and a notification feed (to alert them of engagement with activities they posted).

There are three different types of feed groups:

  • Flat: default feed type and the only feed type that you can follow
  • Aggregated: helpful if you want to group activities together
  • Notification: contains Activity Groups, each with a seen and read status field

Create Stream Feed Groups

In this sample application, we’ll need two flat feeds:

  • “user” and
  • “timeline”

The “user” feed is where individual users will post activities to, and the “timeline” feed will subscribe/follow specific users’ “user” feeds.

By the end of this tutorial, your “user” feed will display all of your posts/activities, and the “timeline” feed will display all the activities of the users you’ve followed.

From your Stream dashboard, you’ll need to create these feed groups. Navigate to Feed Groups, and select Add Feed Group.

You should then see the following:

The screenshot above shows the process of creating the “user” flat feed. Follow the same steps to create a “timeline” flat feed. After you've completed these steps, you should see the following two feeds in your dashboard:

Stream Feed Client Setup

Let's start by creating an instance of the Stream Feed client in our code. Stream's Flutter client acts as a gateway between your application and Stream's servers.

To create a client, developers must pass an API key to the constructor.

Open main.dart, and make sure you have the following imports:

dart
1
2
3
import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:stream_feed_flutter_core/stream_feed_flutter_core.dart';

Then create a new client by instantiating the class StreamFeedClient within the main function, and replace the apiKey value with your newly created key:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
Future<void> main() async { const apiKey = 'q29npdvqjr99'; // TODO: Replace with your key final client = StreamFeedClient(apiKey); runApp( MyApp(client: client), ); } class MyApp extends StatelessWidget { const MyApp({ Key? key, required this.client, }) : super(key: key); final StreamFeedClient client; Widget build(BuildContext context) { return MaterialApp( builder: (context, child) => FeedProvider( bloc: FeedBloc( client: client, ), child: child!, ), home: const SelectUserPage(), ); } }

The FeedProvider widget is an InheritedWidget that exposes the FeedBloc instance to your entire application. The FeedBloc and StreamFeedClient classes are used to manage the state of your Stream Feed application and to interact with your Stream server application. We’ll explore these more in later sections.

Demo Users

As this is a tutorial, we will create a hardcoded list of DemoUsers.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class DemoUser { final User user; final Token token; const DemoUser({ required this.user, required this.token, }); } // TODO: Replace tokens and users with your values. const demoUsers = [ DemoUser( user: User( id: 'sachaarbonel', data: { 'handle': '@sachaarbonel', 'first_name': 'Sacha', 'last_name': 'Arbonel', 'full_name': 'Sacha Arbonel', 'profile_image': 'https://avatars.githubusercontent.com/u/18029834?v=4', }, ), token: Token( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FjaGFhcmJvbmVsIn0.P61gSErNtvGk1BK3EYGzC3z1ZNJLXV7blcGiBuyi-DI'), ), DemoUser( user: User( id: 'GroovinChip', data: { 'handle': '@GroovinChip', 'first_name': 'Reuben', 'last_name': 'Turner', 'full_name': 'Reuben Turner', 'profile_image': 'https://avatars.githubusercontent.com/u/4250470?v=4', }, ), token: Token( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiR3Jvb3ZpbkNoaXAifQ.CUifllzvz7s41imbCnyoGyOsLpRyQk-MA5Zu0oUbIIk'), ), DemoUser( user: User( id: 'gordonphayes', data: { 'handle': '@gordonphayes', 'first_name': 'Gordon', 'last_name': 'Hayes', 'full_name': 'Gordon Hayes', 'profile_image': 'https://avatars.githubusercontent.com/u/13705472?v=4', }, ), token: Token( 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ29yZG9ucGhheWVzIn0.4VaMAj8XkYMYt1JzeNxRZcuGwBSZ9gJ1Us5Jn7SImm0'), ), ];

The DemoUser class consists of User and Token fields - these classes come from the Stream Feed package. The User class takes in a mandatory id and an optional data argument to set user-specific data. The data map can contain any mappable object you want to store on your user object.

Please note that in this tutorial the user tokens are hardcoded. However, this should never be done in a production environment. See our documentation on user tokens.

You can generate a token using any of our backend SDKs, manually using our online token generator, or using the stream-cli.

Stream CLI - Generate User Tokens

Let’s look at generating user tokens using the Stream CLI.

For installation instructions, see the Github repo.

Configure an application by running:

bash
1
stream-cli config new

This will ask for:

  1. The name of your application
  2. Access Key
  3. Secret Key
  4. Optional base URL

Fill these in with the information you have on your dashboard. The name you give is only for the CLI configuration so that you can identify your different applications.

After configuring an application, you can easily generate a new frontend token. Let’s generate a token for the user Sacha, with ID sachaarbonel:

bash
1
stream-cli chat create-token --user sachaarbonel

This generates a token that does not have an expiration date. You can now replace the token for the user with ID sachaarbonel in your demo users.

Now, do the same for all the other demo users.

If you have multiple applications configured in you CLI, you can specify which one to use with --app, for example:

bash
1
stream-cli chat create-token --user sachaarbonel --app [YOUR-APP-NAME]

For more options, run:

bash
1
stream-cli chat create-token –help

User Data Extensions

Earlier, you set a few different parameters on the User.data object. Now create an extension class that will allow you to access those fields easily:

dart
1
2
3
4
5
6
7
8
9
/// Extension method on Stream's [User] class - to easily access user data /// properties used in this sample application. extension UserData on User { String get handle => data?['handle'] as String? ?? ''; String get firstName => data?['first_name'] as String? ?? ''; String get lastName => data?['last_name'] as String? ?? ''; String get fullName => data?['full_name'] as String? ?? ''; String get profileImage => data?['profile_image'] as String? ?? ''; }

This will come in handy later when we need to display this data.

Set and Connect a User - Select User Page

Next, create a SelectUserPage that allows you to select one of these demo users and connect them:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/// Page to select from connect as one of the [DemoUser]s. class SelectUserPage extends StatelessWidget { const SelectUserPage({Key? key}) : super(key: key); Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Select user')), body: ListView( children: demoUsers .map( (demoUser) => UserTile( user: demoUser.user, onTap: () async { try { await context.feedClient .setUser(demoUser.user, demoUser.token); Navigator.of(context).pushReplacement( MaterialPageRoute<void>( builder: (BuildContext context) => const HomePage(), ), ); } on Exception catch (e, st) { debugPrintStack(stackTrace: st); } }, ), ) .toList(), ), ); } } /// UI widget to display an [User]'s profile picture and name. /// /// Optional: [onTap] callback and [trailing] widget. class UserTile extends StatelessWidget { const UserTile({ Key? key, required this.user, this.onTap, this.trailing, }) : super(key: key); final User user; final VoidCallback? onTap; final Widget? trailing; Widget build(BuildContext context) { return ListTile( leading: CircleAvatar(backgroundImage: NetworkImage(user.profileImage)), title: Text(user.fullName), onTap: onTap, trailing: trailing, ); } }

This code is mostly just UI. The important part is the call to:

dart
1
await context.feedClient.setUser(demoUser.user, demoUser.token);

This sets the current user by passing in a User and Token object.

If that is successful, the application will navigate to the HomePage. You will create the HomePage next, however if you wanted to run the application now by creating a temporary empty HomePage widget, it should look something like this:

App Navigation - Home Page

The Home Page is the base landing page of your application. From this page, a user will be able to navigate to all the other pages using a PageView.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/// Home page of the sample application. /// /// Provides navigation to the rest of the app through a [PageView]. /// /// Pages: /// - [TimelinePage] (default) /// - [ProfilePage] /// - [PeoplePage] class HomePage extends StatefulWidget { const HomePage({Key? key}) : super(key: key); State<HomePage> createState() => _HomePageState(); } class _HomePageState extends State<HomePage> { final _pageController = PageController(); int _currentIndex = 0; void dispose() { _pageController.dispose(); super.dispose(); } Widget build(BuildContext context) { return Scaffold( body: PageView( controller: _pageController, children: const [ TimelinePage(), ProfilePage(), PeoplePage(), ], ), bottomNavigationBar: BottomNavigationBar( onTap: (value) { _pageController.jumpToPage(value); setState(() { _currentIndex = value; }); }, currentIndex: _currentIndex, items: const [ BottomNavigationBarItem( icon: Icon(Icons.timeline), label: 'timeline'), BottomNavigationBarItem(icon: Icon(Icons.person), label: 'profile'), BottomNavigationBarItem(icon: Icon(Icons.people), label: 'people'), ], ), ); } }

This page uses a PageView to access three other pages:

  • Timeline Page
  • Profile Page
  • People Page

For now, you can create your own empty TimelinePage and PeoplePage widgets.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class TimelinePage extends StatelessWidget { const TimelinePage({Key? key}) : super(key: key); Widget build(BuildContext context) { return Container(); } } class PeoplePage extends StatelessWidget { const PeoplePage({Key? key}) : super(key: key); Widget build(BuildContext context) { return Container(); } }

You’ll implement these later. First, let’s start with the ProfilePage.

User Feed - Profile Page

This page displays the feed of activities/posts by the current user.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/// Page that displays the "user" Stream feed group. /// /// A list of the activities that you've posted. /// /// Displays your reactions, and reaction counts. class ProfilePage extends StatefulWidget { const ProfilePage({ Key? key, }) : super(key: key); State<ProfilePage> createState() => _ProfilePageState(); } class _ProfilePageState extends State<ProfilePage> { final EnrichmentFlags _flags = EnrichmentFlags() ..withReactionCounts() ..withOwnReactions(); bool _isPaginating = false; static const _feedGroup = 'user'; Future<void> _loadMore() async { // Ensure we're not already loading more activities. if (!_isPaginating) { _isPaginating = true; context.feedBloc .loadMoreEnrichedActivities(feedGroup: _feedGroup) .whenComplete(() { _isPaginating = false; }); } } Widget build(BuildContext context) { final client = context.feedClient; return Scaffold( appBar: AppBar(title: const Text('Your posts')), body: FlatFeedCore( feedGroup: _feedGroup, userId: client.currentUser!.id, loadingBuilder: (context) => const Center( child: CircularProgressIndicator(), ), emptyBuilder: (context) => const Center(child: Text('No activities')), errorBuilder: (context, error) => Center( child: Text(error.toString()), ), limit: 10, flags: _flags, feedBuilder: ( BuildContext context, activities, ) { return RefreshIndicator( onRefresh: () { return context.feedBloc.refreshPaginatedEnrichedActivities( feedGroup: _feedGroup, flags: _flags, ); }, child: ListView.separated( itemCount: activities.length, separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { bool shouldLoadMore = activities.length - 3 == index; if (shouldLoadMore) { _loadMore(); } return ListActivityItem( activity: activities[index], feedGroup: _feedGroup, ); }, ), ); }, ), floatingActionButton: FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute<void>( builder: (context) => const ComposeActivityPage()), ); }, tooltip: 'Add Activity', child: const Icon(Icons.add), ), ); } }

The most important part is the FlatFeedCore widget. This allows you to easily access the feed data for a particular user and feed group by specifying the feedGroup and userId attributes.

Note that you can easily access the StreamFeedClient using context.feedClient.

Next, there are four builders that you need to set:

  • loadingBuilder: widget to return when the data is loading.
  • emptyBuilder: widget to return when there is no data to display.
  • errorBuilder: widget to return when an error occurred.
  • feedBuilder: widget to return when the feed data has been retrieved.

In the feedBuilder, you get a list of activities that you can display. In this example, you’re creating a ListView.seperated.

Within the ListView, you’re returning a ListActivityItem, which we’ll explore later. We’ll also explore the ComposeActivityPage in a later step. We navigate to this page when tapping the FloatingActionButton.

Refresh Activities

To enable your users to refresh the list of activities, we wrap the ListView with a RefreshIndicator.

In the onRefresh argument, you call FeedBloc.refreshPaginatedEnrichedActivities, specifying the feedGroup and the flags to use. This creates the UI and logic to enable a user to drag down to refresh the data.

Activity Enrichment

The flags argument determines the activity enrichment that you require. Enrichment is an interesting concept that you can read more about here.

Not specifying any flags will simply return the activities without any reaction data, for example, “likes” or “comments”.

In this example, you’re specifying the following flags:

dart
1
2
3
final EnrichmentFlags _flags = EnrichmentFlags() ..withReactionCounts() ..withOwnReactions();

These two flags will ensure that the activity object contains the number of reactions added to it and whether the current user has added any reactions.

Note you’re using the same flags for onRefresh, within the FlatFeedCore widget, and when calling loadMoreEnrichedActivities. If you don’t use the same flags, you may receive activities with conflicting reactions when doing a refresh or when loading more activities.

Display Activity and Add “Like” Reactions

It's time to add the ListActivityItem widget, which does the following:

  • displays an activity/post
  • enables a user to like a post (add a reaction to it), and
  • navigates to the comments screen on tap
dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/// UI widget to display an activity/post. /// /// Shows the number of likes and comments. /// /// Enables the current [User] to like the activity, and view comments. class ListActivityItem extends StatelessWidget { const ListActivityItem({ Key? key, required this.activity, required this.feedGroup, }) : super(key: key); final EnrichedActivity activity; final String feedGroup; Widget build(BuildContext context) { final actor = activity.actor!; final attachments = (activity.extraData)?.toAttachments(); final reactionCounts = activity.reactionCounts; final ownReactions = activity.ownReactions; final isLikedByUser = (ownReactions?['like']?.length ?? 0) > 0; return ListTile( leading: CircleAvatar( backgroundImage: NetworkImage(actor.profileImage), ), title: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Row( children: [ Text(actor.fullName), const SizedBox(width: 8), Text( actor.handle, style: Theme.of(context).textTheme.caption, ), ], ), ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Text('${activity.object}'), ), if (attachments != null && attachments.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Image.network(attachments[0].url), ), Row( children: [ Row( children: [ IconButton( iconSize: 16, onPressed: () { if (isLikedByUser) { context.feedBloc.onRemoveReaction( kind: 'like', activity: activity, reaction: ownReactions!['like']![0], feedGroup: feedGroup, ); } else { context.feedBloc.onAddReaction( kind: 'like', activity: activity, feedGroup: feedGroup); } }, icon: isLikedByUser ? const Icon(Icons.favorite_rounded) : const Icon(Icons.favorite_outline), ), if (reactionCounts?['like'] != null) Text( '${reactionCounts?['like']}', style: Theme.of(context).textTheme.caption, ) ], ), const SizedBox(width: 16), Row( children: [ IconButton( iconSize: 16, onPressed: () => _navigateToCommentPage(context), icon: const Icon(Icons.mode_comment_outlined), ), if (reactionCounts?['comment'] != null) Text( '${reactionCounts?['comment']}', style: Theme.of(context).textTheme.caption, ) ], ) ], ) ], ), onTap: () { _navigateToCommentPage(context); }, ); } void _navigateToCommentPage(BuildContext context) { Navigator.of(context).push( MaterialPageRoute<void>( builder: (BuildContext context) => CommentsPage( activity: activity, ), ), ); } }

From the activity passed into the widget, you can retrieve the reaction counts and the current user’s reactions from the activity - as determined by the EnrichmentFlags you set earlier.

Within the onPressed method you’re adding or removing a “like” reaction to the current activity, depending on whether the reaction already exists or not.

You will create the CommentsPage later on - where you’ll be able to add a “comment” to a post. For now, we’re simply showing the total number of comments and adding navigation to the comment page.

For now, you can create an empty CommentsPage that takes in an EnrichedActivity.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
class CommentsPage extends StatelessWidget { const CommentsPage({ Key? key, required this.activity, }) : super(key: key); final EnrichedActivity activity; Widget build(BuildContext context) { return Container(); } }

Activities Pagination

Within the ProfilePage widget, the _loadMore method handles loading more activities. This method gets called from the ListView widget when the third last widget is built. There is some additional logic with the _isPaginating boolean to ensure that you only perform one API call to load more data.

Note that this is only an example implementation. You can implement pagination in whatever way you want and load more activities by calling:

dart
1
context.feedBloc.loadMoreEnrichedActivities(feedGroup: _feedGroup, flags: _flags)

In this call you specify the feed group and the enrichment flags needed.

Posting Activities - Compose Activity Page

Now that we’re able to display activities, the next step is to create new ones.

We previously created a FloatingActingButton that navigates the user to a page, called ComposeActivityPage. Let’s create that page now.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
/// A page to compose a new [Activity]/post. /// /// - feed: "user" /// - verb: "post" /// - object: "text data" /// - data: media /// /// [More information](https://getstream.io/activity-feeds/docs/flutter-dart/adding_activities/?language=dart) on activities. class ComposeActivityPage extends StatefulWidget { const ComposeActivityPage({Key? key}) : super(key: key); State<ComposeActivityPage> createState() => _ComposeActivityPageState(); } class _ComposeActivityPageState extends State<ComposeActivityPage> { final TextEditingController _textEditingController = TextEditingController(); void dispose() { _textEditingController.dispose(); super.dispose(); } /// "Post" a new activity to the "user" feed group. Future<void> post() async { final uploadController = context.feedUploadController; final media = uploadController.getMediaUris()?.toExtraData(); if (_textEditingController.text.isNotEmpty) { await context.feedBloc.onAddActivity( feedGroup: 'user', verb: 'post', object: _textEditingController.text, data: media, ); uploadController.clear(); Navigator.pop(context); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cannot post with no message'))); } } Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Compose'), actions: [ Padding( padding: const EdgeInsets.all(8.0), child: ActionChip( label: const Text( 'Post', style: TextStyle( color: Colors.blue, ), ), backgroundColor: Colors.white, onPressed: post, ), ), ], ), body: Padding( padding: const EdgeInsets.all(8.0), child: SingleChildScrollView( child: Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: TextField( controller: _textEditingController, decoration: const InputDecoration(hintText: "What's on your mind"), ), ), Row( children: [ IconButton( onPressed: () async { final ImagePicker _picker = ImagePicker(); final XFile? image = await _picker.pickImage( source: ImageSource.gallery, maxHeight: 600, maxWidth: 300, imageQuality: 50, ); if (image != null) { await context.feedUploadController .uploadImage(AttachmentFile(path: image.path)); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Cancelled'))); } }, icon: const Icon(Icons.file_copy), ), Text( 'Add image', style: Theme.of(context).textTheme.caption, ), ], ), UploadListCore( uploadController: context.feedUploadController, loadingBuilder: (context) => const Center(child: CircularProgressIndicator()), uploadsErrorBuilder: (error) => Center(child: Text(error.toString())), uploadsBuilder: (context, uploads) { return SizedBox( height: 100, child: ListView.builder( scrollDirection: Axis.horizontal, itemCount: uploads.length, itemBuilder: (context, index) => FileUploadStateWidget( fileState: uploads[index], onRemoveUpload: (attachment) { return context.feedUploadController .removeUpload(attachment); }, onCancelUpload: (attachment) { return context.feedUploadController .cancelUpload(attachment); }, onRetryUpload: (attachment) async { return context.feedUploadController .uploadImage(attachment); }), ), ); }, ), ], ), ), ), ); } }

Make sure to have an import for the image_picker package:

plaintext
1
import 'package:image_picker/image_picker.dart';

Depending on the platform you target you will also need to configure you app permissions. If this step is skipped your application may crash when trying to access the device image gallery.

For information, see the package installation instructions.

The code above makes use of two controllers:

  • UploadController to manage file uploads to Stream’s CDN. For this application, we can upload images.
  • TextEditingController allows a user to type in text that will be added to an activity.

The IconButton’s onPressed callback uses the image_picker package to select an image from the device’s gallery and uses the upload controller to upload the image.

The uploaded images are retrieved using the UploadListCore widget, and rendered with FileUploadStateWidget - these widgets come from Stream’s core package.

Within the ActionChip’s onPressed callback you call the _post method, which performs the following:

dart
1
2
3
4
5
6
7
8
9
10
final uploadController = context.feedUploadController; final media = uploadController.getMediaUris()?.toExtraData(); if (_textEditingController.text.isNotEmpty) { await context.feedBloc.onAddActivity( feedGroup: 'user', verb: 'post', object: _textEditingController.text, data: media, ); uploadController.clear();

The above code reads like this: post an activity to the user feed group, with this object and data:

  • The object can be more complex than a simple text value. However, that will need to be a separate tutorial. See our documentation on enrichment and collections for more details.
  • The data field is a map of type Map<String, Object>?, and similar to the User class, we can provide any mappable content. For this example, we’re using the data to store the uploaded image URLs.
  • After creating the new activity, we clear the upload controller.

Your HomePage should now look like this, when navigating to the profile page:

As you can see, we've not yet added any activities to our Stream Feed app. Let's do that now.

Press the floating action button to go to the new activity page, and create a post:

Note: be sure to have set the correct app permissions to access the device's image gallery.

Pressing the Post button will navigate you back to the Profile Page, and you should see your newly added post.

Congratulations! You're on your way to mastering activity feeds. You can try to add as many posts as you want, and experiment with the pagination you've already created.

Following and Unfollowing Users - People Page

At this stage, your application will allow you to post and view the current user’s activities. Not very exciting on its own, so let’s fix that. We want a way to see other users’ posts, and no timeline application would be complete without the ability to follow and unfollow users.

For this functionality, you will need to create a new page in your application, called PeoplePage. This page is accessible from the HomePage’s PageView.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/// Page that displays all [User]s, enabling the current user to /// follow/unfollow specific users. class PeoplePage extends StatelessWidget { const PeoplePage({Key? key}) : super(key: key); Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('People')), body: ListView( children: demoUsers .where((element) { return element.user.id != context.feedBloc.currentUser!.id; }) .map((demoUser) => FollowUserTile(user: demoUser.user)) .toList(), ), ); } }

Here we’re simply using the list of demo users we’ve made and removing the current user from that list. Then we’re displaying a FollowUserTile, which we’ll create next:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/// A UI widget that displays a [User] tile to follow/unfollow. class FollowUserTile extends StatefulWidget { const FollowUserTile({ Key? key, required this.user, }) : super(key: key); final User user; State<FollowUserTile> createState() => _FollowUserTileState(); } class _FollowUserTileState extends State<FollowUserTile> { bool _isFollowing = false; void didChangeDependencies() { super.didChangeDependencies(); checkIfFollowing(); } Future<void> checkIfFollowing() async { final result = await context.feedBloc.isFollowingFeed(followerId: widget.user.id!); _setStateFollowing(result); } Future<void> follow() async { try { _setStateFollowing(true); await context.feedBloc.followFeed(followeeId: widget.user.id!); } on Exception catch (e, st) { _setStateFollowing(false); debugPrint(e.toString()); debugPrintStack(stackTrace: st); } } Future<void> unfollow() async { try { _setStateFollowing(false); context.feedBloc.unfollowFeed(unfolloweeId: widget.user.id!); } on Exception catch (e, st) { _setStateFollowing(true); debugPrint(e.toString()); debugPrintStack(stackTrace: st); } } void _setStateFollowing(bool following) { setState(() { _isFollowing = following; }); } Widget build(BuildContext context) { return UserTile( user: widget.user, trailing: TextButton( onPressed: () { if (_isFollowing) { unfollow(); } else { follow(); } }, child: _isFollowing ? const Text('unfollow') : const Text('follow'), ), ); } }

This widget uses the FeedBloc to see if the current user is following a specific user and to follow/unfollow a user; see the following methods:

  • _checkIfFollowing
  • _follow
  • _unfollow

Note that with these methods you can optionally supply the feed groups. By default these are set to “user” and “timeline”.

What this essentially means is that when you “follow” a user, you are in fact subscribing your “timeline” feed to their “user” feed. Everything that the user posts to their “user” feed will now show up in your “timeline” feed.

Now, when navigating to the people page you should see all the demo users (except for who you are logged in as) and be able to follow/unfollow them:

Experiment by following different users from different accounts, and also make sure to create posts from all the different accounts.

Congratulations 🎉 ! You've successfully followed a user and added their activity to your timeline using Stream Feeds. You've also created posts as different users. You will now create the timeline page that will show the posts of the users you've followed.

Timeline Feed - Timeline Page

The timeline page will be very similar to the profile page, the only difference being that it’ll be using a different feed group - the “timeline”.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
/// Page that displays the "timeline" Stream feed group. /// /// This is a combination of your activities, and the users you follow. /// /// Displays your reactions, and reaction counts. class TimelinePage extends StatefulWidget { const TimelinePage({Key? key}) : super(key: key); State<TimelinePage> createState() => _TimelinePageState(); } class _TimelinePageState extends State<TimelinePage> { final EnrichmentFlags _flags = EnrichmentFlags() ..withReactionCounts() ..withOwnReactions(); bool _isPaginating = false; static const _feedGroup = 'timeline'; Future<void> _loadMore() async { // Ensure we're not already loading more activities. if (!_isPaginating) { _isPaginating = true; context.feedBloc .loadMoreEnrichedActivities(feedGroup: _feedGroup, flags: _flags) .whenComplete(() { _isPaginating = false; }); } } Widget build(BuildContext context) { final client = context.feedClient; return Scaffold( appBar: AppBar(title: const Text('Timeline')), body: FlatFeedCore( feedGroup: _feedGroup, userId: client.currentUser!.id, loadingBuilder: (context) => const Center( child: CircularProgressIndicator(), ), emptyBuilder: (context) => const Center(child: Text('No activities')), errorBuilder: (context, error) => Center( child: Text(error.toString()), ), limit: 10, flags: _flags, feedBuilder: ( BuildContext context, activities, ) { return RefreshIndicator( onRefresh: () { return context.feedBloc.refreshPaginatedEnrichedActivities( feedGroup: _feedGroup, flags: _flags, ); }, child: ListView.separated( itemCount: activities.length, separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { bool shouldLoadMore = activities.length - 3 == index; if (shouldLoadMore) { _loadMore(); } return ListActivityItem( activity: activities[index], feedGroup: _feedGroup, ); }, ), ); }, ), floatingActionButton: FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute<void>( builder: (context) => const ComposeActivityPage()), ); }, tooltip: 'Add Activity', child: const Icon(Icons.add), ), ); } }

As you can see, you set the _feedGroup variable to “timeline”. Other than that, it’s the same as we saw on the profile page.

This is all that is needed! You’ll now see all posts for the users you follow by navigating to the timeline page:

Fantastic! You can see the posts of the users you follow - you can also like their posts!

But, you may have noted that the current user’s posts aren’t showing in their timeline. This is because you’ll also need to follow your own “user” feed for that to show up.

In the SelectUserPage widget, after calling:

dart
1
await context.feedClient.setUser(demoUser.user, demoUser.token);

Add this code to follow your own feed:

dart
1
2
3
4
5
await context.feedBloc.followFeed( followerFeedGroup: 'timeline', followeeFeedGroup: 'user', followeeId: demoUser.user.id!, );

Hot restart the application and select the same user, their posts should now show up in their timeline feed, along with all the users they follow.

Something else you may have noted is that the profile page updates the moment you add a new activity, but the timeline page doesn’t. This isn't a bug, the timeline page will require you to reload the data in order to see the latests activities (from yourself and other users). On the timeline page you can do this by dragging down and initiating a refresh, or you can navigate to a different page of the app and back (which will cause the whole page to rebuild as we're not caching it).

Add Reactions and Child Reactions - Comments Page

Your application is almost finished. Now you only need to enable users to add comments to posts. We’ve seen how to do this with “likes” by adding reactions to activities.

We can do the exact same thing for a comment, the only difference is that we’ll need to supply some extra information - the comment we’re adding.

We also want to allow adding a “like” reaction to a comment. You may be scratching your head right now. Essentially, this will require you to add a reaction to a reaction - a child reaction!

Let’s create the CommentsPage:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
/// A page that displays all [Reaction]s/Comments for a specific /// [Activity]/Post. /// /// Enabling the current [User] to add comments and like other reactions. class CommentsPage extends StatefulWidget { const CommentsPage({ Key? key, required this.activity, }) : super(key: key); final EnrichedActivity activity; State<CommentsPage> createState() => _CommentsPageState(); } class _CommentsPageState extends State<CommentsPage> { bool _isPaginating = false; final EnrichmentFlags _flags = EnrichmentFlags()..withOwnChildren(); Future<void> _loadMore() async { // Ensure we're not already loading more reactions. if (!_isPaginating) { _isPaginating = true; context.feedBloc .loadMoreReactions(widget.activity.id!, flags: _flags) .whenComplete(() { _isPaginating = false; }); } } Future<void> _addOrRemoveLike(Reaction reaction) async { final isLikedByUser = (reaction.ownChildren?['like']?.length ?? 0) > 0; if (isLikedByUser) { FeedProvider.of(context).bloc.onRemoveChildReaction( kind: 'like', childReaction: reaction.ownChildren!['like']![0], lookupValue: widget.activity.id!, parentReaction: reaction, ); } else { FeedProvider.of(context).bloc.onAddChildReaction( kind: 'like', reaction: reaction, lookupValue: widget.activity.id!, ); } } Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Comments')), body: Column( children: [ Expanded( child: ReactionListCore( lookupValue: widget.activity.id!, kind: 'comment', loadingBuilder: (context) => const Center( child: CircularProgressIndicator(), ), emptyBuilder: (context) => const Center(child: Text('No comment reactions')), errorBuilder: (context, error) => Center( child: Text(error.toString()), ), flags: _flags, reactionsBuilder: (context, reactions) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: RefreshIndicator( onRefresh: () { return context.feedBloc.refreshPaginatedReactions( widget.activity.id!, flags: _flags, ); }, child: ListView.separated( itemCount: reactions.length, separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) { bool shouldLoadMore = reactions.length - 3 == index; if (shouldLoadMore) { _loadMore(); } final reaction = reactions[index]; final isLikedByUser = (reaction.ownChildren?['like']?.length ?? 0) > 0; final user = reaction.user; return ListTile( leading: CircleAvatar( backgroundImage: NetworkImage(user!.profileImage), ), title: Padding( padding: const EdgeInsets.all(8.0), child: Text( '${reaction.data?['text']}', style: const TextStyle(fontSize: 14), ), ), trailing: IconButton( iconSize: 14, onPressed: () { _addOrRemoveLike(reaction); }, icon: isLikedByUser ? const Icon(Icons.favorite) : const Icon(Icons.favorite_border), ), ); }, ), ), ); }, ), ), AddCommentBox(activity: widget.activity) ], ), ); } }

In the above code, you’re using a ReactionListCore to retrieve the list of reactions that meet certain criteria. That criteria are:

  • lookupValue: which is the current activity’s id.
  • kind: which is the type of reaction, a “comment” in this scenario.

This reads as follows: “Retrieve all comments for the activity with this id.”

As we saw previously, we have several different builders to handle the different data states, for example error and loading. Let’s take a closer look at the reactionsBuilder.

The reactionsBuilder builder returns the list of reactions that meet the above defined criteria. We can then do with that data whatever we want. In this sample, we’re displaying the list of comments in a list view.

Each comment also has a “like” button, that when pressed calls _addOrRemoveLike. In this method we add a child reaction to the current reaction, or if the reaction already exists we remove it.

Finally, you need to create the AddCommentBox widget, to enable users to create new comments:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
/// UI widget that displays a [TextField] to add a [Reaction]/Comment to a /// particular [activity]. class AddCommentBox extends StatefulWidget { const AddCommentBox({ Key? key, required this.activity, }) : super(key: key); final EnrichedActivity activity; State<AddCommentBox> createState() => _AddCommentBoxState(); } class _AddCommentBoxState extends State<AddCommentBox> { final textController = TextEditingController(); void dispose() { textController.dispose(); super.dispose(); } Future<void> _addComment() async { final value = textController.text; textController.clear(); if (value.isNotEmpty) { context.feedBloc.onAddReaction( kind: 'comment', activity: widget.activity, feedGroup: 'timeline', data: {'text': value}, ); } } Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32), child: TextField( controller: textController, onSubmitted: ((value) { _addComment(); }), decoration: InputDecoration( hintText: 'Add a comment', suffix: IconButton( onPressed: _addComment, icon: const Icon(Icons.send), ), ), ), ); } }

This widget manages a TextEditingController, with a TextField, that onSubmitted calls the _addComment method. This simply adds a new reaction to the current activity. The kind of reaction is a “comment”, and you specify the data argument to contain the actual comment text.

From the timeline page, select one of the posts and add a comment:

From the timeline page you can also see how many people liked and commented on a post.

Conclusion

Woohoo 🥳, you've successfully built a Feeds application using Stream and Flutter.

This is just the tip of the iceberg when it comes to Activity Feeds and what is possible using Stream. We support different feed groups for a wide variety of scenarios and use cases.

Check out our Instagram clone we made using Stream Feeds and Flutter: https://getstream.io/blog/instagram-clone-flutter/

Also see our ever-growing samples repository to see what else you can make with Stream and Flutter: https://github.com/GetStream/flutter-samples

Other resources you may find useful:

Final Thoughts

In this tutorial we saw how easy it is to use Stream API and the Flutter library to add a fully featured timeline to an application.

Adding feeds to an app can take weeks or months, even if you're a Flutter developer. Stream makes it easy and gives you the tools and the resources to improve user engagement within your app. Time to add a feed!

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding

If you're interested in a custom plan or have any questions, please contact us.