How to Build a Clubhouse Clone App on Flutter with Agora and Firebase — A Tutorial by Perpetio: Part I

Perpetio
Perpetio
Published in
16 min readMar 30, 2021

We are sure you’ve heard about Clubhouse, right? This new app is booming! Everyone wants exclusive information, to chat to their favorite influencers, and find like-minded people.

The hype around Clubhouse is not limited to simply using it. There are many fascinating elements connected with the app, so it’s no wonder developers and business owners are exploring its functionality and want to learn how to build it. Well, guess what? We know how and want to share it with you!

Our Perpetio team was extremely curious about Clubhouse too and have already discussed its voice chat powered by Agora in one of our blog posts. But we decided to dive deeper into how it all works, going as far as actually recreating this app using Flutter, Firebase, and Agora.

This tutorial is about us having fun with Flutter and exploring the possibilities of Firebase and Agora. We are not attempting to create a second Clubhouse or sneak an Android version. It’s simply a way to learn something new and share it with others.

So buckle up, there will be quite a few things going on here! This tutorial comes in three parts, each focusing on a separate aspect of the process.

We will first discuss how to get started with an app in Flutter, make all the basic components, and create the UI. In the second part, we will discuss Firebase integration for authentication and back-end. Finally, in the third post, we will review how to embed Agora’s voice call functionality into our app.

Take a look at our Clubhouse inspired app to understand what kind of result you can have as well:

Let’s go through the process step-by-step, so you can see in detail how to recreate Clubhouse yourself!

Step 1: Creating a Flutter project

  1. Let’s begin with our Clubhouse clone app. First, we must open Visual Studio Code and install the extensions for Flutter. You can, of course, use Android Studio or IntelliJ IDEA if you prefer one of those. Flutter framework can be downloaded from this website where you will also find instructions for installing it on any operating system or tool.
  2. Next, we need to create the actual Flutter app. For this step, go to View => Command Palette, type “flutter”, and select Flutter: New Project.

3. Enter your project’s name. For instance, ours is called “clubhouse.” Then, create or select a parent directory for the new project folder.

4. You will now wait a bit for the project to be created. When the process is completed, a file main.dart will be automatically opened in your editor.

5. You should see a panel on your left in Visual Studio Code, which shows your project’s structure. Our app consists of several main structural parts:

  • The android / ios” folder is a specification of the code for each platform, including icons and program settings, where all the necessary app permissions are set.
  • The “Assets” folder contains icons, fonts, and images for the app.
  • The “lib” folder is the project’s main folder containing its structure, such as dart files. The Pubspec.yaml file includes the project’s general parameters: its name, description, versions, dependencies, and assets.

6. Now, simply replace the code in main.dart with the following:

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

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Clubhouse',
debugShowCheckedModeBanner: false,
onGenerateRoute: router,
theme: ThemeData(
scaffoldBackgroundColor: AppColor.LightBrown,
appBarTheme: AppBarTheme(
color: AppColor.LightBrown,
elevation: 0.0,
brightness: Brightness.light,
iconTheme: IconThemeData(
color: Colors.black,
),
),
),
home: AuthService().handleAuth(),
);
}
}

What’s going on here? The main() function uses the => operator for a single line function to run the app. You can see one class for the app named MyApp.

Our app is a StatelessWidget. You might have heard that Flutter is “all widgets.” Well, it’s true — most entities in any Flutter app are widgets; either stateless or stateful (they need to interact with a State object). We override the build()widget method to create the App widget, as well as the MaterialApp widget that provides numerous components needed for apps following Material Design.

7. Let’s add some color now! The app theme defines all the custom colors and fonts inside the ThemeData widget and can be used anywhere within your app via the Theme.of() function. App colors are always extracted to the app_color.dart file. Here are the colors we used:

class AppColor {
static const LightBrown = Color(0xfff1efe5);
static const LightGreen = Color(0xffE8FCD9);
static const LightGrey = Color(0xfff2f2f2);
static const AccentRed = Color(0xffDA6864);
static const AccentGreen = Color(0xff55AB67);
static const AccentBrown = Color(0xffE6E2D6);
static const AccentBlue = Color(0xff5B75A6);
static const AccentGrey = Color(0xff807970);
static const DarkBrown = Color(0xff918E81);
static const SelectedItemGrey = Color(0xffCCCFDC);
static const SelectedItemBorderGrey = Color(0xffC5C5CF);
}

