Mobile App Development with Flutter (Part 3)

Mobile App Development with Flutter (Part 3)

By Ümit Kara • September 28, 2022 • 17 mins read

From the start of this series, we checked out the basics of Dart and Flutter; and made a plan about how we are intending to develop our Twitter clone app. Now it's time to get action and start programming.

First of all, I should say that this one month adventure was fun. I didn't learn any brand-new technical skills, but I did a lot of practice in mobile app development. Coming from web development, I can say that mobile development is easier than web development, thanks to lots of prebuilt content and services. But at the end of the day, they are all tools to build stuff and it was fun.

I learned some things about Firebase on the road and I changed parts of our plan accordingly. The things about Firebase are, firstly, the Firebase functions are paid, and secondly, the Firestore extensions are paid. Due to the Turkish Lira - US Dolar currency exchange rate, I don't want to subscribe to a paid service. So I cancel these futures. Instead, for demonstration, I implemented an example trending topics collection to display on our explore page. Rather than full-text search, I decided to search only for users by their usernames when looking at the search results. Other than these, for paginated Firestore queries, I used a package. However, I will also discuss how it is possible to do it by hand.

Additionally, I should mention that this is not a learning meterial but I tried to explain the code as much as I could. Also at the end, you could check out the Github repository of the project for full source code.

With the light of all this, let's start...

Last Step: Play the Game

First of all, we need to install the Flutter extension to Visual Studio Code, if you didn't already install. Thanks to this extension we can create Flutter projects. All we need to do is open VS Code's command pallet and type 'New Project'; choose the Flutter project option. VS Code will ask you where to store the project name and where to save the project. After these steps, it will automatically create a blank project with an example counter application. You can have a look at this app...

Directory Structure

After project creation we need to create our directory layout. This step is critical because in order to efficiently code we need to find and reach files easily. There will be 3 main folders that I'll create: Models, routes and widgets. In the models folder, we will store our database models and providers. The Routes folder will hold the screens that we build. Finally, in the widgets folder we will store reuseable widgets that we'll use while building this app.

With these folders, I create a routes file(routes.dart) that will hold our routing mechanics next to the main(main.dart) file.

Routing

In the routes file, we create a RouteGenerator class. There are some static strings in this class that store the names of routes. Additionally, it has a generateRoute function that takes a RouteSettings and provides the real route to the Material app. For now we will return container widgets, but when we create our screens we add them to here for routing.

class RouteGenerator {  static const String home = '/';  static const String login = '/singin';  static const String register = '/signup';  RouteGenerator._();  static Route<dynamic> generateRoute(RouteSettings settings) {    switch (settings.name) {      case home:        return FirebaseAuth.instance.currentUser == null            ? MaterialPageRoute(                builder: (_) => const SigninScreen(),              )            : MaterialPageRoute(                builder: (_) => const TimelineScreen(),              );      case login:        return MaterialPageRoute(builder: (_) => const SigninScreen());      case register:        return MaterialPageRoute(builder: (_) => const SignupScreen());      default:        return MaterialPageRoute(            builder: (_) => ErrorWidget('Route not found'));    }  }}

In order for routing to work, we need to add it to our Material app that is in our main file. We import it, than using it's static methods we set the 'initialRoute' parameter of material app to RouteGenerator's home. After we set the onGenerateRoute parameter to the RouteGenerator's generateRoute function. With this way we can navigate through our app thanks to static strings.

