Build a Personal Calendar App
Build a Flutter web calendar app with Firebase Auth and Firestore persistence.
Introduction
30 Second Summary
Every app you use daily relies on a calendar somewhere. Whether it's booking a meeting, tracking deadlines, or planning your week, calendars are one of the most common features in modern software.
In this project, you will build a personal calendar web app using Flutter and Firebase. You'll create a full monthly calendar grid with event creation, user authentication, and cloud-based data persistence.
What You'll Build
When you open your finished app, you'll sign in with your email, tap any day on the calendar, and add events that survive page refreshes because they're stored in the cloud.
By the end of this project, you'll have:
- A monthly calendar grid where you can tap any day, create events, and see indicator dots appear on days that have entries.
- A sign-up and login flow powered by Firebase Authentication, so only you can access your events.
- Cloud persistence through Cloud Firestore, meaning your events survive page refreshes, browser restarts, and even device changes.
- Secret Mission: Deploy your app to Firebase Hosting as a live PWA you can open on any device and add to your home screen.
Are there any prerequisites?
You should have basic familiarity with HTML, CSS, and JavaScript concepts. No prior Flutter or Dart experience is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
This step is your moment to commit to the goal before any hands-on work begins.
Set Up Your Flutter Development Environment
Flutter, Firebase CLI, and Node.js are the three tools that power this project. Flutter builds your web UI, the Firebase CLI connects your app to cloud services, and Node.js runs the Firebase CLI under the hood.
All three tools are installed on your machine. Before you start building, you need to verify they work together and that Chrome is ready for Flutter web development.
In this step, get ready to:
- Run Flutter's built-in diagnostics to verify your full toolchain.
- Confirm Chrome is detected and ready for Flutter web development.
Verify your environment with flutter doctor
Flutter includes a diagnostic tool called flutter doctor that checks every part of your development setup in one go. It reports whether your SDK, Chrome, and connected tooling are all working correctly.
Before you run this, what do you think flutter doctor will report about Chrome on your machine?
- Run the Flutter diagnostics command in the VS Code terminal:
flutter doctor
What does flutter doctor check?
This command scans your system and reports the status of each development target. Each row represents a toolchain validator.
- The Flutter row confirms the SDK itself is installed and on your PATH.
- The Chrome row confirms Google Chrome is detected and available for web development.
- A green checkmark next to a row means that validator passed all checks.
- Look at the Chrome row in the output. It should display a green checkmark, confirming Chrome is ready.
Why does Chrome matter for Flutter?
Flutter uses Chrome as its development browser for web apps. When you run your app later with flutter run -d chrome, Flutter launches Chrome and hot-reloads your code changes into it.
Without Chrome detected, Flutter cannot serve your web app for testing.
Chrome row not showing a green checkmark?
- Make sure Google Chrome is installed (not Chromium or Edge). Flutter specifically looks for Chrome.
- If Chrome is installed but not detected, try closing and reopening your terminal. The PATH may not have refreshed.
- On Windows, ensure Chrome is installed at its default location. Flutter checks the standard install path.
Help me troubleshoot why flutter doctor does not detect Chrome on Windows.
Your development environment is verified and ready. Next up, you will create your Flutter project and see it running live in Chrome.
Create Your Flutter Project and See It Run
Your development tools are locked in. Flutter, the Firebase CLI, and Chrome are all confirmed working from the previous step.
Now you need a running Flutter project in your browser before you can start building the calendar UI. Seeing the default app proves your toolchain works end-to-end.
In this step, get ready to:
- Create a new Flutter web project on your Desktop.
- Run the default app in Chrome to confirm everything works.
Create the Flutter project
Flutter needs a project folder with the right structure before you can write any code. The flutter create command scaffolds everything for you.
- Move to your Desktop by running this command in your VS Code terminal:
cd ~/Desktop
- Create the Flutter project by running this command:
flutter create --platforms=web shared_calendar
What does this command do?
- flutter create scaffolds a new Flutter project with all the boilerplate files and dependencies.
- --platforms=web tells Flutter to only generate web platform files, keeping the project lightweight.
- shared_calendar is the project name. Flutter creates a folder with this name containing your app.
You should see Flutter print a list of created files ending with a message that says your application code is in lib/main.dart.
- Move into your new project folder and open it in VS Code by running these commands:
cd shared_calendar
code .
VS Code opens with your new project folder. You should see a file tree in the sidebar with folders like lib/, web/, and test/.
Don't see the project in VS Code?
- If code . gives a "command not found" error, open VS Code manually, click File > Open Folder, and navigate to Desktop/shared_calendar.
- Make sure you ran cd shared_calendar before code .. If you skipped it, the wrong folder opens.
help me open my Flutter project in VS Code
Run the app in Chrome
Flutter projects come with a working demo app out of the box. Running it proves your full development toolchain works from Dart code all the way to a live browser tab.
Before you run the next command, what do you think will appear in Chrome?
- Start the app in Chrome by running this command in your VS Code terminal:
flutter run -d chrome
What happens when you run this?
Flutter compiles your Dart code to JavaScript, starts a local development server, and opens Chrome with your app. This command takes over the terminal until you stop it.
The first build takes 30-60 seconds while Flutter downloads web dependencies. Subsequent builds are much faster.
Chrome opens with the Flutter demo counter app. You see an app bar titled "Flutter Demo Home Page" and a counter displaying "0".
- Click the floating + button in the bottom-right corner a few times.
The counter increments with each tap. This confirms your Flutter app is interactive and running correctly in the browser.
- Return to your VS Code terminal and press q to stop the app.
The Chrome tab goes blank or closes. Your terminal prompt returns, ready for new commands.
Chrome didn't open?
- Make sure Chrome is installed and that flutter doctor shows Chrome with a green checkmark. If Chrome is missing from the doctor output, close and reopen your terminal, then try again.
- If you see "No supported devices found", confirm you ran flutter create --platforms=web (not just flutter create without the web flag).
- If the build hangs for more than 2 minutes, press Ctrl+C to cancel and run flutter run -d chrome again.
help me get flutter run -d chrome working
Your Flutter project is alive and running in Chrome. Next up, you'll tear out the counter demo and build the calendar UI with local events.
Build the Calendar UI with Local Events
Your Flutter project runs, but right now it is just a counter app. The real goal is a personal calendar where you can tap a day and create events.
In this step you will replace the counter with a full monthly calendar grid and local event storage. By the end, you will discover a critical limitation: local state vanishes the moment you refresh the browser.
In this step, get ready to:
- Add the table_calendar package to your Flutter project.
- Build an interactive calendar screen with event creation.
- Hit the wall of local-only state when events disappear on refresh.
Add the table_calendar package
Building a calendar grid from scratch means weeks of date math and gesture handling. The table_calendar package handles all of that so you can focus on your app logic.
- Install the calendar package by running this command in your VS Code terminal:
flutter pub add table_calendar
You should see output confirming the package was resolved and added to your pubspec.yaml.
Seeing a version solving error?
Make sure your Flutter SDK is up to date. The table_calendar package requires Dart SDK 3.0 or higher.
Run flutter --version to check. If your SDK is outdated, update with flutter upgrade and try again.
Help me fix a version solving error with table_calendar
Replace main.dart with the calendar app
You are going to replace the entire default counter app with your calendar. The new code goes into lib/main.dart in sections so you can understand each piece.
- Open lib/main.dart in VS Code.
- Select all the existing code and delete it.
- Paste the following app shell at the top of the now-empty file:
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
void main() {
runApp(const MyApp());
}
// App root widget that sets up theme and home screen
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Shared Calendar',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const HomeScreen(),
);
}
}
What does this code do?
This is the entry point of your app. It imports Flutter's material library and the table_calendar package you just installed.
The MyApp widget creates a MaterialApp with a blue theme and points to a HomeScreen widget that you will define next.
- Directly below the MyApp class, paste the HomeScreen widget and its state variables:
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
// Calendar display format (month, two weeks, or week)
CalendarFormat _calendarFormat = CalendarFormat.month;
DateTime _focusedDay = DateTime.now();
DateTime? _selectedDay;
// Local storage for events keyed by normalized date
Map<DateTime, List<String>> _events = {};
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
}
// Strips time so dates match consistently as map keys
DateTime _normalizeDate(DateTime date) {
return DateTime.utc(date.year, date.month, date.day);
}
// Returns event titles for a given day
List<String> _getEventsForDay(DateTime day) {
return _events[_normalizeDate(day)] ?? [];
}
What does this code do?
- HomeScreen is a StatefulWidget because the calendar selection and events change over time.
- _events is a Map that stores event title strings keyed by date. This is local memory only.
- _normalizeDate strips the time component from dates so that different timestamps on the same calendar day map to the same key.
- Below the _getEventsForDay method, paste the event creation dialog:
// Shows a dialog for the user to type an event title
Future<void> _addEvent() async {
final titleController = TextEditingController();
final title = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('New Event'),
content: TextField(
controller: titleController,
decoration: const InputDecoration(
hintText: 'Event title',
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, titleController.text),
child: const Text('Add'),
),
],
),
);
- Immediately below that closing );, paste the event storage logic that completes the _addEvent method:
// Store event in local map if title is not empty
if (title != null && title.isNotEmpty) {
setState(() {
final key = _normalizeDate(_selectedDay!);
if (_events[key] == null) _events[key] = [];
_events[key]!.add(title);
});
}
}
How does event creation work?
When the user taps the floating action button, _addEvent opens an AlertDialog with a text field. If they type a title and tap Add, the title string is returned from the dialog.
The setState call adds the title to the _events map under the selected day's normalized date. This triggers a rebuild so the calendar shows the new event dot.
- Now paste the build method that renders the calendar widget. Place it directly below the _addEvent method's closing brace:
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('My Calendar')),
body: Column(
children: [
// Interactive monthly calendar grid
TableCalendar(
firstDay: DateTime.utc(2020, 1, 1),
lastDay: DateTime.utc(2030, 12, 31),
focusedDay: _focusedDay,
calendarFormat: _calendarFormat,
selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
eventLoader: _getEventsForDay,
onDaySelected: (selectedDay, focusedDay) {
setState(() {
_selectedDay = selectedDay;
_focusedDay = focusedDay;
});
},
onFormatChanged: (format) {
setState(() => _calendarFormat = format);
},
onPageChanged: (focusedDay) {
_focusedDay = focusedDay;
},
),
- Finally, paste the event list and floating action button to close the build method and the class:
const SizedBox(height: 8),
// Daily event list below the calendar
Expanded(
child: _getEventsForDay(_selectedDay!).isEmpty
? const Center(child: Text('No events for this day'))
: ListView.builder(
itemCount: _getEventsForDay(_selectedDay!).length,
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.event),
title: Text(_getEventsForDay(_selectedDay!)[index]),
);
},
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _addEvent,
child: const Icon(Icons.add),
),
);
}
}
What does the build method do?
- TableCalendar renders the month grid. The eventLoader parameter tells it which days have events so it can draw indicator dots.
- onDaySelected updates _selectedDay when the user taps a date, which also refreshes the event list below.
- The Expanded widget below the calendar shows either a "No events" message or a scrollable list of event titles for the selected day.
- The FloatingActionButton calls _addEvent to open the event creation dialog.
- Save lib/main.dart.
- Run the app in Chrome by entering this command in your terminal:
flutter run -d chrome
Chrome opens with a full monthly calendar grid, today's date highlighted, and a blue + button in the bottom right corner.
Seeing compile errors?
Double-check that you deleted ALL the old counter app code before pasting. Leftover class definitions conflict with the new HomeScreen widget.
Also confirm each code section was pasted in order, directly below the previous one. A missing closing brace or a section pasted out of sequence causes syntax errors.
Help me fix compile errors in my calendar main.dart
✔️ Awesome, I've got everything!
Great. Make sure you have saved lib/main.dart and confirmed the calendar renders in Chrome.
ⓧ I'd like to double check the full code
Scroll up and confirm you have each section in this order inside lib/main.dart:
- Imports and main() function with MyApp class.
- HomeScreen StatefulWidget and _HomeScreenState with state variables, initState, _normalizeDate, and _getEventsForDay.
- The _addEvent method with the AlertDialog and the setState storage block.
- The build method with TableCalendar, the event list, and the FloatingActionButton.
- A final closing brace } for the _HomeScreenState class.
Test the limitation of local state
Your calendar looks great and event creation works. Now you are going to discover the fundamental problem with storing data only in widget memory.
- In Chrome, tap any day on the calendar to select it.
- Tap the blue + button in the bottom right corner.
- Type Team standup in the event title field and tap Add.
- Tap a different day and add another event called Lunch with Alex.
You should see small indicator dots appear under each day that has an event. Tapping a day shows its events listed below the calendar.
Before you do the next step, predict what will happen if you refresh the browser tab. Will the events still be there?
- Press Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS) to hard-refresh the browser tab.
Both events are gone. The calendar is empty again. The dots disappeared and the event list is back to "No events for this day".
Why did the events vanish?
The _events map lives entirely in the widget's Dart memory. When you refresh the browser, Flutter tears down the widget tree and rebuilds it from scratch. The map starts empty again.
This is the core limitation of local state. For a calendar app to be useful, events need to survive page refreshes, browser restarts, and even device changes. That is exactly the problem Cloud Firestore will solve in a later step.
Your calendar UI is fully functional for a single session. Next up, you will add Firebase Authentication so the app knows who is using it before you wire up cloud storage to make events permanent.
Add Firebase Authentication
Your calendar works, but every event vanishes on refresh because it only lives in local memory. Before you can save events to a cloud database, you need to know who the user is.
Firebase Authentication gives each user a unique ID. You will use that ID in the next step to store their personal events separately from other users.
In this step, get ready to:
- Create a Firebase project and enable Authentication and Firestore.
- Connect your Flutter project to Firebase using the FlutterFire CLI.
- Add a login screen with email/password sign-up and an auth gate that protects the calendar.
Create a Firebase project and enable services
Firebase is a platform of cloud services. You need a Firebase project to hold the Authentication and Firestore services your app will use.
- Open your browser and go to console.firebase.google.com.
- Sign in with your Google account.
- Click Create a new Firebase project.
- Enter a project name (for example, shared-calendar).
- When asked about Google Analytics, toggle it off to keep setup simple.
- Click Create project.
- Wait for Firebase to finish provisioning, then click Continue.
What is Firebase?
Firebase is a set of cloud services from Google. Authentication handles sign-up and login. Cloud Firestore stores data in the cloud.
The Spark plan is completely free for personal projects. No credit card required.
Now enable the two services your app needs.
- In the left sidebar, click Authentication.
- Click Get started.
- Under Sign-in method, click Email/Password.
- Toggle the first switch to Enable, then click Save.
You should see Email/Password listed as an enabled provider.
- In the left sidebar, expand Databases & Storage and click Firestore.
- Click Create database.
- Select a location closest to you (any location works for this project).
- Select Start in test mode, then click Create.
You should now see an empty Firestore database in the console.
Why test mode?
Test mode allows any read or write for 30 days without security rules. This lets you build quickly without configuring rules yet. You would add proper security rules before launching to real users.
Configure Firebase in your Flutter project
The FlutterFire CLI links your Flutter project to the Firebase project you just created. It generates a config file so your app knows which Firebase project to connect to.
- Switch back to VS Code and open the terminal.
- Run the FlutterFire configuration command:
flutterfire configure
What will this command ask?
The CLI will prompt you to select your Firebase project from a list and then choose which platforms to configure. Use your arrow keys to select the project you just created.
When asked about platforms, select Web only (use Space to toggle, Enter to confirm). This generates a lib/firebase_options.dart file in your project.
- Confirm the file was created by checking for lib/firebase_options.dart in the VS Code Explorer sidebar.
Now add the Firebase packages your app needs.
- Install the three Firebase packages by running:
flutter pub add firebase_core firebase_auth cloud_firestore
What do these packages do?
- firebase_core initializes the connection to your Firebase project.
- firebase_auth handles user sign-up, login, and logout.
- cloud_firestore reads and writes data to your Firestore database (you will use this in the next step).
Seeing a dependency error?
If you see version conflicts, make sure you ran flutterfire configure first. The config step updates pubspec.yaml dependencies that the Firebase packages rely on.
help me fix Flutter Firebase dependency conflicts
Add the AuthGate and LoginScreen
Now you will restructure the app so it shows a login screen to unauthenticated users and the calendar to authenticated users. This involves splitting code into separate files and adding Firebase initialization.
Start by extracting the HomeScreen into its own file and adding a logout button.
- In VS Code, right-click the lib folder in the Explorer sidebar and select New File.
- Name it home_screen.dart.
- In lib/main.dart, select the entire HomeScreen class and its _HomeScreenState class (from class HomeScreen extends StatefulWidget down to the final closing brace of the file).
- Cut this code (Ctrl+X) and paste it into lib/home_screen.dart.
- Add the following imports at the very top of lib/home_screen.dart:
import 'package:flutter/material.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:table_calendar/table_calendar.dart';
Why these imports?
The HomeScreen uses Material widgets, the table_calendar package, and will now use FirebaseAuth for the logout button.
Now add the logout button to the app bar.
- In lib/home_screen.dart, find this line inside the build method:
appBar: AppBar(title: const Text('My Calendar')),
- Replace it with the following, which adds an actions list containing a logout button:
appBar: AppBar(
title: const Text('My Calendar'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => FirebaseAuth.instance.signOut(),
),
],
),
What does this code do?
The IconButton shows a logout icon in the top-right of the app bar. When tapped, it calls FirebaseAuth.instance.signOut() which signs the user out and triggers the auth gate to show the login screen.
- Save lib/home_screen.dart.
Next, create the login screen.
- Right-click the lib folder and create a new file called login_screen.dart.
- Add the class structure with the authentication logic:
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 _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLogin = true;
String? _error;
Future<void> _submit() async {
setState(() => _error = null);
try {
if (_isLogin) {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
);
} else {
await FirebaseAuth.instance.createUserWithEmailAndPassword(
email: _emailController.text.trim(),
password: _passwordController.text.trim(),
);
}
} on FirebaseAuthException catch (e) {
setState(() => _error = e.message);
}
}
What does this code do?
- _emailController and _passwordController capture what the user types in the form fields.
- _isLogin toggles between sign-in and sign-up mode.
- _submit() calls the appropriate Firebase Auth method depending on the mode. Errors are caught and stored in _error to display to the user.
- Now add the build method that creates the login form UI. Paste this directly below the _submit method (still inside the _LoginScreenState class):
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_isLogin ? 'Sign In' : 'Create Account',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 24),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
obscureText: true,
),
- Continue pasting the rest of the build method directly below (the error display, buttons, and closing brackets):
if (_error != null) ...[
const SizedBox(height: 12),
Text(_error!, style: const TextStyle(color: Colors.red)),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _submit,
child: Text(_isLogin ? 'Sign In' : 'Sign Up'),
),
TextButton(
onPressed: () => setState(() => _isLogin = !_isLogin),
child: Text(
_isLogin
? 'Need an account? Sign up'
: 'Have an account? Sign in',
),
),
],
),
),
),
);
}
}
What does the login form do?
The form shows email and password fields. When _isLogin is true, the button says "Sign In" and calls signInWithEmailAndPassword. The TextButton at the bottom lets users toggle to sign-up mode.
If authentication fails, the error message appears in red text between the password field and the button.
- Save lib/login_screen.dart.
Finally, rewrite lib/main.dart to initialize Firebase and add the auth gate.
- Open lib/main.dart. Select all the remaining code and replace it with the following imports and main() function plus the MyApp widget:
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'firebase_options.dart';
import 'login_screen.dart';
import 'home_screen.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Shared Calendar',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const AuthGate(),
);
}
}
What does this code do?
- WidgetsFlutterBinding.ensureInitialized() is required before calling any asynchronous code in main().
- Firebase.initializeApp() connects to your Firebase project using the config generated by flutterfire configure.
- MyApp now uses AuthGate() as the home widget instead of HomeScreen() directly.
- Now add the AuthGate widget below the MyApp class in lib/main.dart:
class AuthGate extends StatelessWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: FirebaseAuth.instance.authStateChanges(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasData) {
return const HomeScreen();
}
return const LoginScreen();
},
);
}
}
What is the AuthGate?
AuthGate listens to Firebase's authStateChanges() stream. This stream emits the current user whenever the auth state changes (login, logout, or app start).
If a user is signed in (snapshot.hasData), the app shows HomeScreen. Otherwise, it shows LoginScreen. No manual navigation needed.
- Save lib/main.dart.
Before you run the app, what do you think you will see when it opens in Chrome?
- Run the app by executing:
flutter run -d chrome
You should see a login screen with email and password fields and a Sign In button.
- Click Need an account? Sign up at the bottom to switch to sign-up mode.
- Enter a test email (for example, test@example.com) and a password (at least 6 characters).
- Click Sign Up.
After signing up, you land on the calendar screen. The auth gate detected the new user and showed the HomeScreen.
- Click the logout icon (arrow icon) in the top-right corner of the app bar.
You return to the login screen. Authentication is working.
App crashes or shows a red screen?
Make sure lib/firebase_options.dart exists. If you skipped flutterfire configure, the import will fail.
Also check that you removed the old import 'package:table_calendar/table_calendar.dart'; from main.dart since the calendar now lives in home_screen.dart.
help me debug my Flutter Firebase initialization error
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved: lib/main.dart, lib/home_screen.dart, and lib/login_screen.dart.
ⓧ I'd like to double check the full code
Your project should now have three Dart files you wrote plus one generated file. Confirm each file has the following structure:
- lib/main.dart: 6 imports at the top, an async main() function that initializes Firebase, a MyApp widget with home: const AuthGate(), and the AuthGate class using StreamBuilder.
- lib/home_screen.dart: imports for material, firebase_auth, and table_calendar. The full HomeScreen widget with calendar, event list, FAB, and an IconButton logout in the app bar actions.
- lib/login_screen.dart: imports for material and firebase_auth. A StatefulWidget with email/password controllers, _isLogin toggle, _submit() method, and a build method showing the form.
- lib/firebase_options.dart: auto-generated by flutterfire configure. Do not edit this file.
Your app now has a real login system. Users can create accounts, sign in, and sign out. Next up, you will connect Firestore so events survive page refreshes.
Persist Events with Cloud Firestore
In the last step, you proved that authentication works. You can sign up, log in, and log out. But you also saw the core problem: every event you add disappears the moment you refresh the browser.
That is because events only exist in the widget's local memory. Cloud Firestore fixes this by storing your events in a cloud database that survives page refreshes, browser restarts, and even device changes.
In this step, get ready to:
- Write new events to Firestore instead of a local variable.
- Read events from Firestore when the calendar loads.
- Verify that events persist across browser refreshes.
Update imports and prepare the data model
Your current home_screen.dart stores events as simple strings in a local Map. To work with Firestore documents, you need to change the data type to a Map that holds document fields like title and date.
- In VS Code, open lib/home_screen.dart.
- Find the import section at the top of the file. Add the cloud_firestore import between the existing firebase_auth and table_calendar imports:
import 'package:cloud_firestore/cloud_firestore.dart';
- Next, find this line inside the _HomeScreenState class:
Map<DateTime, List<String>> _events = {};
- Replace it with this updated type that stores document data instead of plain strings:
Map<DateTime, List<Map<String, dynamic>>> _events = {};
- Now find the _getEventsForDay method:
List<String> _getEventsForDay(DateTime day) {
return _events[_normalizeDate(day)] ?? [];
}
- Replace it with the updated return type to match the new Map structure:
List<Map<String, dynamic>> _getEventsForDay(DateTime day) {
return _events[_normalizeDate(day)] ?? [];
}
Why change the data type?
Each Firestore document is a map of fields. By storing Map<String, dynamic> instead of plain String, each event can carry its title, date, document ID, and any other fields you add later.
Write events to Firestore
Instead of adding event titles to a local list, you will write a document to the users/{uid}/events collection in Firestore.
- In home_screen.dart, scroll down to the _addEvent method.
- Find the if (title != null && title.isNotEmpty) block at the bottom of the method. It currently looks like this:
if (title != null && title.isNotEmpty) {
setState(() {
final key = _normalizeDate(_selectedDay!);
if (_events[key] == null) _events[key] = [];
_events[key]!.add(title);
});
}
- Replace that entire if block with this code that writes to Firestore instead:
if (title != null && title.isNotEmpty) {
// Get the current user's ID for their personal collection
final uid = FirebaseAuth.instance.currentUser!.uid;
// Write the event document to Firestore
await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('events')
.add({
'title': title,
'date': Timestamp.fromDate(_selectedDay!),
'created_at': FieldValue.serverTimestamp(),
});
// Refresh the local events from the database
_loadEvents();
}
What does this code do?
Instead of pushing a string into a local list, you now write a full document to users/{uid}/events in Firestore.
- Each document stores three fields: title (the event name), date (a Firestore Timestamp for the selected day), and created_at (a server-generated timestamp).
- After the write succeeds, _loadEvents() re-fetches all events from the database so the calendar updates immediately.
Read events from Firestore
The write logic calls _loadEvents(), but that method does not exist yet. You will create it now to query Firestore and populate the events map.
- In home_screen.dart, find the _normalizeDate method. Add the following _loadEvents method directly below it:
Future<void> _loadEvents() async {
// Get the logged-in user's ID
final uid = FirebaseAuth.instance.currentUser!.uid;
// Query all event documents for this user
final snapshot = await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('events')
.get();
// Build a local map from the Firestore results
final Map<DateTime, List<Map<String, dynamic>>> loadedEvents = {};
for (final doc in snapshot.docs) {
final data = doc.data();
final date = _normalizeDate((data['date'] as Timestamp).toDate());
if (loadedEvents[date] == null) {
loadedEvents[date] = [];
}
loadedEvents[date]!.add({'id': doc.id, ...data});
}
setState(() => _events = loadedEvents);
}
What does this code do?
- It queries the users/{uid}/events collection and gets all documents for the current user.
- For each document, it converts the Firestore Timestamp back to a normalized UTC date so it lines up with the calendar's date keys.
- The spread operator ...data merges all document fields with the document ID into each event map. This gives you the title field you need to display events.
- Now find the initState method. It currently looks like this:
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
}
- Add a call to _loadEvents() at the end of initState so events load when the screen appears:
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
_loadEvents();
}
- Finally, scroll down to the ListView.builder inside the build method. Find the itemBuilder callback. It currently reads the event as a plain string:
itemBuilder: (context, index) {
return ListTile(
leading: const Icon(Icons.event),
title: Text(_getEventsForDay(_selectedDay!)[index]),
);
},
- Replace it with this version that extracts the title field from the event map:
itemBuilder: (context, index) {
final event = _getEventsForDay(_selectedDay!)[index];
return ListTile(
leading: const Icon(Icons.event),
title: Text(event['title']),
);
},
- Save home_screen.dart.
Why extract event['title']?
Events are now maps with multiple fields (title, date, created_at). The ListTile needs just the title string for display, so you access it with event['title'].
✔️ Awesome, I've got everything!
Great. Make sure you have saved home_screen.dart before moving on.
ⓧ I'd like to double check the full code
Your home_screen.dart is over 30 lines, so confirm each section is in place by scrolling through the file:
- Imports section: includes cloud_firestore between firebase_auth and table_calendar.
- State declarations: _events is typed as Map<DateTime, List<Map<String, dynamic>>>.
- The initState method calls _loadEvents() after setting _selectedDay.
- The _loadEvents method sits below _normalizeDate and queries users/{uid}/events.
- The _getEventsForDay method returns List<Map<String, dynamic>>.
- The _addEvent method writes to Firestore and calls _loadEvents() at the end.
- The ListView.builder uses final event = _getEventsForDay(_selectedDay!)[index] and displays event['title'].
Seeing red underlines in VS Code?
- If you see a type error on _events or _getEventsForDay, make sure both the variable declaration and the method return type use Map<String, dynamic> instead of String.
- If FirebaseFirestore is unresolved, check that import 'package:cloud_firestore/cloud_firestore.dart'; is at the top of the file.
- If Timestamp is unresolved, it comes from the same cloud_firestore import. Confirm it is present.
Why am I seeing type errors after updating home_screen.dart?
Verify persistence across refreshes
All the pieces are in place. Time to prove that your events survive a page refresh.
- Run your app in Chrome by running this command in the VS Code terminal:
flutter run -d chrome
Before you interact with the app, ask yourself: if you add an event and then refresh the page, do you expect the event to still be there?
- Log in with the account you created earlier.
- Tap today's date on the calendar.
- Press the + floating action button.
- Type a title like Team standup and press Add.
- Confirm a dot appears on today's date and the event title shows in the list below.
- Add a second event on a different day to confirm multiple events work.
Now the real test. Refresh the browser tab (Ctrl+R on Windows or Cmd+R on macOS).
Your events are still there. The dots remain on the calendar and tapping a day still shows your event titles in the list below.
Why do events survive now?
When the page refreshes, Flutter rebuilds the entire widget tree from scratch. The HomeScreen calls _loadEvents() in initState, which fetches your documents from Firestore. The data lives in the cloud, not in browser memory.
- Open the Firebase Console in a new browser tab at console.firebase.google.com.
- Select your project.
- Click Firestore Database in the left sidebar.
- Expand the users collection, then your user document, then the events subcollection.
- You should see your event documents with title, date, and created_at fields.
Events disappear on refresh?
- Check the Firebase Console. If no documents appear in the events subcollection, the write failed. Verify that your _addEvent method uses await before the Firestore .add() call.
- If documents exist in the console but do not show in the app, confirm that _loadEvents() is called inside initState.
- If you see a permission error in the browser console, verify that your Firestore database is still in test mode (it expires after 30 days from creation).
My events still disappear after refreshing. Help me debug my Firestore read and write logic.
Your calendar now has real persistence. Events live in the cloud and reload every time you open the app. Next up, you will deploy this app as a live Progressive Web App that you can access from any device.
Secret mission
Deploy as a Live PWA on Firebase Hosting
Your calendar app works locally, but only you can see it. In this secret mission, you will deploy it to Firebase Hosting as a live Progressive Web App. Anyone with the link can use it. You can even install it on your phone like a native app.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Firebase Spark plan, which is free indefinitely with no charges even when idle.
Resources you used:
- Firebase project (Firestore database, Authentication, Hosting site).
- Local shared_calendar project folder on your Desktop.
- Flutter SDK installed at C:\dev\flutter.
- Firebase CLI (global npm package).
- FlutterFire CLI (global Dart package).
Keep everything running
No action needed. Choose this if you plan to keep building on this calendar app or want it to stay live.
- The Firebase Spark plan is free indefinitely. Your Firestore database, Authentication, and Hosting site stay active at no cost.
- Free tier limits are generous for personal use: 50,000 reads/day, 20,000 writes/day, 10 GB Hosting storage, and 360 MB/day data transfer.
- The Flutter SDK, Firebase CLI, and FlutterFire CLI remain installed on your machine for future projects.
Pause - I'll come back to this later
No action needed here either. The Firebase Spark plan has no charges even if your app sits idle for months.
- Your hosted app stays live at its Firebase Hosting URL. You can return and redeploy anytime.
- Your Firestore data stays stored until you delete it.
- If you want to free local disk space, you can delete the build/web/ folder inside your project. Regenerate it anytime with flutter build web.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow these steps to fully clean up.
- Delete the Firebase project: go to the Firebase Console (console.firebase.google.com), click your project, then go to Project Settings (gear icon). Scroll to the bottom of the page and click Delete project. Follow the confirmation prompts. This removes Firestore, Authentication, and Hosting permanently.
- Delete the local project folder by running this command in PowerShell:
- Remove the Firebase CLI by running:
- Remove the FlutterFire CLI by running:
- Remove the Flutter SDK by deleting the C:\dev\flutter folder and removing C:\dev\flutter\bin from your Windows PATH environment variable (System Properties > Environment Variables > Path > select the Flutter entry > Delete).
Remove-Item -Recurse -Force ~\Desktop\shared_calendar
npm uninstall -g firebase-tools
dart pub global deactivate flutterfire_cli
Remove-Item -Recurse -Force C:\dev\flutter
Nice Work!
Nice Work!
Nice work! You just built a full-stack personal calendar web app with Flutter and Firebase, complete with real authentication and cloud-persisted events.
You've learned how to:
- Built an interactive monthly calendar UI using the table_calendar package, with day selection, event dots, and format switching.
- Implemented Firebase Authentication with email/password sign-up and login, protecting your app so only authenticated users can access their calendar.
- Connected Cloud Firestore to persist events across page refreshes and browser sessions, fixing the fragile local-state problem you hit in Step 3.
- Secret Mission: Deployed the app as a live PWA on Firebase Hosting, accessible from any device with a shareable URL.
Ready to quiz yourself?