8. As for app navigation, we utilize onGenerateRoute — a generator callback navigating the app to a named route. Instead of extracting the arguments directly inside the widget, we extract the arguments inside the onGenerateRoute() function and pass them to the widget. We then put the route in one file (router.dart <project dir>/lib/utils). By doing this, we are able to organize all the elements in a class so that nothing gets mixed up. No one wants that, right?

class Routers {
static const String home = '/home';
static const String phone = '/phone';
static const String sms = '/sms';
static const String profile = '/profile';
}

// ignore: missing_return
Route<dynamic> router(routeSetting) {
switch (routeSetting.name) {
case Routers.home:
return new MaterialPageRoute(
builder: (context) => HomeScreen(),
settings: routeSetting,
);
break;
case Routers.phone:
return new MaterialPageRoute(
builder: (context) => PhoneScreen(),
settings: routeSetting,
);
break;
case Routers.sms:
return new MaterialPageRoute(
builder: (context) => SmsScreen(
verificationId: routeSetting.arguments,
),
settings: routeSetting);
break;
case Routers.profile:
return new MaterialPageRoute(
builder: (context) => ProfileScreen(
profile: routeSetting.arguments,
),
settings: routeSetting);
break;
}
}

9. In the main.dart file, we have a function called handleAuth() that returns to the initial screen depending on the authentication results. We will discuss the authentication process in more detail later on.

handleAuth() {
return StreamBuilder(
stream: FirebaseAuth.instance.onAuthStateChanged,
builder: (BuildContext context, snapshot) {
if (snapshot.hasData) {
return HomeScreen();
} else {
return PhoneNumberScreen();
}
},
);
}

Step 2: Creating our Flutter app’s UI

Now, let’s move on to the UI. We will go screen by screen through the process so you can take a look at each one.

  1. We need to begin by working on the model data in the models.dart file. Here you can see two models: the user and the room.

Let’s begin with the user model. This encompasses three parameters: name, username, and profileImage.

class User {
final String name;
final String username;
final String profileImage;

User({
this.name,
this.username,
this.profileImage,
});

factory User.fromJson(json) {
return User(
name: json['name'],
username: json['username'],
profileImage: json['profileImage'],
);
}
}

As for the room model, it will include parameters like title, User list data, and speakerCount.

class Room {
final String title;
final List<User> users;
final int speakerCount;

Room({
this.title,
this.speakerCount,
this.users,
});

factory Room.fromJson(json) {
return Room(
title: json['title'],
users: json['users'].map<User>((user) {
return User(
name: user['name'],
username: user['username'],
profileImage: user['profileImage'],
);
}).toList(),
speakerCount: json['speakerCount'],
);
}
}

2. Now, let’s create global widgets. These are reusable widgets that will be used a lot in the project. We will place them in the widgets folder so we can find them easily when we need them.

  • RoundedButton is a widget that depicts a custom rounded button. We will utilize this to make almost all buttons in our app, for instance, “Start a room” or “Next” on the PhoneScreen.
class RoundedButton extends StatelessWidget {
final String text;
final double fontSize;
final Color color;
final Color disabledColor;
final EdgeInsets padding;
final Function onPressed;
final Widget child;
final bool isCircle;
final double minimumWidth;
final double minimumHeight;

const RoundedButton({
Key key,
this.text = '',
this.fontSize = 20,
this.color,
this.disabledColor,
this.padding = const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
this.onPressed,
this.isCircle = false,
this.minimumWidth = 0,
this.minimumHeight = 0,
this.child,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return ElevatedButton(
style: ButtonStyle(
minimumSize:
MaterialStateProperty.all<Size>(Size(minimumWidth, minimumHeight)),
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(states) {
if (states.contains(MaterialState.disabled)) {
return disabledColor;
}
return color;
},
),
shape: MaterialStateProperty.all<OutlinedBorder>(
isCircle
? CircleBorder()
: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
padding: MaterialStateProperty.all<EdgeInsets>(padding),
elevation: MaterialStateProperty.all<double>(0.5),
),
onPressed: onPressed,
child: text.isNotEmpty
? Text(
text,
style: TextStyle(fontSize: fontSize, color: Colors.white),
)
: child,
);
}
}
  • RoundedImage is a widget that depicts a custom rounded user icon. We will use this for creating profile images.
class RoundedImage extends StatelessWidget {
final String url;
final String path;
final double width;
final double height;
final EdgeInsets margin;
final double borderRadius;

const RoundedImage({
Key key,
this.url,
this.path = "",
this.margin,
this.width = 40,
this.height = 40,
this.borderRadius = 40,
}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
height: height,
width: width,
margin: margin,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
image: DecorationImage(
image: path.isNotEmpty ? AssetImage(path) : NetworkImage(url),
fit: BoxFit.cover,
),
),
);
}
}