class MyApp extends StatelessWidget {  const MyApp({Key? key}) : super(key: key);  @override  Widget build(BuildContext context) {    return MaterialApp(      title: 'Twitter Clone',      theme: ThemeData(        primarySwatch: Colors.blue,      ),      initialRoute: RouteGenerator.home,      onGenerateRoute: RouteGenerator.generateRoute,      debugShowCheckedModeBanner: false, // This removes debug banner    );  }}

Firebase

One of the most crucial things that we have to do is set up our app as a Firebase app. For this we need to add some packages to our app: Firebase Core, Firebase Auth, Cloud Firestore. Also we need to activate the Firebase command line tool for Dart with this command: dart pub global activate flutterfire_cli (Check out for help). After this step, we can use flutterfire(firebase dart CLI) to configure our app. All we have to do is input this command into our project folder: flutterfire configure. It will ask you whether you want to choose an existing project or make a new one. You can create a brand-new project following the steps. Then, CLI will ask you about which platforms should be configured for the Firebase projects. After a short wait, everything will be set up.

In order to use Firebase in our app, we should initialize it last. In main.dart, import the Firebase Core package, and Firebase options file that Flutterfire generated. After add this code sniplet before the runApp statement:

WidgetsFlutterBinding.ensureInitialized();await Firebase.initializeApp(    options: DefaultFirebaseOptions.currentPlatform,);

With this done, we can import sub-service packages to our files and use Firebase services.

Riverpod: State Management

As we are developing through our app, we need to share information between screens. Riverpod was selected as our state management package for this purpose. In order for Riverpod to work we need to initialize it too. We do this by changing the runApp statement to this statement:

runApp(  const ProviderScope(    child: MyApp(),  ),);

Authentication

Sign In Page

Here we are at the point that we really start the project. For sign in, we need to create a sign in page first. This page displays the scaffold with app bar. As its body we will create a stateful Sign in form widget. We created this page in the routes folder. Also we need to add it to the routes.dart file in order to navigate.

class SigninScreen extends StatelessWidget {  const SigninScreen({super.key});  @override  Widget build(BuildContext context) {    return Scaffold(      appBar: AppBar(        title: const Text('Sign in'),      ),      body: const SigninForm(),    );  }}

For the sign in widget, we need to create a form. With the form widget, we can easily validate data. In the form there are 2 text fields for email address and password, a button for sign in and a button to redirect user to sign up. We need to define a controller for each text field in order to get the text entered there afterwards.

The implementation of the sign up button is easy. All we need to do is push the sign up page to the navigator. We will create it next.

Navigator.of(context).pushReplacementNamed(RouteGenerator.register);

For the sign in button, first we need to validate the inputs. Then, using Firebase Auth, we try to sign the user in. Afterwards, if no error occurs, we push the homepage route to the navigator.

ElevatedButton(    onPressed: () async {        if (_formKey.currentState?.validate() ?? false) {        try {            await FirebaseAuth.instance.signInWithEmailAndPassword(                email: _emailController.text,                password: _passwordController.text,            );            if (!mounted) return;            Navigator.of(context)                .pushReplacementNamed(RouteGenerator.home);        } on FirebaseAuthException catch (e) {            ScaffoldMessenger.of(context).showSnackBar(            SnackBar(                content: Text(e.message ?? 'Error'),            ),            );        }        }    },

Sign Up Page

As like as the sign in page we also create a sign up screen and use the same structure. Then we create a signup steps widget in the widgets folder. As I mentioned in the previous post we will use the stepper widget here.

There will be 2 forms in this widget, 2 steps. Firstly, we obtain the credentials from the user, and then we get the information. For each form we need to define controllers and for each text field we need to define controllers and focus nodes. Focus nodes are used to change the focus of the input when the user is done. We use it to switch between text fields. Don't forget to dispose of controllers and focus nodes, otherwise memory leaks may occur.

For the steper widget we need to define an index. When the first form is valid the user could continue to fill the second form in this way. After validation of the first form, we will increase the index. We will use conditions to cancel the dynamic. If the index is 0, this means that we are in the first step; and if we cancel it we should go back to sig in screen. But if the index is 1 we should decrease it and return back to the first step.

Future<void> form1Submit(UserRegister userRegisterer,    Map<String, String> userRegistererState) async {  emailExists =      await userRegisterer.checkEmailExists(emailController.text.trim());  usernameExists = await userRegisterer      .checkUsernameExists(usernameController.text.trim());  if (step1FormKey.currentState!.validate()) {    isForm1Valid = true;    userRegistererState['email'] = emailController.text;    userRegistererState['password'] = passwordController.text;    userRegistererState['username'] = usernameController.text;    setState(() {      _index++;      nameFocusNode.requestFocus();    });  }}Future<void> form2Submit(Map<String, String> userRegistererState,    UserRegister userRegisterer, BuildContext context) async {  if (step2FormKey.currentState!.validate()) {    isForm2Valid = true;    userRegistererState['name'] = nameController.text;    userRegistererState['bio'] = bioController.text;    userRegistererState['location'] = locationController.text;    userRegistererState['website'] = websiteController.text;    if (await userRegisterer.tryRegister()) {      Navigator.of(context).pushReplacementNamed(RouteGenerator.home);    }  }}

The validation of forms works the same way as we did in the sign in form. After the second form is filled and verified, we try to sign up the user. For this we need to create some providers and a user model.

User Model

First, let's start with the user model class. This class will hold the user's information that we gather from the database.

We create a user file in the models and create a user class in it. We provide all the fields that I mentioned in the previous post as well as the ID field in addition. For some relational purposes we use the ID field, so it needs to be here. After that, we create a null-safe constructor for it, so even if we don't gather all the fields, we won't get any errors. As a final step we need to write from and to JSON functions. With these functions we could create new users and convert the Firestore data to a user object.

class User {  String? bio;  DateTime? createdAt;  List<dynamic>? followings;  List<dynamic>? followers;  List<dynamic>? tweets;  String? id;  String? link;  String? location;  String? name;  String? username;  User({    this.bio,    this.createdAt,    this.followings,    this.followers,    this.id,    this.link,    this.location,    this.name,    this.username,    this.tweets,  });  factory User.fromJson(Map<String, dynamic> json) {    return User(      bio: json['bio'] as String?,      createdAt: (json['createdAt'] as Timestamp).toDate(),      followings: json['followings'] as List<dynamic>?,      followers: json['followers'] as List<dynamic>?,      id: json['id'] as String,      link: json['link'] as String?,      location: json['location'] as String?,      name: json['name'] as String,      username: json['username'] as String,      tweets: json['tweets'] as List<dynamic>?,    );  }  Map<String, dynamic> toJson() {    return {      'bio': bio,      'createdAt': createdAt,      'followings': followings,      'followers': followers,      'id': id,      'link': link,      'location': location,      'name': name,      'username': username,      'tweets': tweets,    };  }}

User Providers

In this step we will create some providers for serving user's information through our app. First provider I want to implement is a stream provider that serves user's stream from Firebase Auth service. Another one is also a stream provider that provides the current user's profile information. This stream will return the user object that we created previously. Other than that, there is a future provider that will serve the current user's followings. Instead, we could use the profile provider, but I thought this would lead to cleaner code. And finally we create a state notifier provider. This provider is used for user registration. With this provider we serve the class UserRegister that we are about to create. With this class we hold information between steps of sign up widget and attempting to register the user.

final userStreamProvider = StreamProvider<User?>((ref) {  return FirebaseAuth.instance.authStateChanges();});final userProfileProvider = StreamProvider.autoDispose<User?>((ref) {  final userStream = ref.watch(userStreamProvider);  final user = userStream.asData?.value;  if (user == null) {    return const Stream.empty();  }  return FirebaseFirestore.instance      .collection('users')      .doc(user.uid)      .snapshots()      .map((snapshot) => User.fromJson(snapshot.data()!));});final userFollowingsProvider = FutureProvider((ref) async {  final userStream = ref.watch(userStreamProvider);  final user = userStream.asData?.value;  if (user == null) {    return const Stream.empty();  }  final userDoc =      await FirebaseFirestore.instance.collection('users').doc(user.uid).get();  final userFollowings = userDoc.data()!['followings'] as List<dynamic>;  return userFollowings;});

The class extends the StateNotifier class. As a constructor, we will create an emty map. We store all the information on this map. In this class, there are two functions to check if the entered email address or username already exists. We do this by using a firestore collection for each. And finally we have the tryRegister function that tried to register the user. Via the map we defined, first we create an account with Firebase Auth. Then using the id that firebase gave to user, we create a document and store all the profile information of user in it. Finally, we add the username and email to our database for further user registration checks.

final userRegisterProvider =    StateNotifierProvider<UserRegister, Map<String, String>>(  (ref) => UserRegister(),);class UserRegister extends StateNotifier<Map<String, String>> {  UserRegister() : super({});  Future<bool> checkUsernameExists(String username) async {    final usernames = await FirebaseFirestore.instance        .collection('credentials')        .doc('usernames')        .get();    return (usernames.data()!['usernames'] as List<dynamic>).contains(username);  }  Future<bool> checkEmailExists(String email) async {    final emails = await FirebaseFirestore.instance        .collection('credentials')        .doc('emails')        .get();    return (emails.data()!['emails'] as List<dynamic>).contains(email);  }  Future<bool> tryRegister() async {    if (state['username'] == null) {      return Future.value(false);    }    if (state['email'] == null) {      return Future.value(false);    }    if (state['password'] == null) {      return Future.value(false);    }    if (state['name'] == null) {      return Future.value(false);    }    final UserCredential newUser;    // Register user via Firebase Auth    try {      newUser = await FirebaseAuth.instance.createUserWithEmailAndPassword(        email: state['email']!,        password: state['password']!,      );    } catch (e) {      return Future.value(false);    }    FirebaseFirestore.instance      // Save user info to Firestore      ..collection('users').doc(newUser.user!.uid).set({        'id': newUser.user!.uid,        'name': state['name'],        'username': state['username'],        'email': state['email'],        'bio': state['bio'] ?? '',        'location': state['location'] ?? '',        'website': state['website'] ?? '',        'createdAt': DateTime.now(),        'followers': [newUser.user!.uid],        'followings': [],        'likes': [],        'tweets': [],      })      // Add username to username list      ..collection('credentials').doc('usernames').update({        'usernames': FieldValue.arrayUnion([state['username']]),      })      // Add email to email list      ..collection('credentials').doc('emails').update({        'emails': FieldValue.arrayUnion([state['email']]),      });    return Future.value(true);  }}

Once we have defined all the necessary things, we will use them to authenticate the user and show the timeline, our homepage.

Timeline

As I mentioned, the timeline page is meant to be our homepage. In this page we display all the tweets, retweets and replies that user and user's followings have.

As we did in authentication screens we create a timeline screen in the routes folder and add it to our route generator. The page has a scaffold widget that holds the app bar, navigation drawer we are about to create, body and the floating action button to post a new tweet.

For the app bar's title, I'm intending to insert a const text widget that writes timeline to the top, and add an action to open the search page. During the planning phase, I thought of creating a separate search page, but it became more logical to create a drawer instead. We use an icon button with a search icon for action lists.

class TimelineScreen extends StatelessWidget {  const TimelineScreen({super.key});  @override  Widget build(BuildContext context) {    return Scaffold(        appBar: AppBar(          title: const Text('Timeline'),          actions: [            IconButton(              onPressed: () {                Navigator.of(context).pushNamed(RouteGenerator.search);              },              icon: const Icon(Icons.search),            )          ],        ),        drawer: NavigationDrawerWidget(pageContext: context),        body: const TweetListview(),        floatingActionButton: FloatingActionButton(          onPressed: () {            Navigator.pushNamed(context, RouteGenerator.newTweet);          },          child: const Icon(Icons.add),        ));  }}

The navigation drawer has two purposes. Firstly, it displays current user stats, and secondly, it shows routes. For this widget we create a navigation drawer widget file in our widgets folder. This widget doesn't require any state, so we define it as stateless.

I'm planning to use the profile screen to display every profile, not just the profile of current users. Because of this, I will create a new provider to hold which users to show on the profile pages. When we click the profile button in this drawer, this provider will hold the current user's ID for us. Later in the profile page we'll use this information.

final whichProfileToShowProvider = StateProvider<String>(  (ref) => '',);

For user's stats, we'll use a drawer header widget with a column as a child. In that column we show a dummy profile image, user's name, bio, following and follower stats. We fetch these information from the provider that we defined in the previous part.

For navigation buttons, we'll use List tiles with dedicated icons as leading. Because we create static strings for route names, we can easily navigate using those.

ListTile(  leading: const Icon(Icons.home),  title: const Text('Home'),  onTap: () {    Navigator.pushReplacementNamed(pageContext, RouteGenerator.home);  },),

Tweet Model

Before creating the body, we need to build the tweet model. As we did in the user model, we declare all the fields that we used in firestore. In addition, we include the tweet's ID. We create a constructor that initializes all the fields and implement from and to JSON methods. In the from JSON method, we focus on the original tweet field. When we are fetching a tweet, if it has an original tweet, we need to fetch it as well.

class Tweet {  String? id;  String? body;  DateTime? createdAt;  List<dynamic>? likes;  List<dynamic>? media;  dynamic originalTweet;  List<dynamic>? replies;  List<dynamic>? retweets;  TweetType? type;  Future<User>? user;  String? userId;  List<dynamic>? hashtags;  List<dynamic>? mentions;  Tweet({    this.id,    this.body,    this.createdAt,    this.likes,    this.media,    this.originalTweet,    this.replies,    this.retweets,    this.type,    this.user,    this.userId,    this.hashtags,    this.mentions,  });  factory Tweet.fromJson(Map<String, dynamic> json, String? id) {    final Future<Tweet>? orgTweet;    if (json['originalTweet'] == null) {      orgTweet = null;    } else {      orgTweet = (json['originalTweet'] as DocumentReference).get().then(          (value) =>              Tweet.fromJson(value.data()! as Map<String, dynamic>, value.id));    }    return Tweet(      id: id,      body: json['body'] as String?,      createdAt: (json['createdAt'] as Timestamp).toDate(),      likes: json['likes'] as List<dynamic>?,      media: json['media'] as List<dynamic>?,      originalTweet: orgTweet,      replies: json['replies'] as List<dynamic>?,      retweets: json['retweets'] as List<dynamic>?,      type: _getTypeFromString(json['type']),      user: (json['user'] as DocumentReference).get().then(          (value) => User.fromJson(value.data()! as Map<String, dynamic>)),      userId: json['userId'] as String?,      hashtags: json['hashtags'] as List<dynamic>?,      mentions: json['mentions'] as List<dynamic>?,    );  }  Map<String, dynamic> toJson() {    final userDoc = FirebaseFirestore.instance.collection('users').doc(userId);    return {      'id': id,      'body': body,      'createdAt': createdAt,      'likes': likes ?? [],      'originalTweet': originalTweet,      'replies': replies ?? [],      'retweets': retweets ?? [],      'type': type?.name,      'user': userDoc,      'userId': userId,      'hashtags': hashtags ?? [],      'mentions': mentions ?? [],    };  }}

Other than these methods, we need to create methods to like and dislike the tweet, retweet and unretweet the tweet and reply to the tweet.

void _like(String? userId) {  likes!.add(userId);  FirebaseFirestore.instance.collection('tweets').doc(id).update({    'likes': FieldValue.arrayUnion([userId])  });  final currentUser = auth.FirebaseAuth.instance.currentUser;  if (currentUser != null) {    FirebaseFirestore.instance        .collection('users')        .doc(currentUser.uid)        .update({      'likes': FieldValue.arrayUnion([id])    });  }}void _dislike(String? userId) {  likes!.remove(userId);  FirebaseFirestore.instance      .collection('tweets')      .doc(id)      .update({'likes': likes});  final currentUser = auth.FirebaseAuth.instance.currentUser;  if (currentUser != null) {    FirebaseFirestore.instance        .collection('users')        .doc(currentUser.uid)        .update({      'likes': FieldValue.arrayRemove([id])    });  }}void likeDislike(String? userId) {  if (likes!.contains(userId)) {    _dislike(userId);  } else {    _like(userId);  }}void _retweet(String? userId) async {  retweets!.add(userId);  final tweetDoc = FirebaseFirestore.instance.collection('tweets').doc(id);  tweetDoc.update({    'retweets': FieldValue.arrayUnion([userId])  });  final newRetweet = Tweet(    body: "",    type: TweetType.retweet,    userId: userId,    createdAt: DateTime.now(),    originalTweet: tweetDoc,  );  final newRetweetDoc = await FirebaseFirestore.instance      .collection('tweets')      .add(newRetweet.toJson());  newRetweetDoc.update({    'id': newRetweetDoc.id,  });  final currentUser = auth.FirebaseAuth.instance.currentUser;  if (currentUser != null) {    FirebaseFirestore.instance        .collection('users')        .doc(currentUser.uid)        .update({      'tweets': FieldValue.arrayUnion([newRetweetDoc.id])    });  }}void _unretweet(String? userId) async {  retweets!.remove(userId);  final tweetDoc = FirebaseFirestore.instance.collection('tweets').doc(id);  tweetDoc.update({    'retweets': FieldValue.arrayRemove([userId])  });  final currentUser =      FirebaseFirestore.instance.collection('users').doc(userId);  final retweetDoc = await FirebaseFirestore.instance      .collection('tweets')      .where('originalTweet', isEqualTo: tweetDoc)      .where('userId', isEqualTo: currentUser.id)      .get();  retweetDoc.docs.first.reference.delete();  currentUser.update({    'tweets': FieldValue.arrayRemove([retweetDoc.docs.first.id])  });}void retweetUnretweet(String? userId) {  if (retweets!.contains(userId)) {    _unretweet(userId);  } else {    _retweet(userId);  }}void reply(String? userId) {  replies!.add(userId);  FirebaseFirestore.instance.collection('tweets').doc(id).update({    'replies': FieldValue.arrayUnion([userId])  });  final currentUser = auth.FirebaseAuth.instance.currentUser;  if (currentUser != null) {    FirebaseFirestore.instance        .collection('users')        .doc(currentUser.uid)        .update({      'replies': FieldValue.arrayUnion([id])    });  }}

Body: The Timeline widget

After the drawer, the one critical missing part is the body of the screen. For body we'll create a tweet listview widget in our widgets directory. Again this widget is a stateless widget.

While planning, I mentioned that I wanted to paginate the timeline stream. For pagination, I used a package called Paginate Firestore. This package handles all the pagination related stuff, so we don't need to write extra code for it. I will, however, show you my idea of how it could be done without it.

For this, I added a new provider in our providers file called timeline provider. This provider will be a StateNotifierProvider, and keep track of a timeline class that we are about to implement and return a list of tweets. The timeline class will extend the StateNotifier class. During the creation of the class, we create an empty list and call the init method. We create a separate init class because we will do some asynchronous work in it that we can't do in the class constructor.

final timelineProvider = StateNotifierProvider<Timeline, List<Tweet>>((ref) {  return Timeline();});

In the init method. We get the current user from Firebase Auth. Next, we get the following information from Firestore about the user. Finally, we query Firestore. We query the tweets collection where the creator of the tweet's id is in the user's followings list. We order it by creation time and limit it by number. Finally we need to map each returned value from this query. In this way we create a list of tweet objects with the given limit.

Via this initialization we get the latest tweets of user's followings; but we want to fetch more tweets. For this we create a new method, load more. For loading more we need to know the last document that Firestore returned. To solve this we create a class variable called last document and in the init method we initialize it to the last document that query returned. There is only one thing we are going to add to this method, which is start after document query selector. We reinitialize the last document after querying. We convert them to tweets and add them all to the list. To use this future in a listview widget in the timeline widget, we need to use a scroll indicator widget. With the scroll indicator, we could know that the user is at the bottom of the page and run the load more function to fetch tweets.

class Timeline extends StateNotifier<List<Tweet>> {  DocumentSnapshot? lastDocument;  User? user;  User? userProfile;  Timeline({this.lastDocument}) : super([]) {    init();  }  void init() async {    user = FirebaseAuth.instance.currentUser!;    userProfile = await FirebaseFirestore.instance        .collection('users')        .doc(user?.uid)        .get()        .then((value) => User.fromJson(value.data()!));    state = await FirebaseFirestore.instance        .collection('tweets')        .where('userId', whereIn: userProfile?.followings)        .orderBy('createdAt', descending: true)        .limit(5)        .get()        .then((value) {      lastDocument = value.docs.last;      return value.docs          .map((doc) => Tweet.fromJson(doc.data(), doc.id))          .toList();    });  }  void loadMore() async {    try {      final newTweets = await FirebaseFirestore.instance          .collection('tweets')          .where('userId', whereIn: userProfile?.followings)          .orderBy('createdAt', descending: true)          .startAfterDocument(lastDocument!)          .limit(5)          .get()          .then((value) {        lastDocument = value.docs[value.size - 1];        return value.docs            .map((doc) => Tweet.fromJson(doc.data(), doc.id))            .toList();      });      state = [...state, ...newTweets];    } catch (e) {      debugPrint(e.toString());    }  }}

However, instead of this, I use the paginate Firestore package to handle this. It paginates and fetches more tweets on scroll. Additionally, it displays the live feed. All we need to do is add it to the widget and give it the query.

For building each item that Firestore returned, it uses an item builder. First, we need to convert each item to a tweet object. Than we check the type of tweet and load the dedicated widget to display the tweet in the correct form.

PaginateFirestore(  itemBuilder: (context, documentSnapshots, index) {    final tweet = Tweet.fromJson(        documentSnapshots[index].data() as Map<String, dynamic>,        documentSnapshots[index].id);    if (tweet.type == TweetType.retweet) {      return TweetRetweetWidget(          tweet: tweet, originalTweet: tweet.originalTweet);    } else if (tweet.type == TweetType.reply) {      return TweetReplyWidget(          tweet: tweet, originalTweet: tweet.originalTweet);    } else {      return TweetWidget(tweet: tweet);    }  },  query: FirebaseFirestore.instance      .collection('tweets')      .where('userId', whereIn: (userFollowings.value as List<dynamic>))      .orderBy('createdAt', descending: true),  itemBuilderType: PaginateBuilderType.listView,  isLive: true,  itemsPerPage: 20,  onEmpty: const Center(    child: Text('No tweets'),  ),)

Tweet Widget

To show the tweet, create a tweet widget in the widgets directory. This widget is also a stateless widget, because we will get the tweet from the timeline widget. I use a card as a wrapper but a column could be used instead. To show user's info, I created another widget to make code more readable. With this widget, we use a future provider to retrieve the user's information and display their name and username. We display a dummy icon as your profile icon.

Finally we display the tweet's body and actions. Again for readability, I created a separate widget for tweet actions.

// Tweet User widgetWidget build(BuildContext context, WidgetRef ref) {  final whichProfileToShow = ref.watch(whichProfileToShowProvider.notifier);  return FutureBuilder(    future: user,    builder: ((context, snapshot) {      if (snapshot.hasData) {        return Row(          mainAxisAlignment: MainAxisAlignment.start,          children: [            TextButton(              onPressed: () {                whichProfileToShow.state = snapshot.data!.id!;                Navigator.pushNamed(context, '/profile');              },              child: Text(                snapshot.data!.name ?? 'Loading...',                style: const TextStyle(fontWeight: FontWeight.w600),              ),            ),            const SizedBox(width: 5),            Text(              snapshot.data != null                  ? '@${snapshot.data!.username}'                  : 'Loading...',              style: const TextStyle(color: Colors.grey),            ),            const SizedBox(width: 5),            Text(              timeago.format(tweetPostTime ?? DateTime.now()),              style: const TextStyle(color: Colors.grey),            ),          ],        );      } else {        return const Text('Loading...');      }    }),  );}

In the tweet actions we conditionally display actions icons. And use the methods of the given tweet as actions.

//Tweet actions widgetWidget build(BuildContext context) {return Row(  mainAxisAlignment: MainAxisAlignment.spaceEvenly,  children: [    Row(      mainAxisSize: MainAxisSize.min,      children: [        IconButton(          color: tweet?.replies                      ?.contains(FirebaseAuth.instance.currentUser?.uid) ??                  false              ? Colors.blue              : Colors.grey,          icon: const Icon(Icons.comment),          onPressed: () {            showDialog(              context: context,              builder: (context) {                return TweetNewReplyWidget(replyingTweet: tweet!);              },            );          },          splashRadius: 20,          tooltip: 'Reply',        ),        Text(tweet?.replies?.length.toString() ?? '0'),      ],    ),...

For the reply and retweet all we need to do is use this tweet widget to display the original tweet. In retweet we don't have a body so we don't show any text; but in reply we need to show the reply body on top of the original tweet. To prevent some unclear behaviors, I do not include tweet actions in retweets and replies.

New Tweet

The final thing left to do is to compose a new tweet. For this, we also add a separate screen and widget in dedicated folders. I won't use the local state when I create a new tweet because I want to share the tweet information between the screen and widget. In this way there could be a button on the screen to post the tweet. We will need to create a new tweet provider for this purpose. This provider is also a state notifier provider, that provides the new tweet class we are about to implement.

final newTweetProvider = StateNotifierProvider.autoDispose<NewTweet, Tweet>(  (ref) => NewTweet(),);

The new tweet class constructs an empty tweet. It has a function to update the body, set the original tweet and set the type of the tweet. The last method that we need to implement is the post method that saves the tweet object to Firestore. By using the post method, we can handle the behavior I mentioned previously.

class NewTweet extends StateNotifier<Tweet> {  NewTweet() : super(Tweet());  void updateText(String text) {    state.body = text;  }  void setOriginalTweet(Tweet tweet) {    final tweetDoc =        FirebaseFirestore.instance.collection('tweets').doc(tweet.id);    state.originalTweet = tweetDoc;  }  void setTweetType(TweetType type) {    state.type = type;  }  Future<void> post() async {    final user = FirebaseAuth.instance.currentUser;    if (user == null) {      throw Exception('User is not signed in');    }    final userDoc = await FirebaseFirestore.instance        .collection('users')        .doc(user.uid)        .get();    final userData = userDoc.data();    if (userData == null) {      throw Exception('User data is not found');    }    final userObj = my_user.User.fromJson(userData);    final tweet = Tweet(      body: state.body,      createdAt: DateTime.now(),      likes: [],      media: [],      originalTweet: state.originalTweet,      replies: [],      retweets: [],      type: state.type ?? TweetType.tweet,      user: Future.value(userObj),      userId: user.uid,      hashtags: state.hashtags,      mentions: state.mentions,    );    final newTweetDoc = await FirebaseFirestore.instance        .collection('tweets')        .add(tweet.toJson());    newTweetDoc.update({'id': newTweetDoc.id});    await FirebaseFirestore.instance.collection('users').doc(user.uid).update({      'tweets': FieldValue.arrayUnion([newTweetDoc.id]),    });  }}

In the widget we need to validate if the tweet body is empty. If this or any other error occurs, we show the snackbar to notify the user.

Profile

Before talking about the profile page, I think that I show enough code to describe the general shape and structure. Therefore, I will not post any code here anymore, but you can find the repository of the project at the end of the post. If you want you can check out dedicated files.

Mentioning that, let's talk about the profile page. For the profile page, I created the screen in the routes file and created three widgets to show tweets, retweets and replies of the user separately. I use the page view widget to display these widgets. With the page view widget we also gain to swipe left and right to change the page. Also, I added a bottom navigation bar to show the user which page they are on.

In the sub-widgets to display user's tweets, I use the paginate firestore package again. I get the user info from the provider, which determines which user to show on the profile page. Then query the user tweets and show them using the tweet widget, like we did in timeline.

At the top of the page, I created a profile header widget to display the information of the user that we hold in Firestore. Also I display tweet count as well as following and follower counts.

Explore

To begin with, I want to say that the explore page frustrates me. Because we can't define cloud functions for free in Firebase. To avoid this, I created a dummy trending topics collection in Firestore.

Before implementing this page, I added hashtags and mentions to the tweet model. They contain the mentions and hashtags the user enters. To detect mentions and hashtags, I used another package called detectable text field. This package provides us with brand-new text and text field widgets. The text widget displays the detected entities in different colors and makes them clickable. When the user clicks a hashtag we will navigate to the dedicated hashtag page. And when the user clicks on the mention we'll show the profile of that user. The text field is also capable of detecting entities. It reformats the detected entity in different colors and gives us a list of entities that are detected. This way, we can add mentions and hashtags to the tweet's dedicated fields.

I created a screen and a widget for the explore page. In the widget, I used a future builder to get trending topics from Firestore. The same as in the profile, I created a provider to store the hashtag information that a user presses for display. For displaying hashtags, I built a separate screen and widget. In this widget I used the paginate Firestore extension to display tweets whose hashtag field contains the hashtag.

Messages

Last but not least, we are here to create instant messaging. The messaging widget could be considered a separate app, but I added it to increase my practice. First of all, we need to create models for messages and chats.

The Firestore message collection will contain the message id, creation time, sender's and receiver's IDs, and a list of the respective IDs for further checks. We populate the message class with these fields and also implement the from and to JSON methods. Besides these methods, I also implemented methods that return the user of the sender and receiver. Moreover, I created a method to create a new message, create a new chat message, a method to check if chat exists.

In each message, we store chat messages as a collection, not as a field. With this approach, our querying capabilities increase and we could use the paginate firestore extension. An individual chat message consists of a body, a creation time, a from, and an is read field. For this collection, we also implement a chat message model and methods needed.

When we look at the messages widget, we query the messages collection and get all the messages that current users ID in the IDs field of messages. As we did in profile and hashtags we created a provider to hold the information about which message to show while navigating to the chat screen.

For the chat screen, I created a widget to display individual message body. This widget displays the message near or far end of the screen with different colors depending on whether it is from the current user or not. For fetching messages we again use the paginate firestore package. This time we use the reversing future. When the user scrolls up, the old messages will load.

As for the final, we implemented the search screen. For search, I used a package called firestore search that handles querying. As we couldn't implement full text search, I decided to search only for users with usernames.

For the package to work we need to add a method to the user model. This method will get a query snapshow from firestore and convert it to a user object.

Thanks to the listview builder, this object is used to dynamically display users in a list. When the tile is clicked, the user profile page will be displayed.

As a conclusion, I want to show some screenshots of the app. It's not pretty, but it works... Having said that, the only thing I didn't implement was the notification. As we progressed through this, it became quite obvious how to do this. We can create a notification collection for each user in the Firestore and display from there.

carousel image
1 / 0

Conclusion

This journey was enjoyable for me. As a reader, I hope you enjoyed the story...

Here is the link to the Github Repo if you want to check... Have a nice day.