Share Your Calendar with Real-Time Sync
Build a collaborative calendar with live sync and invite codes using Firestore.
Introduction
30 Second Summary
You know how shared calendars work at their best: one person adds a meeting, and everyone else sees it instantly. Behind that seamless experience is a real-time data layer that pushes changes the moment they happen.
In this project, you will transform your personal calendar app into a collaborative one where multiple users share events through invite codes and see each other's changes sync live across browser tabs.
What You'll Build
Open two browser tabs side by side, add an event in one, and watch it appear in the other within a second.
By the end of this project, you'll have:
- A calendar that uses Firestore real-time streams to push event updates to every connected tab instantly, with no page refresh needed.
- Full CRUD on events: create, edit a title by tapping, and delete by long-pressing, with every change syncing live.
- A working invite code system where you generate a short code, share it with another user, and both of you add and see events on the same shared calendar in real time.
- Secret Mission: Lock down your database with Firestore security rules so only calendar members can read and write events.
Are there any prerequisites?
Yes. This is Part 2 of the Shared Calendar series. You must have completed Part 1 ("Build a Personal Calendar App") and have a working Flutter project with Firebase Auth and Firestore event persistence already set up.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Verify Your Part 1 App and Update Packages
Your Part 1 calendar app is the foundation for everything you build in this project. Every feature you add (real-time sync, shared calendars, invite codes) layers on top of the working codebase you already have.
If the app does not run cleanly or has outdated packages, every later step will fail with confusing errors. In this step, you will confirm your baseline works and bring all packages up to date.
In this step, get ready to:
- Confirm your Part 1 app runs and signs in successfully.
- Update all Firebase packages to the latest compatible versions.
Run your existing app
Before making any changes, you need to prove the app still works exactly as you left it in Part 1. A successful run here means your environment, Firebase connection, and authentication are all healthy.
- In VS Code, open the terminal panel (Ctrl+`).
- Launch the app in Chrome by running this command:
flutter run -d chrome
What does this command do?
The flutter run command compiles your Dart code and launches the app. The -d chrome flag tells Flutter to run it in Google Chrome instead of looking for a mobile device.
- Wait for the app to compile and open in a Chrome tab.
- Sign in with the email and password you created in Part 1.
You should see your calendar load with any events you added during Part 1. The event dots appear on the calendar and the titles display below when you tap a day.
App not loading or sign-in failing?
Make sure your WSL terminal is in the shared_calendar project directory. If you see a Firebase connection error, confirm your Firebase project is still active in the Firebase Console.
If you forgot your Part 1 credentials, use the Create Account option to make a new account. Your old events will not appear, but the app will still work.
Help me troubleshoot why my Flutter calendar app is not loading in Chrome.
Update Firebase packages
Flutter packages receive frequent updates with bug fixes and performance improvements. Updating now ensures you are building on the latest stable versions and avoids compatibility issues with the new code you will add.
- Switch back to the terminal where Flutter is running.
- Stop the app by pressing q in the terminal.
- Update all packages to their latest compatible versions by running:
flutter pub upgrade
What does this command do?
The flutter pub upgrade command updates all dependencies to the latest versions that satisfy your pubspec.yaml constraints. It will not break anything because it stays within the version ranges you already specified.
You should see output listing the packages that were updated, along with their new version numbers.
- Before you run the app again, ask yourself: do you expect the calendar to behave any differently after a package upgrade?
- Run the app again to confirm everything still works:
flutter run -d chrome
- Sign in with your Part 1 account.
- Confirm the calendar loads and your existing events still display correctly.
The app should behave identically to before. Package upgrades within your version constraints do not change functionality.
Seeing errors after the upgrade?
If the app fails to compile, run flutter clean followed by flutter pub get, then try flutter run -d chrome again. This clears cached build artifacts that may conflict with the updated packages.
Help me fix compilation errors after running flutter pub upgrade in my shared calendar project.
Your baseline is solid. The app runs, authentication works, and your packages are current. Next up, you will replace the one-shot data fetch with a real-time stream so events sync instantly across browser tabs.
Add Real-Time Sync to Your Calendar
Right now your app fetches events once when the screen loads. If you open a second browser tab and add an event there, the first tab stays stale until you manually refresh.
Real-time listeners fix this by pushing changes to your UI the moment Firestore receives them. In this step, you will replace the one-shot fetch with a stream that keeps every open tab in sync automatically.
In this step, get ready to:
- Discover the stale data problem by opening two browser tabs.
- Replace the one-shot fetch with a Firestore real-time stream.
- Verify that events sync across tabs within a second.
Discover the stale data problem
Before you fix anything, experience the problem firsthand. You need to see what "stale data" actually looks like so the fix makes sense.
- Switch back to your Chrome tab where the app is running.
- Copy the URL from the address bar (it will be something like http://localhost: followed by a port number).
- Open a new Chrome tab and paste that URL.
- Sign in with the same account in both tabs.
- Arrange both tabs side by side so you can see both calendars at once.
Before you add an event, predict what will happen. If you add an event in Tab 1, do you think it will appear in Tab 2 automatically?
- In Tab 1, tap a date and add an event called Test sync.
- Look at Tab 2.
The event does NOT appear in Tab 2. Tab 2 is stale because _loadEvents() only runs once in initState.
Why is Tab 2 stale?
Your current code calls _loadEvents() once when the widget initializes. That method uses a one-shot .get() call that fetches data and stops listening.
Tab 2 loaded its data at sign-in time and never checks again. You need a stream that stays open and pushes new data the instant Firestore receives it.
Replace the one-shot fetch with a real-time stream
Firestore's .snapshots() method returns a stream that fires immediately with the current data and again whenever a document is added, modified, or removed.
- In VS Code, open lib/home_screen.dart.
- Add this import at the very top of the file, with your other imports:
import 'dart:async';
- Find your state variables (near the top of _HomeScreenState) and add this new variable alongside them:
StreamSubscription<QuerySnapshot>? _eventsSubscription;
- Now delete the entire _loadEvents() method from your file.
- Add the following _subscribeToEvents() method in its place:
void _subscribeToEvents() {
// Cancel any existing subscription before creating a new one
_eventsSubscription?.cancel();
final uid = FirebaseAuth.instance.currentUser!.uid;
// Listen to the events collection for real-time updates
_eventsSubscription = FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('events')
.snapshots()
.listen((snapshot) {
final Map<DateTime, List<Map<String, dynamic>>> loadedEvents = {};
for (final doc in snapshot.docs) {
final data = doc.data();
if (data['date'] == null) continue;
final date = _normalizeDate((data['date'] as Timestamp).toDate());
if (loadedEvents[date] == null) {
loadedEvents[date] = [];
}
loadedEvents[date]!.add({'id': doc.id, ...data});
}
// Update state whenever Firestore data changes
setState(() => _events = loadedEvents);
});
}
What does this code do?
- .snapshots() opens a persistent connection to Firestore that pushes data to your app whenever documents change.
- .listen(...) attaches a callback that rebuilds your events map every time the stream fires.
- The first line cancels any old subscription so you never have two listeners running at once.
- setState(...) triggers Flutter to re-render the calendar with the latest data.
- Now update your initState and add a dispose method by replacing your existing lifecycle methods with the following:
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
// Start listening to real-time updates
_subscribeToEvents();
}
@override
void dispose() {
// Stop listening when the screen is closed
_eventsSubscription?.cancel();
super.dispose();
}
Why cancel in dispose?
A StreamSubscription stays open until you cancel it. If you navigate away without cancelling, the listener keeps running in the background and causes memory leaks or errors when it tries to call setState on a disposed widget.
- Save home_screen.dart.
Seeing red squiggles after saving?
- If you see an error about _loadEvents being undefined, make sure you deleted the _loadEvents() call from initState and replaced the entire initState method with the version above.
- If StreamSubscription<QuerySnapshot> is unresolved, check that you added import 'dart:async'; at the top of the file.
- help me debug red squiggles in home_screen.dart after adding the real-time stream
✔️ Awesome, I've got everything!
Great. Make sure you have saved home_screen.dart with no red squiggles before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your home_screen.dart contains the following in order:
- import 'dart:async'; at the top with your other imports.
- StreamSubscription<QuerySnapshot>? _eventsSubscription; declared with your state variables.
- The _subscribeToEvents() method (replaces _loadEvents()).
- initState calling _subscribeToEvents() (not _loadEvents()).
- dispose() calling _eventsSubscription?.cancel().
- No remaining reference to _loadEvents anywhere in the file.
Test real-time sync across tabs
Time to see if your stream actually works.
- Stop the running app by pressing q in the terminal.
- Start it again by running:
flutter run -d chrome
- Sign in with your account.
- Open a second Chrome tab at the same localhost URL and sign in with the same account.
- Arrange both tabs side by side.
Before you add an event, predict what will happen this time. Will the event appear in Tab 2 without refreshing?
- In Tab 1, tap a date and add an event.
- Look at Tab 2.
The event dot and title appear in Tab 2 within a second, without refreshing. Both tabs are now fed by the same live Firestore stream.
Event not appearing in Tab 2?
- Make sure both tabs are signed in with the same account. Different accounts read from different users/{uid}/events paths.
- Confirm you deleted the old _loadEvents() method entirely. If it still exists, your initState might be calling the old method instead of _subscribeToEvents().
- Check the Chrome DevTools console for any Firestore permission errors. If you see a permission denied message, your Firestore rules may have expired from test mode.
- help me debug why events are not syncing in real time between my two browser tabs
Your calendar now syncs in real time across every open tab. Next up, you will add edit and delete so you can fix typos and remove cancelled events without leaving the app.
Add Edit and Delete to Complete CRUD
Your calendar now syncs in real time across tabs. That is a big improvement over the one-shot fetch from Part 1.
But try fixing a typo in an event title or removing a cancelled meeting. You cannot do either. A calendar without edit and delete is frustrating to use. In this step, you will complete the full CRUD cycle (Create, Read, Update, Delete) by adding Firestore's .update() and .delete() methods.
In this step, get ready to:
- Add an edit dialog that lets you tap an event to rename it.
- Add a delete action that lets you long-press an event to remove it permanently.
- Verify both changes sync to a second tab in real time.
Add an edit dialog
When a user taps an event in the list, they should see a dialog pre-filled with the current title. On save, the title updates in Firestore and the real-time stream pushes the change to every open tab automatically.
- In lib/home_screen.dart, add the following _editEvent method inside your _HomeScreenState class, below your _addEvent method:
Future<void> _editEvent(Map<String, dynamic> event) async {
// Pre-fill a text controller with the current event title
final titleController = TextEditingController(text: event['title']);
final newTitle = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Edit 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('Save'),
),
],
),
);
// Update the Firestore document if the user provided a new title
if (newTitle != null && newTitle.isNotEmpty) {
final uid = FirebaseAuth.instance.currentUser!.uid;
await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('events')
.doc(event['id'])
.update({'title': newTitle});
}
}
What does this code do?
- showDialog<String> opens an AlertDialog and returns the entered text when the user presses Save, or null if they press Cancel.
- The TextEditingController starts pre-filled with event['title'], so the user sees the current name and can edit it in place.
- .update({'title': newTitle}) changes only the title field on that document. The real-time stream picks up the change and refreshes the UI everywhere.
Now wire the edit method to the event list. Find your ListTile in the ListView.builder and add an onTap callback.
- Find this existing ListTile in your ListView.builder:
ListTile(
leading: const Icon(Icons.event),
title: Text(event['title']),
)
- Add onTap: () => _editEvent(event), so the ListTile now looks like this:
ListTile(
leading: const Icon(Icons.event),
title: Text(event['title']),
onTap: () => _editEvent(event),
)
- Save lib/home_screen.dart.
- Hot restart the app (press R in the terminal). Make sure you have two Chrome tabs open and signed in.
- In Tab 1, tap any event in the list. The edit dialog appears pre-filled with the event title.
- Change the title and press Save. The updated title appears in both Tab 1 and Tab 2 within a second.
Tap does nothing?
- Make sure onTap is inside the ListTile(...) constructor, not outside it. It should be a named parameter alongside leading and title.
- If the dialog opens but the title does not update after pressing Save, check that you used .update({'title': newTitle}) and not .set(). Using .set() without merge would overwrite the entire document.
- Confirm that your events map includes the document ID. Your stream listener should store {'id': doc.id, ...data} so the event['id'] reference works.
Help me debug why tapping an event does not open the edit dialog.
Add delete with confirmation
Deleting an event should require confirmation so users do not accidentally remove something important. A long-press gesture triggers the delete flow, keeping it distinct from the tap-to-edit action.
- Add the following _deleteEvent method below your _editEvent method:
Future<void> _deleteEvent(Map<String, dynamic> event) async {
// Ask the user to confirm before deleting
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Event'),
content: Text('Delete "${event['title']}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
// Remove the document from Firestore if confirmed
if (confirm == true) {
final uid = FirebaseAuth.instance.currentUser!.uid;
await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('events')
.doc(event['id'])
.delete();
}
}
What does this code do?
- showDialog<bool> returns true if the user confirms or false if they cancel.
- The red Delete button makes it visually clear this is a destructive action.
- .delete() removes the document from Firestore entirely. The real-time stream fires immediately when a document is removed, so the event vanishes from every open tab.
- Now add onLongPress to the same ListTile so it calls _deleteEvent. Update it to look like this:
ListTile(
leading: const Icon(Icons.event),
title: Text(event['title']),
onTap: () => _editEvent(event),
onLongPress: () => _deleteEvent(event),
)
Why onLongPress instead of a delete button?
Using onLongPress keeps the list clean without cluttering each row with extra buttons. A long-press is a standard mobile gesture for destructive actions. The confirmation dialog prevents accidental deletions.
- Save lib/home_screen.dart.
- Hot restart the app (press R in the terminal).
Before you test, predict what will happen when you long-press an event and confirm the deletion. Will the event disappear from just one tab, or both?
- In Tab 1, long-press an event. The confirmation dialog appears showing the event title.
- Press Delete. The event disappears from Tab 1 and Tab 2 simultaneously.
The event vanishes from both tabs because your stream subscription fires the moment Firestore processes the delete.
Long-press not triggering?
- In a browser, long-press means clicking and holding the mouse button for about half a second. A quick click triggers onTap instead.
- Make sure onLongPress: () => _deleteEvent(event) is a named parameter inside ListTile(...), not outside the constructor.
- If you see a browser context menu appearing instead, try using Chrome DevTools mobile view (toggle the device toolbar) to simulate touch events.
Help me figure out why long-press is not working for delete.
✔️ Awesome, I've got everything!
Great. Double check you have saved lib/home_screen.dart and both edit and delete are working across tabs.
ⓧ I'd like to double check the full code
Your lib/home_screen.dart has grown across two steps now. Scroll up and confirm you have all of the following pieces in order:
- The StreamSubscription<QuerySnapshot>? _eventsSubscription field and _subscribeToEvents() method from Step 2.
- The _addEvent() method from Part 1.
- The _editEvent() method you just added.
- The _deleteEvent() method you just added.
- The ListTile with both onTap: () => _editEvent(event) and onLongPress: () => _deleteEvent(event).
- dispose() cancelling _eventsSubscription.
You now have full CRUD on your calendar events. Create, Read, Update, and Delete all work in real time across multiple tabs. Next up, you will restructure your data model so multiple users can share the same calendar.
Restructure to Shared Calendars
Your calendar now has full CRUD with real-time sync. Add, edit, or delete an event in one tab, and the other tab updates instantly.
There is a problem hiding underneath. Your events live at users/{uid}/events, which means only one user can ever see them. To share events between users, you need a shared calendars collection where each calendar document holds a memberIds array and events live underneath it.
In this step, get ready to:
- Create a shared calendar data model with an invite code generator.
- Auto-create a default calendar on first login.
- Redirect all event reads and writes to the new shared path.
Create the shared calendar data model
Before you can share a calendar, you need infrastructure: state variables to track which calendar is active, and a method to generate short invite codes that other users can type in to join.
- In lib/home_screen.dart, add the dart:math import at the top of the file, below your existing imports:
import 'dart:math';
- Inside the _HomeScreenState class, find the existing state variables (_calendarFormat, _focusedDay, etc.) and add these two new variables below them:
// Track which shared calendar is currently active
String? _currentCalendarId;
String? _currentInviteCode;
- Add the _generateInviteCode() method inside the class, below the dispose() method:
// Generate a random 6-character uppercase alphanumeric invite code
String _generateInviteCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
final rand = Random();
return List.generate(6, (_) => chars[rand.nextInt(chars.length)]).join();
}
What does this code do?
The _generateInviteCode() method picks 6 random characters from a set of uppercase letters and digits. This produces codes like A7KM2X that are short enough to type but random enough to avoid collisions.
The two state variables track which calendar document the app is currently reading from and its invite code. They start as null until the app finds or creates a calendar on login.
- Save home_screen.dart.
Auto-create a default calendar on first login
When the user signs in, the app should check if they already belong to a calendar. If not, it creates one named "My Calendar" with the user as the sole member.
- Add the _initCalendar() method below _generateInviteCode() in home_screen.dart:
Future<void> _initCalendar() async {
final uid = FirebaseAuth.instance.currentUser!.uid;
// Check if the user already belongs to a calendar
final snapshot = await FirebaseFirestore.instance
.collection('calendars')
.where('memberIds', arrayContains: uid)
.limit(1)
.get();
if (snapshot.docs.isEmpty) {
// No calendar found, create a new one
final code = _generateInviteCode();
final docRef =
await FirebaseFirestore.instance.collection('calendars').add({
'name': 'My Calendar',
'ownerUid': uid,
'memberIds': [uid],
'inviteCode': code,
'createdAt': FieldValue.serverTimestamp(),
});
setState(() {
_currentCalendarId = docRef.id;
_currentInviteCode = code;
});
} else {
// Calendar exists, use it
final doc = snapshot.docs.first;
setState(() {
_currentCalendarId = doc.id;
_currentInviteCode = doc.data()['inviteCode'] as String?;
});
}
_subscribeToEvents();
}
What does this code do?
The arrayContains query finds any calendar where the current user's UID is in the memberIds array. If none exists, it creates a new document at calendars/{autoId} with the user as owner and sole member.
After finding or creating the calendar, the method saves the calendar's document ID and invite code to state, then calls _subscribeToEvents() to start listening for events.
- Now update initState to call _initCalendar() instead of directly subscribing to events. Find your current initState method and replace it with:
@override
void initState() {
super.initState();
_selectedDay = _focusedDay;
_initCalendar();
}
- Save home_screen.dart.
- Run the app with flutter run -d chrome and sign in.
The app should load without errors. Your events from the previous steps still display because _subscribeToEvents() is still pointing at the old path. The important change is behind the scenes.
- Open the Firebase Console in a new browser tab.
- Navigate to Firestore in the left sidebar, then click the Data tab.
- Click the calendars collection. You should see a new document with fields: name set to My Calendar, memberIds containing your UID, and a 6-character inviteCode.
Don't see the calendars collection?
Make sure you signed in to the app after saving the new code. The calendar document is only created when _initCalendar() runs during initState. If you had the app running from before your code change, stop it (press q) and restart with flutter run -d chrome.
Also confirm you replaced the old initState body with the new version that calls _initCalendar().
Help me debug why my calendars collection isn't appearing in Firestore.
Point events at the shared calendar
The calendar document exists, but your events are still being read from and written to the old users/{uid}/events path. Now you will redirect all event operations to calendars/{calendarId}/events.
- Find your current _subscribeToEvents() method in home_screen.dart and replace it entirely with this version:
void _subscribeToEvents() {
_eventsSubscription?.cancel();
// Don't subscribe if no calendar is set yet
if (_currentCalendarId == null) return;
_eventsSubscription = FirebaseFirestore.instance
.collection('calendars')
.doc(_currentCalendarId)
.collection('events')
.snapshots()
.listen((snapshot) {
final Map<DateTime, List<Map<String, dynamic>>> loadedEvents = {};
for (final doc in snapshot.docs) {
final data = doc.data();
if (data['date'] == null) continue;
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 changed?
The stream now listens to calendars/{_currentCalendarId}/events instead of users/{uid}/events. A null guard at the top prevents subscribing before the calendar is initialized.
Because the calendar document was just created and has no events subcollection yet, your calendar will appear empty after this change. That is correct. You will add a new event in a moment to confirm writes also go to the new path.
- Now update the _addEvent() method. Find the Firestore write inside it and replace the path. Locate this section:
await FirebaseFirestore.instance
.collection('users')
.doc(uid)
.collection('events')
.add({
- Replace it with this path, and also update the condition to check for _currentCalendarId:
if (title != null && title.isNotEmpty && _currentCalendarId != null) {
await FirebaseFirestore.instance
.collection('calendars')
.doc(_currentCalendarId)
.collection('events')
.add({
'title': title,
'date': Timestamp.fromDate(_selectedDay!),
'createdBy': FirebaseAuth.instance.currentUser!.uid,
'createdAt': FieldValue.serverTimestamp(),
});
}
- Update the _editEvent() method. Find its Firestore .update() call and replace the path section with:
if (newTitle != null && newTitle.isNotEmpty && _currentCalendarId != null) {
await FirebaseFirestore.instance
.collection('calendars')
.doc(_currentCalendarId)
.collection('events')
.doc(event['id'])
.update({'title': newTitle});
}
- Update the _deleteEvent() method. Find its Firestore .delete() call and replace the path section with:
if (confirm == true && _currentCalendarId != null) {
await FirebaseFirestore.instance
.collection('calendars')
.doc(_currentCalendarId)
.collection('events')
.doc(event['id'])
.delete();
}
- Save home_screen.dart.
Before you run the app, what do you expect to see on the calendar when it loads? Remember, the stream now points at the new path which has no events yet.
- Stop and restart the app with flutter run -d chrome. Sign in.
The calendar loads with no events. That is expected. Your old events still exist at the previous path, but the app now reads from the shared calendar's subcollection.
- Tap the + button and add a new event.
- Open a second Chrome tab at the same localhost URL and sign in.
- Confirm the event you just created appears in both tabs. Tap it to edit the title, then long-press to delete it. Both actions should sync across tabs in real time.
Events not syncing to the second tab?
Confirm both tabs are signed in with the same account. The _initCalendar() method finds the same calendar for the same UID, so both tabs should subscribe to the same calendars/{id}/events path.
If the second tab shows no events, check the browser console for permission errors. Your Firestore rules may still be in test mode, which should allow all reads and writes.
Help me figure out why my events aren't syncing between tabs after restructuring to shared calendars.
✔️ Awesome, I've got everything!
Great. Confirm you have saved home_screen.dart and your app adds, edits, and deletes events on the new shared path.
ⓧ I'd like to double check the full code
Your home_screen.dart should now contain these pieces in order. Scroll through your file and confirm each one is present:
- The import 'dart:math'; line at the top, alongside your other imports.
- State variables _currentCalendarId and _currentInviteCode declared as String?.
- The initState method calling _initCalendar() (not the old subscription setup).
- The _generateInviteCode() method returning a 6-character string.
- The _initCalendar() method querying calendars with arrayContains, creating if empty, and ending with _subscribeToEvents().
- The _subscribeToEvents() method pointing at calendars/{_currentCalendarId}/events.
- All three CRUD methods (_addEvent, _editEvent, _deleteEvent) writing to calendars/{_currentCalendarId}/events with a _currentCalendarId != null guard.
Your calendar now runs on a shared data model. Any user with the right calendar ID could read and write events. Next up, you will expose the invite code in the UI and build a join flow so a second user can actually connect to your calendar.
Share Your Calendar with an Invite Code
The whole point of restructuring your data in the previous step was to let multiple users access the same calendar. You have the shared data model in place, but no way for a second user to actually find and join your calendar.
In this step, you will display the invite code to the calendar owner and build a join flow so a second user can enter the code and immediately see shared events appear in real time.
In this step, get ready to:
- Display the invite code with a copy button below the app bar.
- Build the join calendar flow that adds a user to an existing calendar's members.
- Test multi-user sharing between two browser windows.
Display the invite code and add a join button
Your calendar already generates an invite code and stores it in Firestore. The user just cannot see it yet. You need to surface the code in the UI and give the user a way to copy it.
- In lib/home_screen.dart, find the imports at the top of the file.
- Add the following import below your existing package:flutter/material.dart line:
import 'package:flutter/services.dart';
What does this import do?
The services.dart package gives you access to Clipboard.setData(), which lets you copy text to the user's clipboard programmatically.
- Now scroll down to the build method and find the AppBar widget.
- Update the actions list to add a join button before the logout button:
appBar: AppBar(
title: const Text('Shared Calendar'),
actions: [
IconButton(
icon: const Icon(Icons.group_add),
onPressed: _joinCalendar,
tooltip: 'Join Calendar',
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => FirebaseAuth.instance.signOut(),
),
],
),
What does this code do?
The Icons.group_add button gives users a way to join another calendar. It calls _joinCalendar, which you will build in the next substep.
Next, you need to display the invite code itself. This goes in the body Column, right before the TableCalendar widget.
- Inside the Column's children list, add this widget as the first child (above TableCalendar):
// Display invite code with a copy button
if (_currentInviteCode != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
const Icon(Icons.share, size: 18),
const SizedBox(width: 8),
Text('Invite code: $_currentInviteCode'),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () {
Clipboard.setData(
ClipboardData(text: _currentInviteCode!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Code copied!')),
);
},
tooltip: 'Copy code',
),
],
),
),
What does this code do?
This Row only renders when _currentInviteCode is not null, meaning a calendar has loaded. It shows a share icon, the code text, and a copy button.
When the user taps the copy icon, Clipboard.setData() copies the code to their clipboard. A SnackBar confirms the action so the user knows it worked.
- Save lib/home_screen.dart.
- Hot reload the app in Chrome (press r in the terminal where Flutter is running).
- You should see a 6-character invite code displayed below the app bar with a share icon and copy icon. You should also see a group_add icon button in the top right of the AppBar.
Don't see the invite code?
Make sure the Padding widget with the invite code Row is placed inside the children: [] list of the Column in your body, above the TableCalendar widget.
Check that you added import 'package:flutter/services.dart'; at the top. Without it, Clipboard is unresolved and the file won't compile.
help me debug why my invite code row is not showing up
Implement the join logic
Now that the invite code is visible, you need the other half of the flow. When someone taps the join button and enters a code, your app needs to find the matching calendar and add them as a member.
- In lib/home_screen.dart, add the _joinCalendar() method. Place it below the _deleteEvent method and above the build method. Start with the dialog that collects the invite code:
Future<void> _joinCalendar() async {
final codeController = TextEditingController();
// Show dialog to collect the invite code from the user
final code = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Join Calendar'),
content: TextField(
controller: codeController,
decoration: const InputDecoration(
hintText: 'Enter invite code',
border: OutlineInputBorder(),
),
textCapitalization: TextCapitalization.characters,
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, codeController.text.trim()),
child: const Text('Join'),
),
],
),
);
if (code == null || code.isEmpty) return;
What does this code do?
This opens an AlertDialog with a text field for the invite code. textCapitalization: TextCapitalization.characters auto-uppercases the input to match the code format.
If the user cancels or enters nothing, the method returns early without hitting Firestore.
- Save the file, hot reload, and tap the group_add icon in the AppBar.
- You should see the "Join Calendar" dialog appear with a text field and two buttons.
The dialog works, but it does not do anything yet. Now add the Firestore query and join logic directly after the early return line you just wrote:
- Continue the _joinCalendar() method by adding this code below the if (code == null || code.isEmpty) return; line:
// Query Firestore for a calendar matching the entered code
final snapshot = await FirebaseFirestore.instance
.collection('calendars')
.where('inviteCode', isEqualTo: code.toUpperCase())
.limit(1)
.get();
if (snapshot.docs.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invalid invite code')),
);
}
return;
}
// Add current user to the calendar's memberIds array
final calendarDoc = snapshot.docs.first;
final uid = FirebaseAuth.instance.currentUser!.uid;
await calendarDoc.reference.update({
'memberIds': FieldValue.arrayUnion([uid]),
});
// Switch to the joined calendar and re-subscribe to its events
setState(() {
_currentCalendarId = calendarDoc.id;
_currentInviteCode = calendarDoc.data()['inviteCode'] as String?;
});
_subscribeToEvents();
}
What does this code do?
The query searches the calendars collection for a document whose inviteCode field matches what the user typed. If no match is found, a SnackBar tells the user the code is invalid.
FieldValue.arrayUnion([uid]) adds the current user's UID to the memberIds array without duplicating it if they already joined. Then the app switches _currentCalendarId to the joined calendar and calls _subscribeToEvents() to start listening to the new calendar's events.
- Save lib/home_screen.dart.
Seeing a compile error about _joinCalendar?
Make sure the method's opening brace from the first chunk and the closing brace from the second chunk are correctly paired. The full method starts with Future<void> _joinCalendar() async { and ends with a single } after _subscribeToEvents();.
help me fix compile errors in my _joinCalendar method
✔️ Awesome, I've got everything!
Great. Make sure you have saved lib/home_screen.dart before moving on to the multi-user test.
ⓧ I'd like to double check the full code
Scroll up and confirm these pieces are present in lib/home_screen.dart, in this order:
- The import 'package:flutter/services.dart'; line near the top.
- The full _joinCalendar() method (dialog chunk + query/join chunk combined into one method).
- The AppBar actions list with Icons.group_add calling _joinCalendar.
- The invite code Padding/Row widget as the first child in the body Column.
Test multi-user sharing
Everything is wired up. Time to prove that two separate users can share a calendar and see each other's events in real time.
- In your existing Chrome tab (User A), note the 6-character invite code displayed below the app bar. Click the copy icon to copy it.
- Open a new incognito window in Chrome (press Ctrl+Shift+N on Windows/Linux or Cmd+Shift+N on macOS).
- Navigate to the same localhost URL that your app is running on.
- Create a new account (User B) using a different email address and password.
- As User B, tap the group_add icon in the AppBar.
- Paste User A's invite code into the text field and tap Join.
You should see a SnackBar confirming that User B joined the calendar. The invite code displayed should now match User A's code.
- As User B, select a day on the calendar and add an event using the floating action button.
Before you look at User A's tab, what do you think will happen there? Will the event appear automatically, or will User A need to refresh?
- Switch to User A's Chrome tab (the non-incognito window).
User B's event appears on User A's calendar within a second. No refresh needed. The real-time stream picks up the new document the moment it lands in Firestore.
- As User A, add another event. Confirm it appears in User B's incognito window in real time.
Why does this work?
Both users are now members of the same calendar document. Both have a StreamSubscription listening to the same calendars/{calendarId}/events collection.
When either user writes a document, Firestore pushes the change to all active listeners on that path. The stream fires, your setState rebuilds the widget, and both UIs stay in sync.
User B's events not showing for User A?
Check that User B's join was successful. Open the Firebase Console, navigate to the Firestore calendars collection, and confirm the calendar document's memberIds array contains both UIDs.
If User B created their own separate calendar instead of joining User A's, the events are going to different documents. Make sure User B tapped the group_add icon and entered the code, not just signed up and used the app directly.
help me debug why shared calendar events are not syncing between users
Two users are now sharing a calendar in real time. Any event added by either person appears instantly for the other. Next up, you will lock down your database so only calendar members can read and write events.
Secret mission
Lock Down Your Database with Security Rules
Right now your Firestore database is wide open. Anyone with the URL could read or write any calendar. Lock it down with security rules that restrict access to calendar members only.
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 runs on Firebase's Spark plan, which is free indefinitely with no ongoing costs.
Resources you used:
- Firebase project (Firestore database with calendars collection, Authentication). Same project from Part 1.
- Local shared_calendar project folder in WSL.
Keep everything running
No action needed. Choose this if you plan to keep building on this project or revisiting the code.
- Firebase Spark plan is free indefinitely. Your Firestore data stays stored at no charge.
- Your local shared_calendar folder remains on disk and uses minimal space.
- Your user accounts in Firebase Authentication remain active.
Pause - I'll come back to this later
No action needed. No charges accumulate on idle Spark projects.
- Your Firestore documents, authentication users, and security rules all persist at zero cost while idle.
- Stop the Flutter dev server if it is still running by pressing q in the terminal.
Delete - I don't want to use this again
Remove all project data and start fresh. Follow these steps to clean up completely.
- Delete the calendars collection from the Firebase Console:
- Open the Firebase Console and select your project.
- In the left panel, go to Databases & Storage > Firestore.
- In the Data tab, select the calendars collection.
- Click the menu icon at the top of the documents column, then click Delete collection.
- Confirm the deletion. This removes all shared calendars and their events (subcollections are deleted along with the collection).
- Your user accounts in Authentication remain. If you want to remove test users, go to Build > Authentication > Users and delete them individually.
To delete the local project folder, run this command in your WSL terminal:
rm -rf ~/Desktop/shared_calendar
What about the Firebase project itself?
This is the same Firebase project you created in Part 1. If you are done with both Part 1 and Part 2, you can delete the entire Firebase project from the Firebase Console under Project settings > General > scroll to the bottom and click Delete project. Only do this if you are certain you no longer need any resources in the project.
Nice Work!
Nice Work!
Nice work! You just transformed a personal calendar app into a real-time collaborative calendar where multiple users share events instantly through invite codes.
You've learned how to:
- Replace one-shot Firestore reads with real-time .snapshots() streams using StreamSubscription, making your UI update instantly across browser tabs without refreshing.
- Implement full CRUD operations on calendar events with Firestore's .update() and .delete() methods, with changes syncing in real time.
- Restructure a personal data model from users/{uid}/events to a shared calendars/{calendarId}/events model with memberIds arrays that support multi-user access.
- Build an invite code system that generates unique codes and uses FieldValue.arrayUnion to let new users join a shared calendar and see events appear live.
- Secret Mission: Deployed Firestore security rules that restrict calendar access to members only, using the request.auth.uid in resource.data.memberIds pattern with get() for subcollection access control.
Ready to quiz yourself?