3. Next, we will make two screens for our authentication process.

  • The first one is a screen for entering a phone number
class PhoneScreen extends StatefulWidget {
@override
_PhoneScreenState createState() => _PhoneScreenState();
}

class _PhoneScreenState extends State<PhoneScreen> {
final _phoneNumberController = TextEditingController();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 30, bottom: 60),
child: Column(
children: [
title(),
SizedBox(height: 50),
form(),
Spacer(),
bottom(),
],
),
),
);

This screen is made up of three main components:

Title — where we have the title text.

Widget title() {
return Text(
'Enter your phone #',
style: TextStyle(fontSize: 25),
);
}

Form — so users can enter their phone number.

Widget form() {
return Container(
width: 330,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
CountryCodePicker(
initialSelection: 'UA',
showCountryOnly: false,
alignLeft: false,
padding: const EdgeInsets.all(8),
textStyle: TextStyle(fontSize: 20),
),
Expanded(
child: Form(
child: TextFormField(
controller: _phoneNumberController,
autocorrect: false,
autofocus: false,
decoration: InputDecoration(
hintText: 'Phone Number',
hintStyle: TextStyle(
fontSize: 20,
),
border: InputBorder.none,
),
keyboardType: TextInputType.numberWithOptions(
signed: true, decimal: true),
style: TextStyle(
fontSize: 20,
color: Colors.black,
fontWeight: FontWeight.w400),
),
),
),
],
),
);
}

And the bottom part of our screen, which contains additional information and the Next button.

Widget bottom() {
return Column(
children: [
Text(
'By entering your number, you\'re agreeing to out\nTerms or Services and Privacy Policy. Thanks!',
style: TextStyle(color: Colors.grey),
),
SizedBox(height: 30),
RoundedButton(
color: AppColor.AccentBlue,
minimumWidth: 230,
disabledColor: AppColor.AccentBlue.withOpacity(0.3),
onPressed: () {},
child: Container(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Next',
style: TextStyle(color: Colors.white, fontSize: 20),
),
Icon(Icons.arrow_right_alt, color: Colors.white),
],
),
),
),
],
);
}
}
  • The second screen we use for authentication is the SmsScreen.
class SmsScreen extends StatefulWidget {
const SmsScreen({Key key}) : super(key: key);
@override
_SmsScreenState createState() => _SmsScreenState();
}

class _SmsScreenState extends State<SmsScreen> {
final _smsController = TextEditingController();

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(top: 30, bottom: 60),
child: Column(
children: [
title(),
SizedBox(height: 50),
form(),
Spacer(),
bottom(),
],
),
),
);
}

Like the PhoneScreen, it consists of three components:

Title — where we have our title text.

Widget title() {
return Padding(
padding: const EdgeInsets.only(left: 90.0, right: 90.0),
child: Text(
'Enter the code we just texted you',
style: TextStyle(fontSize: 25),
textAlign: TextAlign.center,
),
);
}
}

Form — where the user can enter the verification code.

Widget form() {
return Column(
children: [
Container(
width: 330,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
),
child: Form(
child: TextFormField(
textAlign: TextAlign.center,
controller: _smsController,
autocorrect: false,
autofocus: false,
decoration: InputDecoration(
hintText: '••••',
hintStyle: TextStyle(
fontSize: 20,
),
border: InputBorder.none,
),
keyboardType: TextInputType.number,
style: TextStyle(
fontSize: 25,
color: Colors.black,
fontWeight: FontWeight.w400),
),
),
),
SizedBox(height: 15.0),
Text(
'Didnt receive it? Tap to resend.',
style: TextStyle(color: Colors.grey),
),
],
);
}

And the bottom section that contains the Next button.

