Build a University Counselling App
Build a Flutter app with Firebase Auth and Firestore for booking counselling sessions.
Introduction
30 Second Summary
University can be stressful, and students often need professional guidance to navigate academic and personal challenges. Finding the right counsellor and scheduling an appointment should be simple, not another source of stress.
In this project, you will build a student-facing Guidance and Counselling web app using Flutter and Firebase. Students can register, browse available counsellors, and book appointments with date/time selection and status tracking.
What You'll Build
A Flutter web application with Firebase Authentication for student login and Cloud Firestore for storing counsellor profiles and appointment data.
By the end of this project, you'll have:
- A working Flutter app with a calming light-green and blue colour scheme that displays a counsellor listing with profile cards.
- Secure student authentication using Firebase Auth with email and password registration and login.
- A functional appointment booking flow that writes appointment data to Cloud Firestore with status tracking (pending, approved, or rejected).
- Secret Mission: Add real-time appointment status updates so the UI refreshes automatically when a counsellor approves or rejects a booking.
Are there any prerequisites?
A basic understanding of programming concepts is all you need. Familiarity with Dart syntax is helpful but not required.
Set Up Your Development Environment
Before you can build the counselling app, you need to install the tools that power it. Flutter handles the user interface and app logic, while Firebase provides backend services for authentication and data storage.
In this step, you will install Flutter, VS Code, the Firebase CLI, and the FlutterFire CLI. You will also create your project folder and confirm everything is connected.
In this step, get ready to:
- Install Flutter SDK and VS Code with the Flutter extension.
- Install Firebase CLI and FlutterFire CLI.
- Create your Flutter project with web platform support.
Install Flutter and VS Code
Flutter is an open-source UI framework that lets you build apps for web, mobile, and desktop from a single codebase. You need the Flutter SDK installed on your machine to create and run Flutter projects.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check if Flutter is already installed by running this command:
flutter --version
✔️ I see version 3.41 or higher
Flutter is ready to go. Move on to the VS Code setup below.
ⓧ I see an older version
You have Flutter installed, but need a newer version. Upgrade by running this command:
flutter upgrade
This downloads the latest stable release. After it finishes, run flutter --version again to confirm you see 3.41 or higher.
ⓧ Command not found
Flutter is not installed yet. Follow the official install guide for your operating system.
macOS
- Visit the Flutter install page at docs.flutter.dev/get-started/install.
- Select macOS and follow the Quick start path (recommended). This uses VS Code to download and install Flutter for you.
- If you prefer the manual approach, download the SDK bundle, extract it to ~/development/, and add Flutter to your PATH by appending this line to ~/.zshenv:
export PATH="$HOME/development/flutter/bin:$PATH"
- Close and reopen your terminal, then run flutter --version to confirm it works.
Windows
- Visit the Flutter install page at docs.flutter.dev/get-started/install.
- Select Windows and follow the Quick start path (recommended). This uses VS Code to download and install Flutter for you.
- If you prefer the manual approach, download the SDK bundle, extract it to C:\dev\flutter, and add C:\dev\flutter\bin to your system PATH environment variable.
- Close and reopen PowerShell, then run flutter --version to confirm it works.
Linux
- Install the prerequisite packages by running:
sudo apt-get update
sudo apt-get install -y curl git unzip xz-utils zip libglu1-mesa
- Visit the Flutter install page at docs.flutter.dev/get-started/install.
- Select Linux and download the latest stable SDK bundle.
- Extract the SDK and add it to your PATH by appending this line to ~/.bashrc:
export PATH="$HOME/development/flutter/bin:$PATH"
- Close and reopen your terminal, then run flutter --version to confirm it works.
Now set up VS Code with the Flutter extension.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it. If VS Code is not installed, download it from code.visualstudio.com.
- Open the Extensions panel by pressing Cmd+Shift+X (macOS) or Ctrl+Shift+X (Windows).
- Search for Flutter and install the extension published by Dart Code. This also installs the Dart extension automatically.
Finally, verify your environment by running flutter doctor in your terminal.
- Verify that Flutter can see all required components by running this command:
flutter doctor
What does this command do?
The flutter doctor command checks your environment and reports on the status of your installation. It confirms that Flutter, Chrome, and your editor are all detected.
You may see warnings about Android toolchain or Xcode. That is fine for this project because you are building for web only.
flutter command not found after installing?
Make sure you added Flutter's bin directory to your system PATH and restarted your terminal. On macOS, check ~/.zshenv contains the export line. On Windows, check your environment variables include the Flutter path.
help me fix flutter not being found in my PATH
Install Firebase CLI and FlutterFire CLI
The Firebase CLI lets you manage Firebase projects from your terminal. The FlutterFire CLI generates the configuration code that connects your Flutter app to Firebase.
Both tools require Node.js to be installed first, because the Firebase CLI is distributed through npm (the Node package manager).
- Check if Node.js is installed by running this command:
node --version
✔️ I see a version number
Node.js is ready. Continue to the Firebase CLI install below.
ⓧ Command not found
Node.js is not installed yet. Download and install it from the official site.
- Visit nodejs.org/en/download and download the LTS version for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your terminal, then run node --version again to confirm it works.
- Install the Firebase CLI globally by running this command:
npm install -g firebase-tools
- Install the FlutterFire CLI by running this command:
dart pub global activate flutterfire_cli
What do these tools do?
The firebase-tools package gives you the firebase command for managing projects, deploying, and running emulators.
The flutterfire_cli package gives you the flutterfire command, which auto-generates the Firebase configuration file your Flutter app needs.
- Log in to Firebase so the CLI can access your projects by running this command:
firebase login
A browser window opens asking you to sign in with your Google account. Select your account and grant permissions.
npm command not found?
The npm command comes bundled with Node.js. If you see this error, revisit the Node.js installation above and make sure you restarted your terminal after installing.
help me fix npm not being found after installing Node.js
Create your Flutter project
With all tools installed, you can now create the Flutter project. You will target the web platform only, which avoids needing Android Studio or Xcode.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Flutter project with web support by running this command:
flutter create --platforms web counselling_app
What does this command do?
The flutter create command scaffolds a new Flutter project with all the necessary files and folders. The --platforms web flag tells Flutter to only generate web platform support, keeping the project lightweight.
This creates a folder called counselling_app on your Desktop containing the default Flutter starter app.
- Confirm the project folder was created by running:
ls
You should see counselling_app in the output.
- Open the project in VS Code by running this command:
cd counselling_app
code .
code command not working?
If code . does not open VS Code, open VS Code manually and use File then Open Folder to select the counselling_app folder on your Desktop.
VS Code opens with your project structure visible in the Explorer sidebar on the left.
Seeing errors during project creation?
Make sure you are connected to the internet. The flutter create command downloads packages the first time it runs. If you see permission errors, try running the command without sudo and check that your Flutter SDK path is in a user-writable directory.
help me fix errors when running flutter create
Your development environment is ready. Next up, you will connect this project to Firebase and add the packages that power authentication and data storage.
Configure Firebase and Add Dependencies
Your Flutter project is ready, but it needs a backend to handle user accounts and store data.
In this step, you will create a Firebase project that provides authentication and database services. Then you will connect it to your Flutter app so the two can communicate.
In this step, get ready to:
- Create a Firebase project and enable Authentication and Cloud Firestore.
- Connect the Flutter app to Firebase using the FlutterFire CLI.
- Initialize Firebase in your app code and verify it runs in Chrome.
Create a Firebase project and enable services
Firebase needs a project container before you can use any of its services. You will create one in the Firebase Console, then turn on the two services your app needs: Authentication and Cloud Firestore.
- Open the Firebase Console in your browser and sign in with your Google account.
- Click the button to create a new Firebase project.
- Enter a project name, such as counselling-app.
- Toggle off Google Analytics (not needed for this project).
- Click Create project and wait for Firebase to finish provisioning.
- Click Continue to open your new project's dashboard.
Now enable the Email/Password authentication provider.
- In the left-side panel, click Build to expand the section, then select Authentication.
- Click Get Started.
- Select the Sign-in method tab.
- Click Email/Password from the provider options, toggle the switch to Enable, then click Save.
Next, create the Cloud Firestore database.
- In the left panel, expand Build and select Firestore database.
- Click Create database.
- Select a location close to you (or accept the default).
- Under Security Rules, select Start in test mode.
- Click Create.
What is test mode?
Test mode allows any client to read and write data for 30 days. This is fine during development because it lets you build features without worrying about permission rules.
For a production app you would replace these rules with strict security policies before going live.
Connect Flutter to Firebase and add packages
The FlutterFire CLI generates a configuration file that tells your app which Firebase project to talk to. You will also install the three Firebase packages your app needs.
- Make sure your terminal is inside the counselling_app directory by running:
cd counselling_app
- Connect your Flutter app to the Firebase project by running:
flutterfire configure
What does this command do?
The flutterfire configure command detects your Firebase project, asks which platforms to configure, and generates a lib/firebase_options.dart file containing your project-specific configuration.
- When prompted, select the Firebase project you just created.
- Select web as the platform to configure.
- Accept the defaults for the remaining prompts.
- Add the three Firebase packages your app needs by running:
flutter pub add firebase_core firebase_auth cloud_firestore
What do these packages provide?
- firebase_core initializes the Firebase SDK and is required by every Firebase package.
- firebase_auth provides sign-up, login, and session management.
- cloud_firestore lets you read and write documents in your Firestore database.
- Confirm the packages were added by opening pubspec.yaml in VS Code. You should see firebase_core, firebase_auth, and cloud_firestore listed under dependencies.
flutterfire configure not found?
Make sure you activated the FlutterFire CLI in Step 1 with dart pub global activate flutterfire_cli. Also verify that your Dart global bin directory is in your system PATH.
help me fix a flutterfire configure command not found error
Initialize Firebase in your app
With the configuration file generated and packages installed, you need to tell Flutter to start Firebase before the app renders any widgets.
- In VS Code, open lib/main.dart and replace all its contents with the following code:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
// Ensure Flutter bindings are ready before calling Firebase
WidgetsFlutterBinding.ensureInitialized();
// Initialize Firebase with the generated config
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
debugShowCheckedModeBanner: false,
home: const Scaffold(body: Center(child: Text('Firebase Connected'))),
);
}
}
What does this code do?
- WidgetsFlutterBinding.ensureInitialized() tells Flutter that asynchronous work needs to finish before any widgets are drawn.
- Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) reads the config from firebase_options.dart and connects your app to your Firebase project.
- The placeholder Scaffold just shows "Firebase Connected" so you can confirm everything works before building real screens.
- Save lib/main.dart.
- Run the app in Chrome by executing:
flutter run -d chrome
- Wait for Chrome to open. You should see a white page with the text Firebase Connected in the center.
Seeing a red error screen?
If you see a Firebase initialization error, double-check that lib/firebase_options.dart exists. If it is missing, run flutterfire configure again from the counselling_app directory.
help me fix a Firebase initialization error in my Flutter app
✔️ Awesome, I've got everything!
Great. Double-check that you have saved lib/main.dart and that Chrome is showing the confirmation text.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
debugShowCheckedModeBanner: false,
home: const Scaffold(body: Center(child: Text('Firebase Connected'))),
);
}
}
Your Flutter app is now wired up to Firebase. Next up, you will build the landing page and define the calming color theme that ties the whole app together.
Build the Landing Page and App Theme
Your Flutter app is running and connected to Firebase. Now it's time to give it a real interface.
A counselling app should feel calming and approachable from the first screen. In this step, you'll define a soothing color theme, build a welcoming landing page, and wire up navigation so users can move between screens.
In this step, get ready to:
- Define a calming color scheme with light greens and blues in a reusable theme file.
- Build a landing page with a welcome message and call-to-action buttons.
- Set up named routes to connect screens together with navigation.
Define the app theme
Flutter uses a ThemeData object to control colors, shapes, and text styles across your entire app. By defining the theme in a separate file, every screen automatically inherits the same look and feel.
- Create a new file called theme.dart inside the lib/ folder of your counselling_app project.
- Paste the following code into lib/theme.dart:
import 'package:flutter/material.dart';
// Define the app-wide theme with calming greens and blues
final ThemeData appTheme = ThemeData(
primaryColor: const Color(0xFF81C784),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF81C784),
primary: const Color(0xFF81C784),
secondary: const Color(0xFF64B5F6),
surface: Colors.white,
background: const Color(0xFFF5F9F5),
),
scaffoldBackgroundColor: const Color(0xFFF5F9F5),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF81C784),
foregroundColor: Colors.white,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF81C784),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF81C784), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF2E7D32),
),
headlineMedium: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Color(0xFF2E7D32),
),
bodyLarge: TextStyle(
fontSize: 16,
color: Color(0xFF424242),
),
),
);
What does this theme do?
This theme sets up a calming university-appropriate color palette. Here is what each section controls:
- primaryColor and colorScheme set light green and light blue as the dominant colors across the app.
- cardTheme and elevatedButtonTheme apply rounded corners to give the UI a soft, approachable feel.
- textTheme defines reusable text styles for headings and body text so you don't repeat font sizes everywhere.
- Save lib/theme.dart.
Seeing red underlines in the editor?
Make sure the first line is import 'package:flutter/material.dart'; with no typos. All the color, shape, and text classes come from this import.
help me fix red underlines in my theme.dart file
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
final ThemeData appTheme = ThemeData(
primaryColor: const Color(0xFF81C784),
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF81C784),
primary: const Color(0xFF81C784),
secondary: const Color(0xFF64B5F6),
surface: Colors.white,
background: const Color(0xFFF5F9F5),
),
scaffoldBackgroundColor: const Color(0xFFF5F9F5),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF81C784),
foregroundColor: Colors.white,
elevation: 0,
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF81C784),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFF81C784), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Color(0xFF2E7D32),
),
headlineMedium: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Color(0xFF2E7D32),
),
bodyLarge: TextStyle(
fontSize: 16,
color: Color(0xFF424242),
),
),
);
Build the landing page
The landing page is the first thing students see when they open the app. It needs to communicate what the counselling service offers and give clear paths to register or sign in.
- Inside the lib/ folder, create a new folder called screens.
- Inside lib/screens/, create a new file called landing_page.dart.
- Paste the following code into lib/screens/landing_page.dart:
import 'package:flutter/material.dart';
class LandingPage extends StatelessWidget {
const LandingPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Counselling icon at the top
Icon(
Icons.psychology,
size: 100,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 24),
// App title
Text(
'Guidance & Counselling',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Description of the counselling service
Text(
'Access professional counselling services in a safe, '
'confidential, and supportive environment. '
'Book appointments with qualified counsellors at your convenience.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
// Register button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pushNamed(context, '/register'),
child: const Text('Get Started'),
),
),
const SizedBox(height: 16),
// Sign in button
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pushNamed(context, '/login'),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).primaryColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 32, vertical: 16),
),
child: Text(
'Sign In',
style: TextStyle(color: Theme.of(context).primaryColor),
),
),
),
],
),
),
),
);
}
}
What does this landing page do?
- Icons.psychology renders a brain/psychology icon as the visual anchor for the page.
- Theme.of(context) pulls in the colors and text styles from your appTheme so the page uses the calming greens automatically.
- Navigator.pushNamed(context, '/register') and Navigator.pushNamed(context, '/login') send users to the registration or login screens when they tap the buttons.
- Save lib/screens/landing_page.dart.
You won't see the landing page in the browser yet. The next substep connects it to your app's main file.
Seeing errors about Navigator or routes?
That's expected. The Navigator.pushNamed calls reference routes that don't exist yet. You'll define them in the next substep when you update main.dart.
help me fix route errors in my landing page
✔️ Awesome, I've got everything!
Great. Make sure both the screens folder and the landing_page.dart file are saved before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
class LandingPage extends StatelessWidget {
const LandingPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.psychology,
size: 100,
color: Theme.of(context).primaryColor,
),
const SizedBox(height: 24),
Text(
'Guidance & Counselling',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Access professional counselling services in a safe, '
'confidential, and supportive environment. '
'Book appointments with qualified counsellors at your convenience.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pushNamed(context, '/register'),
child: const Text('Get Started'),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => Navigator.pushNamed(context, '/login'),
style: OutlinedButton.styleFrom(
side: BorderSide(color: Theme.of(context).primaryColor),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 32, vertical: 16),
),
child: Text(
'Sign In',
style: TextStyle(color: Theme.of(context).primaryColor),
),
),
),
],
),
),
),
);
}
}
Set up the navigation routes
Flutter's MaterialApp widget supports named routes, which let you navigate between screens using simple string paths like '/login'. You'll now update main.dart to import your new files and define all the routes your app will use.
- Open lib/main.dart and replace its entire contents with the following code:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'theme.dart';
import 'screens/landing_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
// Apply the custom theme to the entire app
theme: appTheme,
debugShowCheckedModeBanner: false,
// Set the landing page as the starting screen
initialRoute: '/',
routes: {
'/': (context) => const LandingPage(),
// Placeholder screens for routes we'll build later
'/login': (context) => const Scaffold(body: Center(child: Text('Login'))),
'/register': (context) => const Scaffold(body: Center(child: Text('Register'))),
'/dashboard': (context) => const Scaffold(body: Center(child: Text('Dashboard'))),
'/counsellors': (context) => const Scaffold(body: Center(child: Text('Counsellors'))),
},
);
}
}
What changed in main.dart?
- import 'theme.dart' and import 'screens/landing_page.dart' bring in the theme and landing page you just created.
- theme: appTheme applies your calming color scheme to every screen in the app.
- initialRoute: '/' tells the app to start on the landing page.
- The routes map defines all the named paths. The login, register, dashboard, and counsellors routes are placeholders you'll replace with real screens in later steps.
- Save lib/main.dart.
- Run the app in Chrome by running this command in your terminal:
flutter run -d chrome
You should see the landing page with a green psychology icon, the title "Guidance & Counselling" in dark green, a description paragraph, and two buttons ("Get Started" and "Sign In") on a light mint-green background.
Don't see the landing page?
- Check that lib/screens/landing_page.dart exists with the class named LandingPage. The import path in main.dart must match the folder structure exactly.
- Verify that lib/theme.dart exports the appTheme variable. If the import fails, you'll see a compile error in the terminal.
- If the app was already running, try stopping it with Ctrl+C and running flutter run -d chrome again. Route changes sometimes need a full restart.
help me debug my Flutter landing page not showing
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'theme.dart';
import 'screens/landing_page.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
theme: appTheme,
debugShowCheckedModeBanner: false,
initialRoute: '/',
routes: {
'/': (context) => const LandingPage(),
'/login': (context) => const Scaffold(body: Center(child: Text('Login'))),
'/register': (context) => const Scaffold(body: Center(child: Text('Register'))),
'/dashboard': (context) => const Scaffold(body: Center(child: Text('Dashboard'))),
'/counsellors': (context) => const Scaffold(body: Center(child: Text('Counsellors'))),
},
);
}
}
Your app now has a welcoming front door with a consistent design system behind it. Next up, you'll build the authentication screens so students can register and sign in.
Implement Student Authentication
Your app has a landing page with working navigation buttons, but they lead to placeholder screens. Time to make those buttons useful.
In this step, you will build real registration and login screens that connect to Firebase Authentication. Students will be able to create accounts with their name, email, and student ID. Their profile data will be saved to Cloud Firestore so the app can reference it later.
In this step, get ready to:
- Build a registration screen with form validation and Firebase Auth.
- Build a login screen that signs in existing users.
- Wire both screens into the app routes and test the flow.
Create the registration screen
The registration screen needs to collect four pieces of information: full name, email, student ID, and password. It also needs to validate those inputs before submitting them to Firebase.
- Create a new file at lib/screens/register_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
// Form key for validation
final _formKey = GlobalKey<FormState>();
// Controllers to read text field values
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _studentIdController = TextEditingController();
final _passwordController = TextEditingController();
// Loading state to disable button during submission
bool _isLoading = false;
Future<void> _register() async {
// Stop if form validation fails
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
// Create the Firebase Auth account
final credential =
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text,
);
// Save extra profile data to Firestore under the user's UID
await FirebaseFirestore.instance
.collection('students')
.doc(credential.user!.uid)
.set({
'name': _nameController.text.trim(),
'email': _emailController.text.trim(),
'studentId': _studentIdController.text.trim(),
'createdAt': FieldValue.serverTimestamp(),
});
if (mounted) {
Navigator.pushReplacementNamed(context, '/dashboard');
}
} on FirebaseAuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message ?? 'Registration failed')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_studentIdController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Account')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Join the Counselling Platform',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
prefixIcon: Icon(Icons.person),
),
validator: (value) =>
value == null || value.isEmpty ? 'Enter your name' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email Address',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) return 'Enter your email';
if (!value.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _studentIdController,
decoration: const InputDecoration(
labelText: 'Student ID',
prefixIcon: Icon(Icons.badge),
),
validator: (value) => value == null || value.isEmpty
? 'Enter your student ID'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _register,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Register'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () =>
Navigator.pushReplacementNamed(context, '/login'),
child: const Text('Already have an account? Sign In'),
),
],
),
),
),
);
}
}
What does this code do?
This file creates a complete registration flow with two layers: form validation and Firebase submission.
- Four TextFormField widgets collect the student's name, email, student ID, and password. Each has a validator that checks for empty inputs (and minimum length for passwords).
- The _register() method first calls createUserWithEmailAndPassword to create a Firebase Auth account. Then it writes the student's profile data (name, email, studentId, createdAt) to a students Firestore collection using the Auth UID as the document ID.
- The _isLoading flag disables the button during submission so the user cannot tap it twice.
- If Firebase returns an error (duplicate email, weak password), it appears as a SnackBar at the bottom of the screen.
- Save lib/screens/register_screen.dart.
Seeing red underlines in the file?
If VS Code shows import errors for firebase_auth or cloud_firestore, make sure you ran flutter pub add firebase_core firebase_auth cloud_firestore in the previous step. Run flutter pub get to refresh dependencies.
help me fix import errors in my register_screen.dart file
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _studentIdController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
Future<void> _register() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final credential =
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text,
);
await FirebaseFirestore.instance
.collection('students')
.doc(credential.user!.uid)
.set({
'name': _nameController.text.trim(),
'email': _emailController.text.trim(),
'studentId': _studentIdController.text.trim(),
'createdAt': FieldValue.serverTimestamp(),
});
if (mounted) {
Navigator.pushReplacementNamed(context, '/dashboard');
}
} on FirebaseAuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message ?? 'Registration failed')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_studentIdController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create Account')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Join the Counselling Platform',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
prefixIcon: Icon(Icons.person),
),
validator: (value) =>
value == null || value.isEmpty ? 'Enter your name' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email Address',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) return 'Enter your email';
if (!value.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _studentIdController,
decoration: const InputDecoration(
labelText: 'Student ID',
prefixIcon: Icon(Icons.badge),
),
validator: (value) => value == null || value.isEmpty
? 'Enter your student ID'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
validator: (value) {
if (value == null || value.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _register,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Register'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () =>
Navigator.pushReplacementNamed(context, '/login'),
child: const Text('Already have an account? Sign In'),
),
],
),
),
),
);
}
}
Create the login screen
The login screen is simpler than registration. It only needs email and password fields, then calls Firebase Auth to sign the student in.
- Create a new file at lib/screens/login_screen.dart and add the following code:
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
Future<void> _login() async {
// Stop if form validation fails
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
// Sign in with existing Firebase Auth credentials
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (mounted) {
Navigator.pushReplacementNamed(context, '/dashboard');
}
} on FirebaseAuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message ?? 'Login failed')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Sign in to access your counselling dashboard',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email Address',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) =>
value == null || value.isEmpty ? 'Enter your email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
validator: (value) =>
value == null || value.isEmpty ? 'Enter your password' : null,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Sign In'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () =>
Navigator.pushReplacementNamed(context, '/register'),
child: const Text('Don\'t have an account? Register'),
),
],
),
),
),
);
}
}
What does this code do?
- The _login() method calls signInWithEmailAndPassword to authenticate existing users against Firebase Auth.
- On success, the app navigates to /dashboard using pushReplacementNamed so the user cannot tap Back to return to the login screen.
- The TextButton at the bottom lets users navigate to the registration screen if they do not have an account yet.
- Save lib/screens/login_screen.dart.
Seeing a 'FirebaseAuthException' error?
Make sure you enabled the Email/Password provider in your Firebase Console under Authentication > Sign-in method. Without it, Firebase rejects all email sign-in attempts.
help me debug Firebase Auth sign-in errors in my Flutter app
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
Future<void> _login() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text,
);
if (mounted) {
Navigator.pushReplacementNamed(context, '/dashboard');
}
} on FirebaseAuthException catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(e.message ?? 'Login failed')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Sign In')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Welcome Back',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
Text(
'Sign in to access your counselling dashboard',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 32),
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email Address',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
validator: (value) =>
value == null || value.isEmpty ? 'Enter your email' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
prefixIcon: Icon(Icons.lock),
),
obscureText: true,
validator: (value) =>
value == null || value.isEmpty ? 'Enter your password' : null,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Sign In'),
),
const SizedBox(height: 16),
TextButton(
onPressed: () =>
Navigator.pushReplacementNamed(context, '/register'),
child: const Text('Don\'t have an account? Register'),
),
],
),
),
),
);
}
}
Connect auth screens to your app routes
Both screens are built. Now you need to replace the placeholder routes in main.dart so the app actually uses them.
- In lib/main.dart, find the import section at the top of the file. It currently ends with:
import 'screens/landing_page.dart';
- Add these two imports directly below that line:
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
- Next, find the routes map inside the MaterialApp widget. Locate these two placeholder lines:
'/login': (context) => const Scaffold(body: Center(child: Text('Login'))),
'/register': (context) => const Scaffold(body: Center(child: Text('Register'))),
- Replace those two lines with the real screen widgets:
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
Why pushReplacementNamed instead of pushNamed?
When a user logs in, you do not want them to tap the Back button and land on the login screen again. pushReplacementNamed removes the current route from the navigation stack, so the dashboard becomes the new root screen.
- Save lib/main.dart.
- Run the app in Chrome by running:
flutter run -d chrome
- On the landing page, tap Get Started. You should see the registration form with four fields and a green Register button.
- Fill in all four fields with test data (use any email format like test@example.com and a password of at least 6 characters).
- Tap Register. The app should navigate to the Dashboard placeholder screen.
How do I verify the user was created?
Open your Firebase Console, go to Authentication > Users. You should see the email you just registered with.
Then go to Firestore Database and look for a students collection. It should contain a document with the name, email, and studentId you entered.
- Navigate back to the landing page and tap Sign In. Enter the same email and password you just registered with.
- Tap Sign In. The app should navigate to the Dashboard placeholder screen again.
Registration works but login fails?
Double check that you are entering the exact same email and password used during registration. Firebase Auth is case-sensitive on passwords.
If you see a SnackBar error message, read it carefully. It will tell you whether the issue is an invalid email, wrong password, or a network problem.
help me debug why login fails after a successful registration in my Flutter app
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'theme.dart';
import 'screens/landing_page.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
theme: appTheme,
debugShowCheckedModeBanner: false,
initialRoute: '/',
routes: {
'/': (context) => const LandingPage(),
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
'/dashboard': (context) => const Scaffold(body: Center(child: Text('Dashboard'))),
'/counsellors': (context) => const Scaffold(body: Center(child: Text('Counsellors'))),
},
);
}
}
Students can now register and log in. Next up, you will build the counsellor listing page so students have someone to book appointments with.
Create the Counsellor Listing Page
Your app now handles registration and login. Students can create accounts and sign in securely.
The next piece students need is a way to browse available counsellors. In this step, you will seed your database with sample counsellor profiles and build a listing page that reads those profiles in real time from Cloud Firestore.
In this step, get ready to:
- Seed the Firestore counsellors collection with sample documents.
- Build a CounsellorCard widget with availability indicators.
- Create a real-time counsellor listing page using StreamBuilder.
Seed the Firestore counsellors collection
Your app needs counsellor data to display. You will add sample documents directly in the Firebase Console so the listing page has something to show immediately.
- Open the Firebase Console in your browser and navigate to your project.
- In the left sidebar, click Firestore Database.
- Click the Data tab at the top.
- Click Add collection.
- Enter counsellors as the Collection ID, then click Next.
- Click Auto ID to generate a document ID.
- Add the following fields to your first document:
- name (string): Dr. Sarah Johnson.
- specialization (string): Stress & Anxiety Management.
- availability (boolean): true.
- bio (string): 10 years of experience helping students manage academic stress and anxiety..
- imageUrl (string): leave as an empty string "".
- Click Save.
- Click Add document to add a second counsellor.
- Click Auto ID again and enter these fields:
- name: Prof. Michael Chen.
- specialization: Career Guidance.
- availability: true.
- bio: Specializes in career planning and graduate school preparation..
- imageUrl: "".
- Click Save.
- Click Add document one more time for a third counsellor.
- Click Auto ID and enter these fields:
- name: Dr. Amara Obi.
- specialization: Relationship & Family Issues.
- availability: false.
- bio: Helps students navigate personal relationships and family dynamics..
- imageUrl: "".
- Click Save.
You should now see three documents in your counsellors collection in the Firebase Console data viewer.
Why seed data manually?
Adding sample documents through the Firebase Console gives your app data to display immediately. In a production app, a separate admin interface would manage counsellor profiles.
For this project, manual seeding is the fastest way to test your listing page.
Don't see your documents?
Make sure you clicked Save after entering each document's fields. If the collection does not appear, refresh the Firebase Console page and check the Data tab.
Help me troubleshoot my missing Firestore documents
Build the CounsellorCard widget
Each counsellor needs a visual card component. The card displays the counsellor's name, specialization, an availability badge, their bio, and a booking button.
- Create a new file at lib/screens/counsellors_screen.dart in VS Code.
- Add the following code to define the imports and CounsellorCard widget:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'booking_screen.dart';
class CounsellorCard extends StatelessWidget {
final String counsellorId;
final String name;
final String specialization;
final bool isAvailable;
final String bio;
final VoidCallback onBook;
const CounsellorCard({
super.key,
required this.counsellorId,
required this.name,
required this.specialization,
required this.isAvailable,
required this.bio,
required this.onBook,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// Circular avatar with first letter of name
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF64B5F6),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
// Name and specialization
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
specialization,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
// Availability badge
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isAvailable
? const Color(0xFF81C784).withOpacity(0.2)
: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isAvailable ? 'Available' : 'Unavailable',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isAvailable
? const Color(0xFF2E7D32)
: Colors.grey[600],
),
),
),
],
),
// Bio section
if (bio.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
bio,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
const SizedBox(height: 12),
// Book button - disabled when unavailable
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isAvailable ? onBook : null,
child: const Text('Book Appointment'),
),
),
],
),
),
);
}
}
What does this code do?
The CounsellorCard widget takes in counsellor data and renders a card with three key sections.
- A CircleAvatar shows the first letter of the counsellor's name against a blue background.
- A green or grey badge indicates whether the counsellor is currently available.
- The ElevatedButton is disabled (greyed out) when isAvailable is false, preventing students from booking unavailable counsellors.
Create the counsellor listing page with StreamBuilder
Now you need a page that fetches counsellor data from Firestore and displays a scrollable list of cards. StreamBuilder listens to the Firestore collection in real time, so any changes you make in the console appear instantly in the app.
- In lib/screens/counsellors_screen.dart, add the CounsellorsScreen class between the imports and the CounsellorCard class:
class CounsellorsScreen extends StatelessWidget {
const CounsellorsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Our Counsellors')),
body: StreamBuilder<QuerySnapshot>(
// Listen to real-time changes in the counsellors collection
stream:
FirebaseFirestore.instance.collection('counsellors').snapshots(),
builder: (context, snapshot) {
// Show loading spinner while waiting for data
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Show message if no counsellors exist
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return const Center(
child: Text('No counsellors available at the moment.'),
);
}
final counsellors = snapshot.data!.docs;
// Build a scrollable list of counsellor cards
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: counsellors.length,
itemBuilder: (context, index) {
final data = counsellors[index].data() as Map<String, dynamic>;
return CounsellorCard(
counsellorId: counsellors[index].id,
name: data['name'] ?? 'Unknown',
specialization: data['specialization'] ?? 'General',
isAvailable: data['availability'] ?? false,
bio: data['bio'] ?? '',
onBook: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookingScreen(
counsellorId: counsellors[index].id,
counsellorName: data['name'] ?? 'Unknown',
),
),
);
},
);
},
);
},
),
);
}
}
What does this code do?
StreamBuilder wraps the entire page body and subscribes to the counsellors Firestore collection. Every time a document is added, updated, or deleted, the widget rebuilds automatically.
The builder handles three states: loading (shows a spinner), empty (shows a message), and data-ready (renders a ListView.builder of CounsellorCard widgets).
The CounsellorsScreen imports booking_screen.dart which does not exist yet. You need a placeholder file so the app can compile.
- Create a new file at lib/screens/booking_screen.dart with this temporary placeholder:
import 'package:flutter/material.dart';
// Placeholder - full booking screen built in the next step
class BookingScreen extends StatelessWidget {
final String counsellorId;
final String counsellorName;
const BookingScreen({
super.key,
required this.counsellorId,
required this.counsellorName,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Book Appointment')),
body: const Center(child: Text('Booking screen coming soon')),
);
}
}
Why create a placeholder?
Dart requires all imports to resolve at compile time. The CounsellorsScreen navigates to BookingScreen when a student taps the booking button. This minimal placeholder lets the app compile now. You will replace it with the full booking flow in the next step.
Next, update main.dart to use your new CounsellorsScreen instead of the placeholder route.
- In lib/main.dart, find the existing import section at the top of the file and add this new import:
import 'screens/counsellors_screen.dart';
- In the same file, find this line inside the routes map:
'/counsellors': (context) => const Scaffold(body: Center(child: Text('Counsellors'))),
- Replace it with:
'/counsellors': (context) => const CounsellorsScreen(),
- Save all three files: counsellors_screen.dart, booking_screen.dart, and main.dart.
- Run the app in Chrome by running:
flutter run -d chrome
Navigate to the counsellors page by adding #/counsellors to the URL in your browser. You should see three counsellor cards with names, specializations, and availability badges.
Seeing a blank page or errors?
Check that your counsellors_screen.dart file has both the CounsellorsScreen and CounsellorCard classes. The CounsellorsScreen class must appear above CounsellorCard in the file.
If you see a loading spinner that never resolves, confirm that you spelled the collection name counsellors exactly the same in both Firestore and your Dart code.
Help me debug my counsellors listing page
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: counsellors_screen.dart, booking_screen.dart, and main.dart.
ⓧ I'd like to double check the full code
Here is the complete lib/screens/counsellors_screen.dart file:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'booking_screen.dart';
class CounsellorsScreen extends StatelessWidget {
const CounsellorsScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Our Counsellors')),
body: StreamBuilder<QuerySnapshot>(
stream:
FirebaseFirestore.instance.collection('counsellors').snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return const Center(
child: Text('No counsellors available at the moment.'),
);
}
final counsellors = snapshot.data!.docs;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: counsellors.length,
itemBuilder: (context, index) {
final data = counsellors[index].data() as Map<String, dynamic>;
return CounsellorCard(
counsellorId: counsellors[index].id,
name: data['name'] ?? 'Unknown',
specialization: data['specialization'] ?? 'General',
isAvailable: data['availability'] ?? false,
bio: data['bio'] ?? '',
onBook: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => BookingScreen(
counsellorId: counsellors[index].id,
counsellorName: data['name'] ?? 'Unknown',
),
),
);
},
);
},
);
},
),
);
}
}
class CounsellorCard extends StatelessWidget {
final String counsellorId;
final String name;
final String specialization;
final bool isAvailable;
final String bio;
final VoidCallback onBook;
const CounsellorCard({
super.key,
required this.counsellorId,
required this.name,
required this.specialization,
required this.isAvailable,
required this.bio,
required this.onBook,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFF64B5F6),
child: Text(
name.isNotEmpty ? name[0].toUpperCase() : '?',
style: const TextStyle(
fontSize: 24,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
specialization,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isAvailable
? const Color(0xFF81C784).withOpacity(0.2)
: Colors.grey.withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
isAvailable ? 'Available' : 'Unavailable',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isAvailable
? const Color(0xFF2E7D32)
: Colors.grey[600],
),
),
),
],
),
if (bio.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
bio,
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isAvailable ? onBook : null,
child: const Text('Book Appointment'),
),
),
],
),
),
);
}
}
And here is the updated lib/main.dart:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'theme.dart';
import 'screens/landing_page.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
import 'screens/counsellors_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
theme: appTheme,
debugShowCheckedModeBanner: false,
initialRoute: '/',
routes: {
'/': (context) => const LandingPage(),
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
'/dashboard': (context) => const Scaffold(body: Center(child: Text('Dashboard'))),
'/counsellors': (context) => const CounsellorsScreen(),
},
);
}
}
Your counsellor directory is live. Students can now browse available counsellors and see who is ready for appointments. Next up, you will build the appointment booking flow so students can actually schedule sessions.
Build the Appointment Booking Flow
Your counsellor listing is live and students can browse who's available. The next piece of the puzzle is letting students actually book a session.
In this step, you'll build a booking screen where students pick a date, choose a time slot, select whether they want a physical or online session, and submit the appointment to Cloud Firestore.
In this step, get ready to:
- Create the booking screen with date and time selection widgets.
- Write the booking submission logic that saves appointments to Firestore.
- Add a confirmation dialog and connect the screen to your app.
Create the booking screen with date and time selection
The booking screen needs to receive the counsellor's details from the previous screen and present the student with scheduling options.
- Create a new file at lib/screens/booking_screen.dart inside your counselling_app project folder.
- Add the following code to set up the screen's structure, state variables, and date selection logic:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
class BookingScreen extends StatefulWidget {
final String counsellorId;
final String counsellorName;
const BookingScreen({
super.key,
required this.counsellorId,
required this.counsellorName,
});
@override
State<BookingScreen> createState() => _BookingScreenState();
}
class _BookingScreenState extends State<BookingScreen> {
DateTime? _selectedDate;
String? _selectedTime;
String _sessionType = 'Physical';
bool _isLoading = false;
// Available time slots for booking
final List<String> _timeSlots = [
'09:00 AM',
'10:00 AM',
'11:00 AM',
'02:00 PM',
'03:00 PM',
'04:00 PM',
];
// Opens the date picker dialog
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 1)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) {
setState(() => _selectedDate = picked);
}
}
What does this code do?
The BookingScreen accepts counsellorId and counsellorName as constructor parameters. These are passed from the counsellor card the student tapped.
The state class tracks the student's selections: a date, a time slot, the session type (Physical or Online), and a loading flag. The _selectDate() method opens Flutter's built-in date picker, restricted to the next 30 days.
Write the booking submission logic with confirmation dialog
Before writing to Firestore, the app should confirm the booking details with the student. The submission method handles validation, shows a confirmation dialog, and then creates the appointment document.
- In booking_screen.dart, add the following _submitBooking method directly below _selectDate():
// Validates selections, shows confirmation dialog, then writes to Firestore
Future<void> _submitBooking() async {
if (_selectedDate == null || _selectedTime == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a date and time')),
);
return;
}
// Show confirmation dialog before submitting
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Booking'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Counsellor: ${widget.counsellorName}'),
Text(
'Date: ${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}'),
Text('Time: $_selectedTime'),
Text('Session Type: $_sessionType'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm'),
),
],
),
);
if (confirm != true) return;
setState(() => _isLoading = true);
try {
// Get the currently authenticated student
final user = FirebaseAuth.instance.currentUser!;
// Create appointment document in Firestore
await FirebaseFirestore.instance.collection('appointments').add({
'studentId': user.uid,
'counsellorId': widget.counsellorId,
'counsellorName': widget.counsellorName,
'date':
'${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}',
'time': _selectedTime,
'sessionType': _sessionType,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Appointment booked successfully!'),
backgroundColor: Color(0xFF81C784),
),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Booking failed: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
What does this code do?
- First, it validates that both a date and time are selected. If not, it shows a SnackBar error.
- Then it shows an AlertDialog summarizing the booking details. The student can cancel or confirm.
- On confirmation, it writes a new document to the appointments Firestore collection. The status field starts as 'pending' so counsellors can later approve or reject it.
- On success, a green SnackBar confirms the booking and Navigator.pop sends the student back to the counsellors screen.
Now add the build method below _submitBooking() to define the screen's visual layout:
- Add the following build method and closing braces to complete the file:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Book Appointment')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header showing counsellor name
Text(
'Book with ${widget.counsellorName}',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
// Date picker section
Text(
'Select Date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today),
label: Text(
_selectedDate == null
? 'Choose a date'
: '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}',
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 24),
// Time slot selection
Text(
'Select Time',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _timeSlots.map((time) {
final isSelected = _selectedTime == time;
return ChoiceChip(
label: Text(time),
selected: isSelected,
selectedColor: const Color(0xFF81C784),
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
),
onSelected: (selected) {
setState(() => _selectedTime = selected ? time : null);
},
);
}).toList(),
),
const SizedBox(height: 24),
// Session type toggle
Text(
'Session Type',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: RadioListTile<String>(
title: const Text('Physical'),
value: 'Physical',
groupValue: _sessionType,
onChanged: (value) =>
setState(() => _sessionType = value!),
activeColor: const Color(0xFF81C784),
),
),
Expanded(
child: RadioListTile<String>(
title: const Text('Online'),
value: 'Online',
groupValue: _sessionType,
onChanged: (value) =>
setState(() => _sessionType = value!),
activeColor: const Color(0xFF81C784),
),
),
],
),
const SizedBox(height: 32),
// Submit button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submitBooking,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Book Appointment'),
),
),
],
),
),
);
}
}
What does the build method do?
- An OutlinedButton.icon triggers the date picker and displays the selected date.
- A Wrap of ChoiceChip widgets shows the six time slots. The selected chip turns green.
- Two RadioListTile widgets let the student toggle between Physical and Online sessions.
- The ElevatedButton disables itself while the booking is submitting to prevent duplicate taps.
- Save booking_screen.dart.
Seeing red underlines in your editor?
Your editor may show errors because counsellors_screen.dart already imports booking_screen.dart. Make sure the class name BookingScreen and both constructor parameters (counsellorId and counsellorName) match exactly as shown above.
help me fix red underlines in my booking_screen.dart file
Connect the booking screen to your app
The counsellors screen already imports and navigates to BookingScreen. You just need to add the import to main.dart so the analyzer resolves the dependency across the project.
- In lib/main.dart, find this line near the top of the file:
import 'screens/counsellors_screen.dart';
- Add the following import directly below it:
import 'screens/booking_screen.dart';
- Save main.dart.
- In your terminal, make sure the app is running with flutter run -d chrome. Navigate to the counsellors page, tap Book Appointment on an available counsellor, and you should see the booking screen with date picker, time slots, and session type options.
How do I test the full booking flow?
- Select a date using the calendar button.
- Tap a time slot chip (it turns green when selected).
- Choose Physical or Online.
- Tap Book Appointment. A confirmation dialog appears summarizing your choices.
- Tap Confirm. A green SnackBar says the appointment was booked, and you return to the counsellors list.
- Open the Firebase Console. Navigate to Firestore Database and check the appointments collection. You should see a new document with your booking details including a status field set to pending.
Booking not appearing in Firestore?
Make sure you are logged in as a registered student before booking. The _submitBooking method uses FirebaseAuth.instance.currentUser! which will throw if no user is signed in.
Also check your Firestore security rules allow writes. During development, your rules may still be in test mode (allow all reads and writes). If you see a permission error, verify the rules in the Firebase Console under Firestore Database then Rules.
help me debug why my appointment is not saving to Firestore
✔️ Awesome, I've got everything!
Great. Double check you have saved both booking_screen.dart and main.dart.
ⓧ I'd like to double check the full code
Here is the complete lib/screens/booking_screen.dart:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
class BookingScreen extends StatefulWidget {
final String counsellorId;
final String counsellorName;
const BookingScreen({
super.key,
required this.counsellorId,
required this.counsellorName,
});
@override
State<BookingScreen> createState() => _BookingScreenState();
}
class _BookingScreenState extends State<BookingScreen> {
DateTime? _selectedDate;
String? _selectedTime;
String _sessionType = 'Physical';
bool _isLoading = false;
final List<String> _timeSlots = [
'09:00 AM',
'10:00 AM',
'11:00 AM',
'02:00 PM',
'03:00 PM',
'04:00 PM',
];
Future<void> _selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now().add(const Duration(days: 1)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 30)),
);
if (picked != null) {
setState(() => _selectedDate = picked);
}
}
Future<void> _submitBooking() async {
if (_selectedDate == null || _selectedTime == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a date and time')),
);
return;
}
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Booking'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Counsellor: ${widget.counsellorName}'),
Text(
'Date: ${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}'),
Text('Time: $_selectedTime'),
Text('Session Type: $_sessionType'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Confirm'),
),
],
),
);
if (confirm != true) return;
setState(() => _isLoading = true);
try {
final user = FirebaseAuth.instance.currentUser!;
await FirebaseFirestore.instance.collection('appointments').add({
'studentId': user.uid,
'counsellorId': widget.counsellorId,
'counsellorName': widget.counsellorName,
'date':
'${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}',
'time': _selectedTime,
'sessionType': _sessionType,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Appointment booked successfully!'),
backgroundColor: Color(0xFF81C784),
),
);
Navigator.pop(context);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Booking failed: $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Book Appointment')),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Book with ${widget.counsellorName}',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
Text(
'Select Date',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _selectDate,
icon: const Icon(Icons.calendar_today),
label: Text(
_selectedDate == null
? 'Choose a date'
: '${_selectedDate!.day}/${_selectedDate!.month}/${_selectedDate!.year}',
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 24),
Text(
'Select Time',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _timeSlots.map((time) {
final isSelected = _selectedTime == time;
return ChoiceChip(
label: Text(time),
selected: isSelected,
selectedColor: const Color(0xFF81C784),
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
),
onSelected: (selected) {
setState(() => _selectedTime = selected ? time : null);
},
);
}).toList(),
),
const SizedBox(height: 24),
Text(
'Session Type',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: RadioListTile<String>(
title: const Text('Physical'),
value: 'Physical',
groupValue: _sessionType,
onChanged: (value) =>
setState(() => _sessionType = value!),
activeColor: const Color(0xFF81C784),
),
),
Expanded(
child: RadioListTile<String>(
title: const Text('Online'),
value: 'Online',
groupValue: _sessionType,
onChanged: (value) =>
setState(() => _sessionType = value!),
activeColor: const Color(0xFF81C784),
),
),
],
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submitBooking,
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: const Text('Book Appointment'),
),
),
],
),
),
);
}
}
And here is the updated lib/main.dart:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'theme.dart';
import 'screens/landing_page.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
import 'screens/counsellors_screen.dart';
import 'screens/booking_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
theme: appTheme,
debugShowCheckedModeBanner: false,
initialRoute: '/',
routes: {
'/': (context) => const LandingPage(),
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
'/dashboard': (context) => const Scaffold(body: Center(child: Text('Dashboard'))),
'/counsellors': (context) => const CounsellorsScreen(),
},
);
}
}
Your booking flow is fully functional. Students can now browse counsellors, pick one, schedule an appointment, and see it confirmed in Firestore. Next up, you'll build the student dashboard to display all upcoming appointments with their statuses.
Build the Student Dashboard
Your booking flow writes appointment data to Firestore. Students now need a way to see their upcoming appointments and track whether each one has been approved or rejected.
In this step, you will build the student dashboard. This screen queries the student's appointments from Cloud Firestore in real time and displays them with color-coded status chips.
In this step, get ready to:
- Create the dashboard screen that queries appointments for the current user.
- Build an AppointmentCard widget with color-coded status indicators.
- Add a bottom navigation bar connecting Dashboard, Counsellors, and Profile tabs.
Create the appointments tab with a Firestore query
The appointments tab is the first thing a student sees after logging in. It streams their booking data from Firestore so the list updates automatically whenever a new appointment is created.
- In VS Code, create a new file called dashboard_screen.dart inside the lib/screens/ folder.
- Add the imports and the _AppointmentsTab widget by pasting this code:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'counsellors_screen.dart';
class _AppointmentsTab extends StatelessWidget {
const _AppointmentsTab();
@override
Widget build(BuildContext context) {
// Get the currently signed-in user
final user = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(title: const Text('My Appointments')),
body: StreamBuilder<QuerySnapshot>(
// Query appointments belonging to this student, newest first
stream: FirebaseFirestore.instance
.collection('appointments')
.where('studentId', isEqualTo: user?.uid)
.orderBy('createdAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
// Show empty state when no appointments exist
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.calendar_today,
size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No appointments yet',
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
const SizedBox(height: 8),
const Text('Browse counsellors to book your first session'),
],
),
);
}
final appointments = snapshot.data!.docs;
// Build a scrollable list of appointment cards
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: appointments.length,
itemBuilder: (context, index) {
final data = appointments[index].data() as Map<String, dynamic>;
return AppointmentCard(
counsellorName: data['counsellorName'] ?? 'Unknown',
date: data['date'] ?? '',
time: data['time'] ?? '',
sessionType: data['sessionType'] ?? '',
status: data['status'] ?? 'pending',
);
},
);
},
),
);
}
}
What does this code do?
The StreamBuilder listens to a Firestore query that filters appointments by the current student's UID and orders them by creation date. This means the list updates in real time whenever a new appointment is added.
The empty state gives students a clear message when they have no appointments yet, guiding them to browse counsellors.
What is a StreamBuilder?
A StreamBuilder is a Flutter widget that rebuilds itself whenever new data arrives from a stream. Firestore's .snapshots() method returns a stream that emits a new snapshot every time the underlying data changes. Together, they give you a live-updating UI with no manual refresh needed.
- Save dashboard_screen.dart.
Seeing a red underline on AppointmentCard?
This is expected. The AppointmentCard class does not exist yet. You will create it in the next substep. The error will disappear once you add the class.
help me fix the undefined class error in my dashboard_screen.dart
Build the AppointmentCard widget
Each appointment needs a visual card showing the counsellor name, date, time, and a color-coded status chip so students can quickly see which bookings are pending, approved, or rejected.
- In dashboard_screen.dart, add the AppointmentCard class below the _AppointmentsTab class by pasting this code:
class AppointmentCard extends StatelessWidget {
final String counsellorName;
final String date;
final String time;
final String sessionType;
final String status;
const AppointmentCard({
super.key,
required this.counsellorName,
required this.date,
required this.time,
required this.sessionType,
required this.status,
});
// Return a color based on the appointment status
Color _statusColor() {
switch (status) {
case 'approved':
return const Color(0xFF81C784);
case 'rejected':
return Colors.red;
default:
return Colors.amber;
}
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
counsellorName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'$date at $time',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
sessionType,
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
],
),
),
// Color-coded status chip
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _statusColor().withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
status[0].toUpperCase() + status.substring(1),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _statusColor(),
),
),
),
],
),
),
);
}
}
How does the status chip work?
The _statusColor() method returns a different color depending on the appointment's status value. Amber means the booking is still pending review. Green means approved. Red means rejected.
The chip uses .withOpacity(0.2) for its background so the color is subtle, while the text uses the full color for readability. This gives students an instant visual signal without overwhelming the card layout.
- Save dashboard_screen.dart. The red underline on AppointmentCard in your _AppointmentsTab should now be gone.
Still seeing errors in the file?
Make sure you pasted the AppointmentCard class below the closing brace of _AppointmentsTab, not inside it. Each class must be at the top level of the file.
help me fix class placement errors in my dashboard_screen.dart
Add the bottom navigation bar and profile tab
The dashboard needs a way to navigate between the appointments list, the counsellor directory, and the student's profile. A BottomNavigationBar provides this structure with three tabs.
- In dashboard_screen.dart, add the _ProfileTab class below the AppointmentCard class by pasting this code:
class _ProfileTab extends StatelessWidget {
const _ProfileTab();
@override
Widget build(BuildContext context) {
// Get the current user for displaying their email
final user = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Avatar showing the first letter of the user's email
CircleAvatar(
radius: 48,
backgroundColor: const Color(0xFF64B5F6),
child: Text(
user?.email?.isNotEmpty == true
? user!.email![0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 36,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16),
Text(
user?.email ?? 'No email',
style: const TextStyle(fontSize: 18),
),
const Spacer(),
// Sign out button at the bottom
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
await FirebaseAuth.instance.signOut();
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[400],
),
child: const Text('Sign Out'),
),
),
],
),
),
);
}
}
What does the profile tab do?
The profile tab displays the student's email and a CircleAvatar with the first letter of their email address. The sign out button calls FirebaseAuth.instance.signOut() and redirects the student back to the landing page.
- Now add the DashboardScreen class that ties everything together with a BottomNavigationBar. Paste this code between the imports and the _AppointmentsTab class:
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
// Track which tab is currently selected
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
// Map each tab index to a screen widget
final screens = [
const _AppointmentsTab(),
const CounsellorsScreen(),
const _ProfileTab(),
];
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
selectedItemColor: const Color(0xFF81C784),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.people),
label: 'Counsellors',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
How does the navigation bar work?
The _currentIndex variable tracks which tab the student has selected. When they tap a tab, setState updates the index, which swaps the displayed screen.
The screens list maps index 0 to the appointments tab, index 1 to the CounsellorsScreen you built earlier, and index 2 to the profile tab. This reuses existing screens without duplicating code.
- Save dashboard_screen.dart.
Now you need to connect the dashboard to your app's routing. The current main.dart uses a placeholder for the '/dashboard' route.
- Open lib/main.dart and find this line near the top of the imports:
import 'screens/booking_screen.dart';
- Add this import directly above it:
import 'screens/dashboard_screen.dart';
- Now find the placeholder dashboard route inside the routes map:
'/dashboard': (context) => const Scaffold(body: Center(child: Text('Dashboard'))),
- Replace it with the real DashboardScreen so it looks like this:
'/dashboard': (context) => const DashboardScreen(),
- Save main.dart.
- Run your app in Chrome. Register or sign in, then book an appointment with a counsellor from the listing.
- After booking, you should land on the dashboard and see your appointment card with the counsellor name, date/time, session type, and an amber "Pending" status chip.
- Tap the Counsellors tab at the bottom to verify it shows the counsellor listing.
- Tap the Profile tab to see your email and the sign out button.
What about the composite index?
The appointments query uses both .where() and .orderBy() on different fields. Firestore requires a composite index for this combination.
When you first load the dashboard, your browser's debug console may show an error with a direct link to create the required index. Click that link, confirm in the Firebase Console, and wait a minute or two for it to build. Once the index is ready, refresh and your appointments will appear.
Dashboard shows 'No appointments yet' even after booking?
Check your browser's developer console for a Firestore index error. If you see one, click the link in the error message to create the composite index in the Firebase Console. The index takes a minute or two to build.
If there is no index error, make sure you are signed in with the same account you used to book the appointment. The query filters by studentId, so a different account will show an empty list.
help me debug why my appointments are not showing on the dashboard
✔️ Awesome, I've got everything!
Great. Double check you have saved both dashboard_screen.dart and main.dart.
ⓧ I'd like to double check the full code
Here is the complete dashboard_screen.dart file:
import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'counsellors_screen.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final screens = [
const _AppointmentsTab(),
const CounsellorsScreen(),
const _ProfileTab(),
];
return Scaffold(
body: screens[_currentIndex],
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex,
onTap: (index) => setState(() => _currentIndex = index),
selectedItemColor: const Color(0xFF81C784),
items: const [
BottomNavigationBarItem(
icon: Icon(Icons.dashboard),
label: 'Dashboard',
),
BottomNavigationBarItem(
icon: Icon(Icons.people),
label: 'Counsellors',
),
BottomNavigationBarItem(
icon: Icon(Icons.person),
label: 'Profile',
),
],
),
);
}
}
class _AppointmentsTab extends StatelessWidget {
const _AppointmentsTab();
@override
Widget build(BuildContext context) {
final user = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(title: const Text('My Appointments')),
body: StreamBuilder<QuerySnapshot>(
stream: FirebaseFirestore.instance
.collection('appointments')
.where('studentId', isEqualTo: user?.uid)
.orderBy('createdAt', descending: true)
.snapshots(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.calendar_today,
size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
'No appointments yet',
style: TextStyle(fontSize: 18, color: Colors.grey[600]),
),
const SizedBox(height: 8),
const Text('Browse counsellors to book your first session'),
],
),
);
}
final appointments = snapshot.data!.docs;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: appointments.length,
itemBuilder: (context, index) {
final data = appointments[index].data() as Map<String, dynamic>;
return AppointmentCard(
counsellorName: data['counsellorName'] ?? 'Unknown',
date: data['date'] ?? '',
time: data['time'] ?? '',
sessionType: data['sessionType'] ?? '',
status: data['status'] ?? 'pending',
);
},
);
},
),
);
}
}
class AppointmentCard extends StatelessWidget {
final String counsellorName;
final String date;
final String time;
final String sessionType;
final String status;
const AppointmentCard({
super.key,
required this.counsellorName,
required this.date,
required this.time,
required this.sessionType,
required this.status,
});
Color _statusColor() {
switch (status) {
case 'approved':
return const Color(0xFF81C784);
case 'rejected':
return Colors.red;
default:
return Colors.amber;
}
}
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
counsellorName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'$date at $time',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
sessionType,
style: TextStyle(fontSize: 13, color: Colors.grey[500]),
),
],
),
),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: _statusColor().withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Text(
status[0].toUpperCase() + status.substring(1),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _statusColor(),
),
),
),
],
),
),
);
}
}
class _ProfileTab extends StatelessWidget {
const _ProfileTab();
@override
Widget build(BuildContext context) {
final user = FirebaseAuth.instance.currentUser;
return Scaffold(
appBar: AppBar(title: const Text('Profile')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
CircleAvatar(
radius: 48,
backgroundColor: const Color(0xFF64B5F6),
child: Text(
user?.email?.isNotEmpty == true
? user!.email![0].toUpperCase()
: '?',
style: const TextStyle(
fontSize: 36,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 16),
Text(
user?.email ?? 'No email',
style: const TextStyle(fontSize: 18),
),
const Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
await FirebaseAuth.instance.signOut();
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red[400],
),
child: const Text('Sign Out'),
),
),
],
),
),
);
}
}
And here is the updated main.dart:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
import 'theme.dart';
import 'screens/landing_page.dart';
import 'screens/login_screen.dart';
import 'screens/register_screen.dart';
import 'screens/dashboard_screen.dart';
import 'screens/counsellors_screen.dart';
import 'screens/booking_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const CounsellingApp());
}
class CounsellingApp extends StatelessWidget {
const CounsellingApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Guidance & Counselling',
theme: appTheme,
debugShowCheckedModeBanner: false,
initialRoute: '/',
routes: {
'/': (context) => const LandingPage(),
'/login': (context) => const LoginScreen(),
'/register': (context) => const RegisterScreen(),
'/dashboard': (context) => const DashboardScreen(),
'/counsellors': (context) => const CounsellorsScreen(),
},
);
}
}
Your student dashboard is complete. Students can now see all their appointments, browse counsellors, and manage their profile from a single screen. Next up, you will tackle the Secret Mission and add real-time status updates so students see approvals instantly.
Secret mission
Add Real-Time Appointment Status Updates
Your dashboard already reads from Firestore in real time. But what if a counsellor approves your appointment while you're staring at the screen? In this secret mission, you'll add visual polish that makes status changes impossible to miss. An animated transition on the status chip and a SnackBar notification that pops up the moment something changes.
Clean Up Your Resources
Clean Up Your Resources
This project uses Firebase's free Spark plan, so there are no ongoing costs as long as you stay within free tier limits.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Firebase project (Authentication and Cloud Firestore).
- Local counselling_app project folder on your Desktop.
- Firebase CLI (installed globally via npm).
- FlutterFire CLI (installed globally via Dart).
What about Firebase free tier limits?
Firebase Authentication is free for up to 3,000 daily active users. Cloud Firestore is free for up to 50,000 reads and 20,000 writes per day, with 1 GiB storage.
For a development project like this, you will never come close to hitting these limits.
Keep everything running
No action needed. Choose this if you plan to keep building on this app or want to revisit it soon.
- Your Firebase project stays active at no cost within Spark plan limits.
- Your local project files remain on your Desktop.
- The Firebase CLI and FlutterFire CLI remain installed on your machine.
Pause - I'll come back to this later
Firebase's free tier has no idle charges, so pausing requires no action on the cloud side. You can free up local resources if needed.
- Your Firebase project will stay active at no cost. There is nothing to pause or shut down.
- Your local files stay where they are until you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Delete your Firebase project by opening the Firebase Console.
- Click the gear icon and select Project Settings.
- Scroll to the bottom of the General tab and click Delete Project.
- Select all checkboxes in the confirmation dialog, then click Delete Project.
Delete your local project files by running these commands in your terminal:
macOS
cd ~/Desktop
rm -rf counselling_app
Windows
cd ~\Desktop
rmdir /s /q counselling_app
Uninstall the Firebase CLI by running:
npm uninstall -g firebase-tools
Uninstall the FlutterFire CLI by running:
dart pub global deactivate flutterfire_cli
Nice Work!
Nice Work!
You just built a full university guidance and counselling app from scratch using Flutter and Firebase.
You've learned how to:
- Build a multi-screen Flutter app with a calming, university-themed UI featuring rounded cards, soft color schemes, and clean navigation.
- Implement secure student authentication with Firebase Authentication and store user profile data in Cloud Firestore.
- Create a complete appointment booking flow with real-time counsellor listings, date/time selection, session type toggling, and a student dashboard with color-coded status indicators.
- Secret Mission: Added real-time appointment status updates so students see approvals and rejections instantly without refreshing.
Ready to quiz yourself?