Widget bottom() {
return Column(
children: [
SizedBox(height: 30),
RoundedButton(
color: AppColor.AccentBlue,
minimumWidth: 230,
disabledColor: AppColor.AccentBlue.withOpacity(0.3),
onPressed: () {},
child: Container(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Next',
style: TextStyle(color: Colors.white, fontSize: 20),
),
Icon(Icons.arrow_right_alt, color: Colors.white),
],
),
),
),
],
);
}

4. Finally, we get to create the heart of our app — the HomeScreen.

class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: HomeAppBar(
profile: myProfile,
onProfileTab: () {
Navigator.of(context)
.pushNamed(Routers.profile, arguments: myProfile);
},
),
),
body: RoomsList(),
);
}
}

First of all, this screen contains the AppBar.

class HomeAppBar extends StatelessWidget {
final User profile;
final Function onProfileTab;

const HomeAppBar({Key key, this.profile, this.onProfileTab})
: super(key: key);

@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.topRight,
child: GestureDetector(
onTap: onProfileTab,
child: RoundedImage(
path: profile.profileImage,
width: 40,
height: 40,
),
),
);
}
}

It also houses the RoomList, which we will fetch from Firestore a bit later.

StreamBuilder<QuerySnapshot>(
stream: collection.snapshots(),
builder:
(BuildContext context, AsyncSnapshot<QuerySnapshot> snapshot) {
if (snapshot.hasError) return Text('Error: ${snapshot.error}');

return snapshot.hasData
? SmartRefresher(
enablePullDown: true,
controller: _refreshController,
onRefresh: _onRefresh,
onLoading: _onLoading,
child: ListView(
children: snapshot.data.documents
.map((DocumentSnapshot document) {
return Dismissible(
key: ObjectKey(document.data.keys),
onDismissed: (direction) {
collection.document(document.documentID).delete();
},
child: roomCard(
Room.fromJson(document),
),
);
}).toList(),
),
)
: Center(
child: CircularProgressIndicator(),
);
},
),

Within the RoomList, we will use the pull_to_refresh plugin to provide pull-up load and pull-down refresh for our room list. This includes the RefreshController and two additional functions: onLoading and onRefresh.

RefreshController _refreshController = RefreshController(
initialRefresh: false,
);

void _onRefresh() async {
await Future.delayed(
Duration(milliseconds: 1000),
);
_refreshController.refreshCompleted();
}

void _onLoading() async {
await Future.delayed(
Duration(milliseconds: 1000),
);
_refreshController.loadComplete();
}

Here is how it can be used on the screen:

SmartRefresher(
enablePullDown: true,
controller: _refreshController,
onRefresh: _onRefresh,
onLoading: _onLoading,
child: ListView()
)

The RoomList includes the GradientContainer, which adds a smoothed blur at the bottom of the screen when users scroll the list.

Widget gradientContainer() {
return Container(
height: 50,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColor.LightBrown.withOpacity(0.2),
AppColor.LightBrown,
],
),
),
);
}

It also includes the StartRoomButton that calls HomeBottomSheet, which we will discuss later.

Widget startRoomButton() {
return Container(
margin: const EdgeInsets.only(bottom: 20),
child: RoundedButton(
onPressed: () => showModalBottomSheet(),
color: AppColor.AccentGreen,
text: '+ Start a room'),
);
}

Each element we fetch from Firestore will eventually require the RoomCard return.

class RoomCard extends StatelessWidget {
final Room room;

const RoomCard({Key key, this.room}) : super(key: key);

@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 20,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
offset: Offset(0, 1),
)
]),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
room.title,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
SizedBox(height: 15),
Row(
children: [
profileImages(),
SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
userList(),
SizedBox(height: 5),
roomInfo(),
],
),
],
)
],
),
);
}

What does RoomCard return consist of? First, users’ profileImages in a room.

Widget profileImages() {
return Stack(
children: [
RoundedImage(
margin: const EdgeInsets.only(top: 15, left: 25),
path: 'assets/images/profile.png',
),
RoundedImage(path: 'assets/images/profile.png'),
],
);
}

Then, we have the userList that offers a list of users in a current room.

Widget userList() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < room.users.length; i++)
Container(
child: Row(
children: [
Text(
room.users[i].name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
SizedBox(width: 5),
Icon(Icons.chat, color: Colors.grey, size: 14),
],
),
)
],
);
}

And also, the roomInfo which is the room’s main information about our user and speaker count.

Widget roomInfo() {
return Row(
children: [
Text(
'${room.users.length}',
style: TextStyle(color: Colors.grey),
),
Icon(Icons.supervisor_account, color: Colors.grey, size: 14),
Text(
' / ',
style: TextStyle(color: Colors.grey, fontSize: 10),
),
Text(
'${room.speakerCount}',
style: TextStyle(color: Colors.grey),
),
Icon(Icons.chat_bubble_rounded, color: Colors.grey, size: 14),
],
);
}

5. Next, we will make the HomeBottomSheet that will open when the user wants to create a new room.

class HomeBottomSheet extends StatefulWidget {
final Function onButtonTap;

const HomeBottomSheet({Key key, this.onButtonTap}) : super(key: key);

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

class _HomeBottomSheetState extends State<HomeBottomSheet> {
var selectedButtonIndex = 0;

@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(
top: 10,
right: 20,
left: 20,
bottom: 20,
),
child: Column(
children: [
Container(
width: 40,
height: 5,
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(30),
),
),
SizedBox(height: 30),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (var i = 0, len = 3; i < len; i++) buildRoomCard(i),
],
),
Divider(thickness: 1, height: 60, indent: 20, endIndent: 20),
Text(
bottomSheetData[selectedButtonIndex]['selectedMessage'],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 20),
RoundedButton(
color: AppColor.AccentGreen,
onPressed: widget.onButtonTap,
text: '🎉 Let\'s go')
],
),
);
}

This screen contains three different RoomCards: Open, Social, and Closed.

Widget roomCard(int i) {
return InkWell(
borderRadius: BorderRadius.circular(15),
onTap: () {
setState(() {
selectedButtonIndex = i;
});
},
child: Ink(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
decoration: BoxDecoration(
color: i == selectedButtonIndex
? AppColor.SelectedItemGrey
: Colors.transparent,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: i == selectedButtonIndex
? AppColor.SelectedItemBorderGrey
: Colors.transparent,
),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.only(bottom: 5),
child: RoundedImage(
width: 70,
height: 70,
borderRadius: 20,
path: bottomSheetData[i]['image'],
),
),
Text(
bottomSheetData[i]['text'],
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
),
);
}
}

We can retrieve the data about these cards from the data.dart file in the <project>/lib/core folder.Now, we will work on the LetsGoButton that helps create a new user room.

Widget letsGoButton() {
return RoundedButton(
color: AppColor.AccentGreen,
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) {
return RoomScreen();
},
);
},
text: '🎉 Let\'s go');
}

6. Our next step is the ProfileScreen, which consists of the AppBar and Logout buttons.

class ProfileScreen extends StatelessWidget {
final User profile;

const ProfileScreen({Key key, this.profile}) : super(key: key);

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
actions: [
IconButton(
icon: Icon(Icons.logout),
onPressed: () {
AuthService().signOut();
Navigator.of(context)
.pushNamedAndRemoveUntil(Routers.phone, (route) => false);
},
),
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
profileBody(),
],
),
),
);
}

We also need to add the profileBody — the very core and main content of the ProfileScreen. This section consists of the user’s avatar and main information.

Widget profileBody() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RoundedImage(
path: profile.profileImage,
width: 100,
height: 100,
borderRadius: 35),
SizedBox(height: 20),
Text(
profile.name,
style: TextStyle(
fontSize: 25,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 5),
Text(
profile.username,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 15),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Text(
profileText,
style: TextStyle(fontSize: 15),
),
),
],
);
}
}

7. Now, we will make the RoomScreen that opens when users click on the RoomCard or create a new room.

What does this section include? First of all, the screen’s main part with the AppBar.

class RoomScreen extends StatefulWidget {
final Room room;

const RoomScreen({
Key key,
this.room,
}) : super(key: key);

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

class _RoomScreenState extends State<RoomScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
toolbarHeight: 150,
automaticallyImplyLeading: false,
title: Row(
children: [
IconButton(
iconSize: 30,
icon: Icon(Icons.keyboard_arrow_down),
onPressed: () => Navigator.pop(context),
),
Text(
'All rooms',
style: TextStyle(color: Colors.black, fontSize: 15),
),
Spacer(),
GestureDetector(
onTap: () => Navigator.of(context)
.pushNamed('/profile', arguments: myProfile),
child: RoundedImage(
path: myProfile.profileImage,
width: 40,
height: 40,
),
),
],
),
),
body: body(),
);
}
}

The body follows with all the RoomScreen’s main information.

Widget body() {
return Container(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: 20,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.only(
topRight: Radius.circular(30),
topLeft: Radius.circular(30),
),
),
child: Stack(
children: [
SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 80, top: 20),
child: Column(
children: [
title(widget.room.title),
SizedBox(height: 30),
speakers(
widget.room.users.sublist(0, widget.room.speakerCount),
),
others(
widget.room.users.sublist(widget.room.speakerCount),
),
],
),
),
Align(
alignment: Alignment.bottomCenter,
child: bottom(context),
),
],
),
);
}
}

The body is made up of four parts: Title — which is the main title of the opened room.

Widget title(String title) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(
title,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
Container(
child: IconButton(
onPressed: () {},
iconSize: 30,
icon: Icon(Icons.more_horiz),
),
),
],
);
}

Speakers — a list of people who are chatting in the room.

Widget speakers(List<User> users) {
return GridView.builder(
shrinkWrap: true,
physics: ScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: users.length,
itemBuilder: (gc, index) {
return RoomProfile(
user: users[index],
isModerator: index == 0,
isMute: false,
size: 80,
);
},
);
}

Others — a list of all other people in the room.

Widget others(List<User> users) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Others in the room',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Colors.grey.withOpacity(0.6),
),
),
),
GridView.builder(
shrinkWrap: true,
physics: ScrollPhysics(),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
),
itemCount: users.length,
itemBuilder: (gc, index) {
return RoomProfile(user: users[index], size: 60);
},
),
],
);
}

The bottom section of the screen contains the Leave and Hand up buttons.

Widget bottom(BuildContext context) {
return Container(
color: Colors.white,
child: Row(
children: [
RoundedButton(
onPressed: () {
Navigator.pop(context);
},
color: AppColor.LightGrey,
child: Text(
'✌️ Leave quietly',
style: TextStyle(
color: AppColor.AccentRed,
fontSize: 15,
fontWeight: FontWeight.bold),
),
),
Spacer(),
RoundedButton(
onPressed: () {},
color: AppColor.LightGrey,
isCircle: true,
child: Icon(Icons.thumb_up, size: 15, color: Colors.black),
),
],
),
);
}

8. Last but not least, we will make the RoomProfile that shows the user’s account icon in the room.

class RoomProfile extends StatelessWidget {
final User user;
final double size;
final bool isMute;
final bool isModerator;

const RoomProfile(
{Key key,
this.user,
this.size,
this.isMute = true,
this.isModerator = false})
: super(key: key);

@override
Widget build(BuildContext context) {
return Column(
children: [
Stack(
children: [
GestureDetector(
onTap: () => Navigator.of(context).pushNamed(
'/profile',
arguments: user,
),
child: RoundedImage(
path: user.profileImage,
width: size,
height: size,
),
),
mute(isMute),
],
),
SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
moderator(isModerator),
Text(
user.name.split(' ')[0],
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
],
),
],
);
}

Now, let’s try to change the user’s icon depending on their role.

If our user is a moderator in the current room, we have the following:

Widget moderator(bool isModerator) {
return isModerator
? Container(
margin: const EdgeInsets.only(right: 5),
decoration: BoxDecoration(
color: AppColor.AccentGreen,
borderRadius: BorderRadius.circular(30),
),
child: Icon(Icons.star, color: Colors.white, size: 12),
)
: Container();
}

If the user can’t speak or decides to turn off their microphone, we return this:

Widget mute(bool isMute) {
return Positioned(
right: 0,
bottom: 0,
child: isMute
? Container(
width: 25,
height: 25,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(50),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
offset: Offset(0, 1),
)
],
),
child: Icon(Icons.mic_off),
)
: Container(),
);
}
}

Well done!

That’s it! We’ve just created our app’s skeleton and UI from scratch — good job! In the second part of this tutorial, we will explore everything about Firebase Authentication and Firestore database integration. But for now, we can call it a day.

If you’re interested in seeing this project in full, you can visit our Github. Have any questions or need clarification? Or maybe there is a project you would like to discuss with our team? Don’t hesitate to contact us!

--

--