Secure Realtime Calendar CRUD

Build a Flutter calendar with realtime CRUD and Firestore member security.

Introduction

30 Second Summary

A calendar stops being trustworthy when one browser shows a new event while another keeps the old schedule. Private plans also need a clear boundary around who can see or change them.

In this project, you will upgrade your Part 1 Flutter calendar into a realtime CRUD app that synchronizes event changes across browser sessions. Cloud Firestore Security Rules will use Firebase Authentication UIDs to enforce member authorization.

What You'll Build

Picture creating an event in one signed-in browser window and watching it appear in another without touching refresh.

By the end of this project, you'll have:

  • A date-aware event view that shows the current events for the selected day.
  • A complete realtime CRUD workflow where every create, edit, or confirmed delete appears in both browser sessions.
  • A database-enforced authorization proof for both sides of the membership boundary. The owner request succeeds. The non-member-test request is denied.
  • Secret Mission: Add a second member UID. Prove that membership grants access without weakening the outsider denial.

Are there any prerequisites?

You need a working Part 1 Flutter calendar connected to Firebase Authentication and Cloud Firestore. This project stays at $0 on the Firebase Spark plan while usage remains within its no-cost quotas.

Before We Start

Before changing your working Part 1 calendar, lock in the stale-data problem that realtime CRUD must solve across two signed-in browser sessions. Your goal also includes member authorization enforced by Cloud Firestore Security Rules, so the database protects access beyond the Flutter UI.

Verify and Protect the Part 1 Baseline

The Part 1 Flutter calendar is your only known-good version. A dependency change could make a new failure hard to trace.

A full backup gives you a rollback point. A clean run in Google Chrome proves the existing Firebase sign-in flow still works.

In this step, get ready to:
  • Protect the original Part 1 project with a separate backup folder.
  • Verify the Windows Flutter toolchain.
  • Resolve the required Firebase package versions.
Back up and inspect the current app

A complete folder copy preserves the app before its dependencies or data access change. The original folder remains your active project.

  • Stop the running Part 1 app from its terminal.
  • Press the Windows key to open the search bar.
  • Type File Explorer into the search bar.
  • Press Enter to open Windows File Explorer.
  • Locate the original Part 1 project folder.
  • Copy the entire Part 1 project folder.
  • Paste the copied folder beside the original folder.
  • Add -backup to the copied folder's existing name.

You should see the original folder beside a second folder ending in -backup. The second folder is your untouched recovery copy.

Your rollback point is ready. You can inspect the original project while the copied folder preserves the working baseline.

  • Press the Windows key to open the search bar.
  • Type Visual Studio Code into the search bar.
  • Press Enter to open Visual Studio Code.
  • Drag the original Part 1 project folder from Windows File Explorer into the Visual Studio Code window.
  • Locate the source file that renders the existing calendar screen.
  • Locate the existing Firebase initialization.
  • Locate the existing Firebase Authentication sign-in gate.
  • Record the literal Cloud Firestore event collection path here: your existing event collection path.

Why map the current app?

The later refactor changes how event data is loaded. This map helps you preserve the app behavior that already works.

The literal collection path matters because Part 1 source code can differ between learners. Your current project is the authority for that temporary path.

  • Record the current event field names here: your existing event field names.
  • Identify the existing app name.
  • Identify the current app theme.
  • Trace the existing navigation into the calendar screen.
Verify or repair the Windows Flutter toolchain

The pinned packages require Dart 3.6 or newer. Checking the Flutter release first separates SDK problems from project problems.

  • Check the installed Flutter release from the Visual Studio Code terminal by running this command:
flutter --version

What does this command show?

The output identifies the installed Flutter release. It also identifies the SDK channel that supplied it.

✔️ I see the required stable version

The output reports Flutter 3.47.0 or higher. It also names the stable channel.

  • Continue to the Flutter toolchain health check below.

ⓧ I see an older stable version

An older SDK may include a Dart release below 3.6. Updating the stable SDK gives the pinned packages a compatible foundation.

  • Update Flutter to the newest release on the current stable channel by running this command:
flutter upgrade

What does this command do?

The command updates the Flutter SDK to the newest release on its current channel. The download can keep the terminal busy for several minutes.

  • Confirm the upgraded Flutter release by running this command:
flutter --version

What should I confirm?

The output should report Flutter 3.47.0 or higher. It should also identify the stable channel.

Upgrade not completing?

Close other terminals that are using the Flutter SDK. Restart Visual Studio Code before trying the upgrade again.

Ask for help interpreting the upgrade output.

ⓧ Flutter is not found

The VS Code installer flow can download Flutter stable. It can also add the SDK to the Windows path.

  • Return to Visual Studio Code from earlier.
  • Use Visual Studio Code's extension manager to install the Flutter extension.
  • Press Ctrl+Shift+P to open the command search.
  • Run Flutter: New Project.
  • Choose Download SDK.
  • Choose Clone Flutter.
  • Choose Add SDK to PATH.
  • Restart Visual Studio Code.
  • Restart the Visual Studio Code terminal.
  • Confirm that Windows can find Flutter by running this command:
flutter --version

What should I confirm?

The command should now print a Flutter release. Confirm that it reports 3.47.0 or higher on the stable channel.

Flutter still not found?

Restart Windows if a fresh Visual Studio Code terminal still cannot find Flutter. This reloads the updated path for every application.

Ask for help checking the Windows SDK path.

  • Check the Windows Flutter toolchain by running this command:
flutter doctor

What does this check prove?

The report checks the tools Flutter can use on this computer. This project needs Flutter itself plus the Google Chrome target.

You should see Flutter recognized in the report. You should also see Google Chrome available as the intended web target.

Seeing a Flutter or Chrome issue?

Restart Visual Studio Code after repairing Flutter. Rerun the check from the original Part 1 project terminal.

Ask for help interpreting the toolchain report.

Pin and resolve the project dependencies

Exact package pins make the baseline reproducible across runs. The next commands write the required versions into the existing project.

  • Return to the Visual Studio Code terminal from the toolchain check.
  • Confirm that the terminal path ends with the original Part 1 project folder name.
  • Pin the Firebase packages and resolve the dependency graph by running these commands:
flutter pub add firebase_core:5.1.0
flutter pub add firebase_auth:7.0.1
flutter pub add cloud_firestore:7.1.0
flutter pub get

What do these commands do?

  • The first three commands add exact Firebase package versions to the project.
  • The final command resolves a compatible package graph for the installed SDK.

The terminal should finish dependency resolution without reporting a package compatibility failure. This confirms that the current Flutter SDK satisfies the packages' Dart requirement.

Dependencies not resolving?

Confirm that the terminal is inside the original Part 1 project folder. Check that Flutter reports 3.47.0 or higher on the stable channel.

Ask for help diagnosing the dependency output.

Flutter may pause at a target prompt during the baseline run. Google Chrome keeps this check aligned with the existing web app.

Before you run the baseline, do you expect the package pins to change the calendar's visible behavior?

  • Choose Google Chrome if the target prompt appears.
  • Rebuild the original Part 1 app by running this command:
flutter run

What does this command do?

Flutter rebuilds the project with the resolved dependencies. It starts the configured application in the selected target.

  • Sign in through the existing Part 1 authentication flow.
  • Select a date in the existing calendar.
  • Create one test event through the current add-event flow.

You should see the existing calendar open in Google Chrome. The test event should appear through the same flow that worked before this project.

App not opening in Chrome?

Confirm that Google Chrome was selected as the target. Check that the terminal is running from the original Part 1 project folder.

Ask for help with the baseline run.

Your protected baseline is now reproducible. The backup preserves the original state while the running app proves the pinned packages work.

Next, you will replace stale event loading with a repository-owned realtime stream.

Make Event Creation Realtime

Your protected Part 1 Flutter baseline still works in Google Chrome. However, its one-shot event read can become stale when another client writes an event.

This step replaces refresh-driven loading with a stable Cloud Firestore stream behind a repository pattern. A StreamBuilder then rebuilds the calendar whenever a realtime snapshot arrives.

In this step, get ready to:
  • Define a typed event model that converts Firestore snapshots into Dart values.
  • Move event reads and creation into a repository-owned stream.
  • Render the stable stream and prove that creation synchronizes across two browser sessions.
Add a typed event model

Firestore returns document snapshots with map-shaped data. A typed model gives the calendar one predictable event shape.

The model also converts each Firestore timestamp into a Dart date. The UI can then compare event dates with the selected calendar date.

  • Create a models folder inside lib from the VS Code file sidebar.
  • Create calendar_event.dart inside the new lib/models folder.
  • Define the event fields and snapshot conversion by adding this class:
class CalendarEvent {
  const CalendarEvent({
    required this.id,
    required this.title,
    required this.notes,
    required this.startAt,
    required this.createdBy,
  });

  final String id;
  final String title;
  final String notes;
  final DateTime startAt;
  final String createdBy;

  factory CalendarEvent.fromSnapshot(
    QueryDocumentSnapshot<Map<String, dynamic>> snapshot,
  ) {
    final data = snapshot.data();

    return CalendarEvent(
      id: snapshot.id,
      title: data['title'] as String? ?? 'Untitled event',
      notes: data['notes'] as String? ?? '',
      startAt: (data['startAt'] as Timestamp).toDate(),
      createdBy: data['createdBy'] as String? ?? '',
    );
  }
}

What does this model do?

  • The five fields give every calendar event a document ID and the values needed by the UI.
  • The fromSnapshot factory reads one query result into a CalendarEvent.
  • The fallback strings keep missing optional text fields from crashing the event list.
  • The toDate() conversion turns the stored timestamp into a value the date filter can compare.
  • Use VS Code's import suggestion for QueryDocumentSnapshot to add the Cloud Firestore package import at the top of the file.
  • Save lib/models/calendar_event.dart.
  • Check the file diagnostics. You should see no undefined type errors for QueryDocumentSnapshot or Timestamp.

Seeing undefined Firestore types?

Confirm that the Cloud Firestore import sits above the class. Also confirm that the filename is exactly calendar_event.dart.

Check that the project still resolves cloud_firestore 7.1.0. Help me fix the unresolved Firestore types in calendar_event.dart.

✔️ Awesome, I've got everything!

Your event model is ready. Double-check that lib/models/calendar_event.dart is saved before continuing.

ⓧ I'd like to double check the full code

import 'package:cloud_firestore/cloud_firestore.dart';

class CalendarEvent {
  const CalendarEvent({
    required this.id,
    required this.title,
    required this.notes,
    required this.startAt,
    required this.createdBy,
  });

  final String id;
  final String title;
  final String notes;
  final DateTime startAt;
  final String createdBy;

  factory CalendarEvent.fromSnapshot(
    QueryDocumentSnapshot<Map<String, dynamic>> snapshot,
  ) {
    final data = snapshot.data();

    return CalendarEvent(
      id: snapshot.id,
      title: data['title'] as String? ?? 'Untitled event',
      notes: data['notes'] as String? ?? '',
      startAt: (data['startAt'] as Timestamp).toDate(),
      createdBy: data['createdBy'] as String? ?? '',
    );
  }
}
Put Firestore access behind a repository

The existing widget owns its Firestore calls. Moving those calls into one repository gives the screen a single data source.

This step keeps the user-owned collection path that you identified earlier. The shared calendar path arrives in a later step.

Why a repository-owned stream?

A repository separates the data layer from the calendar UI. The widget asks for events without rebuilding query details itself.

The repository also creates one long-lived snapshot stream. That stable stream keeps rebuilds focused on new event data.

  • Create a services folder inside lib from the VS Code file sidebar.
  • Create calendar_repository.dart inside lib/services.
  • Set up the repository dependencies and Firebase instances by adding this code:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

import '../models/calendar_event.dart';

class CalendarRepository {
  CalendarRepository({
    FirebaseFirestore? firestore,
    FirebaseAuth? auth,
  })  : _firestore = firestore ?? FirebaseFirestore.instance,
        _auth = auth ?? FirebaseAuth.instance;

  final FirebaseFirestore _firestore;
  final FirebaseAuth _auth;
}

What does this foundation do?

  • The constructor accepts optional Firebase instances so the repository can be tested with substitutes later.
  • The default values connect the repository to the Firebase project that already initializes in the Part 1 app.
  • The model import lets the repository return typed CalendarEvent objects.
  • Save lib/services/calendar_repository.dart.
  • Check the file diagnostics. You should see no unresolved import errors.

Seeing repository import errors?

Confirm that calendar_repository.dart is inside lib/services. Confirm that the model remains inside lib/models.

Help me check the repository imports and folder paths.

  • Place the signed-in user guard inside CalendarRepository before its final closing brace by adding this method:
  User _requireUser() {
    final user = _auth.currentUser;
    if (user == null) {
      throw StateError('A signed-in user is required.');
    }
    return user;
  }

Why require the current user?

The current event path belongs to the signed-in account. The guard stops repository operations when the authentication gate has no active user.

  • Add a CollectionReference<Map<String, dynamic>> getter named _eventsRef inside CalendarRepository.
  • Reuse the literal user-owned event path that you identified in Step 1.
  • Build that path from _firestore and the UID returned by _requireUser().
  • Keep the existing collection structure for this step. A path such as users/{uid}/events must retain the collection names used by your Part 1 app.
  • Save lib/services/calendar_repository.dart.
  • Check the file diagnostics. You should see no type errors on _eventsRef.
  • Expose ordered realtime events by adding this method inside CalendarRepository before its final closing brace:
  Stream<List<CalendarEvent>> watchEvents() {
    return _eventsRef
        .orderBy('startAt')
        .snapshots()
        .map(
          (snapshot) => snapshot.docs
              .map(CalendarEvent.fromSnapshot)
              .toList(growable: false),
        );
  }

How does the stream stay current?

  • The query orders event documents by startAt.
  • The snapshots() listener emits another query snapshot after a document changes.
  • The mapping step converts every document into a CalendarEvent.
  • The fixed-length list gives each snapshot one complete event result.
  • Save lib/services/calendar_repository.dart.
  • Check the file diagnostics. You should see no undefined references inside watchEvents().
  • Route new event writes through the same collection by adding this method inside CalendarRepository before its final closing brace:
  Future<void> createEvent({
    required String title,
    required String notes,
    required DateTime startAt,
  }) async {
    final user = _requireUser();

    await _eventsRef.add({
      'title': title,
      'notes': notes,
      'startAt': Timestamp.fromDate(startAt),
      'createdBy': user.uid,
      'createdAt': FieldValue.serverTimestamp(),
      'updatedAt': FieldValue.serverTimestamp(),
    });
  }

What does event creation store?

  • The repository asks Firestore to generate the document ID.
  • The selected Dart date becomes a Firestore timestamp through Timestamp.fromDate.
  • The authenticated UID records who created the event.
  • The server timestamps record when the document was created and last updated.
  • Save lib/services/calendar_repository.dart.
  • Check the file diagnostics. You should see no errors in createEvent().

Seeing errors in the repository?

Check that _eventsRef returns a typed collection reference. Check that its literal path matches the user-owned path from the original screen.

Help me debug the event repository without changing my Part 1 collection path.

Render the stable stream

A stream created during every widget build can restart its asynchronous work. The screen obtains one stream in initState so ordinary UI rebuilds keep the same listener.

The existing date picker remains the visible calendar surface. Each snapshot is filtered against the selected date before its events are rendered.

  • Switch back to the existing calendar screen from Part 1.
  • Use VS Code's import suggestions to add the event model and calendar repository imports.
  • Add the repository and stable stream fields inside the screen's state class.
  • Update the existing initState method until the relevant section matches this code:
  final CalendarRepository _repository = CalendarRepository();

  late final Stream<List<CalendarEvent>> _eventsStream;
  late DateTime _selectedDay;

  @override
  void initState() {
    super.initState();
    _selectedDay = DateTime.now();
    _eventsStream = _repository.watchEvents();
  }

Why create the stream in initState?

The state object creates _eventsStream once. Later widget rebuilds reuse that same listener.

The selected date also receives its initial value once. User selections can then update it through the existing date picker.

  • Save the calendar screen.
  • Check the file diagnostics. You should see no undefined references for CalendarRepository or CalendarEvent.
  • Add the selected-date helpers inside the screen's state class:
  bool _isSameDay(DateTime first, DateTime second) {
    return first.year == second.year &&
        first.month == second.month &&
        first.day == second.day;
  }

  DateTime _selectedDayAtNoon() {
    return DateTime(
      _selectedDay.year,
      _selectedDay.month,
      _selectedDay.day,
      12,
    );
  }

  List<CalendarEvent> _eventsForSelectedDay(List<CalendarEvent> events) {
    return events
        .where((event) => _isSameDay(event.startAt, _selectedDay))
        .toList(growable: false);
  }

How does date filtering work?

  • The day comparison ignores time values by checking the year and month and day.
  • New events use noon on the selected date. This keeps event creation tied to the visible selection.
  • The list filter keeps only events whose startAt value matches _selectedDay.
  • Save the calendar screen.
  • Check the file diagnostics. You should see no errors in the three date helpers.
  • Refactor the existing calendar rendering into a method named _buildCalendar(List<CalendarEvent> events).
  • Keep the existing CalendarDatePicker inside that method.
  • Use _eventsForSelectedDay(events) as the source for the visible event rows.
  • Show No events for this date. when the filtered list is empty.
  • Replace the old one-shot event loader with this stream builder:
          return StreamBuilder<List<CalendarEvent>>(
            stream: _eventsStream,
            builder: (context, eventSnapshot) {
              if (eventSnapshot.hasError) {
                return Center(
                  child: Padding(
                    padding: const EdgeInsets.all(24),
                    child: SelectableText(
                      'Event stream failed: ${eventSnapshot.error}',
                      textAlign: TextAlign.center,
                    ),
                  ),
                );
              }

              if (!eventSnapshot.hasData) {
                return const Center(child: CircularProgressIndicator());
              }

              return _buildCalendar(eventSnapshot.data!);
            },
          );

What does StreamBuilder render?

  • The builder listens to the stable _eventsStream.
  • A stream error becomes selectable text so permission or query failures stay visible.
  • A progress indicator covers the wait for the first snapshot.
  • A successful snapshot sends its typed events into the calendar UI.
  • Save the calendar screen.
  • Check the file diagnostics. You should see no undefined reference for _buildCalendar.
  • Find the direct Firestore write in the existing add-event flow.
  • Keep the existing title validation and controller values.
  • Name the trimmed controller values title and notes.
  • Replace the direct write with this awaited repository call:
        await _repository.createEvent(
          title: title,
          notes: notes,
          startAt: _selectedDayAtNoon(),
        );

Why await the repository write?

The dialog waits for Firestore to accept or reject the write. The existing UI can then show a success message or surface the failure.

The stream delivers the created document back to every listening session. No manual list refresh is needed.

  • Save the calendar screen.
  • Check the file diagnostics. You should see no errors on the repository call.
  • Stop the earlier app process if it is still active.
  • Run the updated app from the existing VS Code terminal with this command:
flutter run
  • Select Chrome if Flutter prompts you for a target.
  • Sign in through the existing Part 1 authentication flow.
  • Confirm that the calendar and date picker load.

Your realtime test is ready

The first browser session now holds the stable listener. A second signed-in session lets you prove that the listener reacts to a remote client write.

  • Open the running app address in a second Chrome browser session.
  • Sign in to the second session with the same account.
  • Select the same date in the first session.
  • Select that date in the second session.
  • Click Add event in the first session.
  • Enter Realtime sync test in the Title field.
  • Enter Created in session one in the Notes field.

Before you save, do you expect the second session to need a refresh?

  • Click Save in the first session.
  • Watch the event list in the second session without refreshing it.

You should see Realtime sync test appear in the second session. That is the first realtime win: one repository stream now keeps both signed-in calendars synchronized.

Event missing from the second session?

Confirm that both sessions use the same Firebase account. Confirm that both sessions have the same calendar date selected.

If the first session creates the event but the second session stays stale, check that the screen passes the field created in initState to StreamBuilder.

Help me debug why my Firestore event stream is not updating the second browser session.

Realtime creation now works across browser sessions. Next, you will extend the same repository with editing and confirmed deletion.

Complete Update and Delete

Your stable Cloud Firestore stream already carries new events between signed-in browser sessions. That visible result proves the calendar can react to database changes without a refresh.

Existing events still need a safe way to be corrected or removed. This step completes CRUD with awaited updates, reusable form validation, confirmation before deletion, and visible feedback after every write.

In this step, get ready to:
  • Add awaited update and delete operations to the calendar repository.
  • Reuse the event form for creation and editing with nonblank title validation.
  • Confirm deletions and prove that both browser sessions update in realtime.
Add awaited update and delete operations

The repository pattern keeps database operations outside the calendar widget. Awaiting each operation also gives the interface a chance to report a failed write before it closes the workflow.

  • In Visual Studio Code, select lib/services/calendar_repository.dart from the project file tree.
  • Add these methods inside CalendarRepository after createEvent by copying the code below:
  Future<void> updateEvent({
    required String eventId,
    required String title,
    required String notes,
  }) async {
    await _eventsRef.doc(eventId).update({
      'title': title,
      'notes': notes,
      'updatedAt': FieldValue.serverTimestamp(),
    });
  }

  Future<void> deleteEvent(String eventId) async {
    await _eventsRef.doc(eventId).delete();
  }

What Do These Operations Add?

  • The eventId identifies the existing event document that the repository changes.
  • The updateEvent method changes the title and notes while recording a server-generated update time.
  • The deleteEvent method removes the document with the same event ID.
  • Each await keeps the operation active until Cloud Firestore reports success or failure.
  • Save lib/services/calendar_repository.dart.
  • Switch back to the running calendar in Chrome.

You should still see the calendar after the app rebuilds. Good progress. Your data layer now has the two operations needed to finish the event lifecycle.

Seeing a Build Error?

Check that both methods sit inside CalendarRepository. Confirm that the class still has its final closing brace after deleteEvent.

Make sure FieldValue remains available through the existing Cloud Firestore import. Help me diagnose the repository build error.

Reuse one form for create and edit

Create and edit collect the same title and notes. One dialog can handle both paths when an optional event supplies the existing values.

  • In Visual Studio Code, select lib/screens/home_screen.dart from the project file tree.
  • Match the opening of _showEventDialog to this reference snippet while keeping the rest of the method below it:
  Future<void> _showEventDialog({CalendarEvent? event}) async {
    final titleController = TextEditingController(text: event?.title ?? '');
    final notesController = TextEditingController(text: event?.notes ?? '');
    final formKey = GlobalKey<FormState>();

How Does the Dialog Know Its Mode?

A missing event gives both controllers empty text for creation. An existing event preloads its title and notes for editing.

The formKey provides one validation checkpoint before either write path runs.

  • Inside the event row's ListTile, set its tap action to match this reference line:
          onTap: () => _showEventDialog(event: event),

What Does the Tap Action Do?

The selected CalendarEvent now enters the same dialog used by the add button. Its existing values become the form's starting values.

  • Save lib/screens/home_screen.dart.
  • Tap an existing event in the running calendar.

You should see the selected event's title and notes already filled into the dialog. Select Cancel to close it without writing.

Are the Edit Fields Empty?

Check that the row passes its own event into _showEventDialog. Confirm that both controllers use the optional event values.

Help me fix the empty edit form.

The form now has the correct starting values. Next, it needs to reject blank titles before the dialog returns a save result.

  • Replace the current dialog content with this form by copying the code below:
          content: Form(
            key: formKey,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                TextFormField(
                  controller: titleController,
                  autofocus: true,
                  decoration: const InputDecoration(labelText: 'Title'),
                  validator: (value) {
                    if (value == null || value.trim().isEmpty) {
                      return 'Enter an event title.';
                    }
                    return null;
                  },
                ),
                TextFormField(
                  controller: notesController,
                  decoration: const InputDecoration(labelText: 'Notes'),
                  maxLines: 3,
                ),
              ],
            ),
          ),

What Does the Form Validate?

The validator trims the title before checking whether it is empty. Notes remain optional through the same create and edit form.

The existing event date stays outside these fields. Editing text therefore preserves the event's original date.

  • Match the dialog's FilledButton to this validation-aware save action:
            FilledButton(
              onPressed: () {
                if (formKey.currentState!.validate()) {
                  Navigator.pop(dialogContext, true);
                }
              },
              child: const Text('Save'),
            ),

When Does the Dialog Close?

The save button asks every form field to validate first. The dialog returns true only when the title passes that check.

  • Save lib/screens/home_screen.dart.
  • Select Add event in the running calendar.
  • Leave the title empty.
  • Select Save.

You should see Enter an event title. beneath the title field. The dialog should remain open until you enter a nonblank title or select Cancel.

Does a Blank Title Still Save?

Confirm that the fields sit inside the Form that uses formKey. Check that the save callback calls the form's validation method before closing the dialog.

Help me repair the title validation.

Validation now protects both modes. The remaining dialog changes label the mode clearly and route a valid submission to the correct repository operation.

  • Set the dialog title to match this reference line:
          title: Text(event == null ? 'Create event' : 'Edit event'),

Why Change the Dialog Title?

The optional event determines the label the learner sees. The same dialog now communicates whether it is creating a new document or editing an existing one.

  • Match the cancellation and controller cleanup section beneath showDialog to this reference snippet:
    if (shouldSave != true) {
      titleController.dispose();
      notesController.dispose();
      return;
    }

    final title = titleController.text.trim();
    final notes = notesController.text.trim();
    titleController.dispose();
    notesController.dispose();

Why Read the Fields Before Disposal?

A cancelled dialog disposes its controllers before returning. A confirmed dialog copies the trimmed values first so the write can continue safely.

  • Save lib/screens/home_screen.dart.
  • Tap an existing event in the running calendar.

You should see Edit event at the top of the prefilled dialog. Select Cancel to confirm that cancellation leaves the event unchanged.

  • Replace the dialog's current write and feedback section with this create-or-update branch:
    try {
      if (event == null) {
        await _repository.createEvent(
          title: title,
          notes: notes,
          startAt: _selectedDayAtNoon(),
        );
      } else {
        await _repository.updateEvent(
          eventId: event.id,
          title: title,
          notes: notes,
        );
      }

      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(event == null ? 'Event created.' : 'Event updated.')),
      );
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Write failed: $error')),
      );
    }

How Does One Dialog Write Both Modes?

  • A missing event sends the form values through createEvent with the selected date.
  • An existing event sends its ID and edited text through updateEvent.
  • The update branch does not send a new date. The existing event therefore remains on its original date.
  • The success and failure snack bars run after the awaited repository operation reports its result.
  • Save lib/screens/home_screen.dart.
  • Tap an existing event in the first browser session.
  • Replace its title with Realtime edit verified.
  • Select Save.
  • Switch to the second signed-in browser session.

You should see Event updated. in the first session. The title Realtime edit verified should appear in the second session without a refresh.

Does the Edit Create a Duplicate?

Check that the edit branch calls updateEvent with event.id. Confirm that the create branch runs only when event is null.

Help me fix the create-or-update branch.

Confirm destructive actions

Deletion permanently removes an event document from the current collection. A confirmation dialog names the event before the repository receives its ID.

  • Add the confirmation portion of _confirmDelete inside _HomeScreenState by copying this code:
  Future<void> _confirmDelete(CalendarEvent event) async {
    final shouldDelete = await showDialog<bool>(
      context: context,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('Delete event?'),
          content: Text('Delete "${event.title}"?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(dialogContext, false),
              child: const Text('Cancel'),
            ),
            FilledButton(
              onPressed: () => Navigator.pop(dialogContext, true),
              child: const Text('Delete'),
            ),
          ],
        );
      },
    );

    if (shouldDelete != true) return;

How Does Confirmation Protect the Event?

The dialog includes the event title so you can verify the target before continuing. Cancelling returns immediately without calling the repository.

  • Complete _confirmDelete by placing this write and feedback section directly beneath the confirmation check:
    try {
      await _repository.deleteEvent(event.id);
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Event deleted.')),
      );
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Delete failed: $error')),
      );
    }
  }

When Does Deletion Finish?

The method waits for deleteEvent before showing success. A failed write keeps the calendar available and surfaces the failure through a snack bar.

  • Add this trailing delete button to the event row's ListTile by copying the code below:
          trailing: IconButton(
            tooltip: 'Delete event',
            onPressed: () => _confirmDelete(event),
            icon: const Icon(Icons.delete_outline),
          ),

What Does the Row Now Offer?

Tapping the row opens the edit form. Selecting its delete icon opens the separate confirmation workflow for that exact event.

Before you test, do you expect the second browser session to reflect both operations without a refresh?

  • Save lib/screens/home_screen.dart.
  • Switch to the first signed-in browser session.
  • Tap one event on the selected date.
  • Replace its title with Final realtime edit.
  • Select Save.
  • Switch to the second signed-in browser session.

You should see Final realtime edit in the second session without refreshing. The stable stream has delivered the modified document.

  • Return to the first browser session.
  • Select the delete icon beside a different event.
  • Confirm that the dialog names the event you selected.
  • Select Delete.
  • Switch to the second browser session.

You should see Event deleted. in the first session. The deleted event should disappear from the second session without a refresh.

Does the Event Stay Visible?

Confirm that the delete button passes the row's event into _confirmDelete. Check that the confirmed branch calls deleteEvent with event.id.

If one session updates while the other stays stale, confirm that both sessions remain signed into the same account and display the same selected date. Help me troubleshoot the realtime deletion.

That completes the calendar's create, read, update, and confirmed delete workflow. Next, you will move events beneath a calendar parent so the data model can represent members.

Introduce the Shared Calendar Boundary

Your Flutter calendar now supports realtime creation, editing, and confirmed deletion across two browser sessions. Its current user-owned path still treats every calendar as a private list belonging to one account.

A shared calendar needs a parent document that records its owner and members. In this step, you will reshape the Cloud Firestore hierarchy around that boundary while leaving the existing event documents untouched.

In this step, get ready to:
  • Point the repository at a calendar parent with a nested events collection.
  • Create the owner-member parent once before starting the event stream.
  • Run the app against the unchanged rules and capture the expected permission failure.
Point the repository at a calendar parent

The repository pattern gives the data hierarchy one home. The signed-in UID becomes calendarId. Event documents then live under that calendar instead of directly under the user.

  • Switch back to lib/services/calendar_repository.dart in Visual Studio Code.
  • Locate the existing _eventsRef getter.
  • Replace that getter with the parent and child references below:
  DocumentReference<Map<String, dynamic>> get _calendarRef {
    final user = _requireUser();
    return _firestore.collection('calendars').doc(user.uid);
  }

  CollectionReference<Map<String, dynamic>> get _eventsRef {
    return _calendarRef.collection('events');
  }

How does the new path work?

  • The _calendarRef getter uses the signed-in user's UID as the calendar document ID.
  • The _eventsRef getter targets the events subcollection beneath that parent.
  • Existing create, read, update, and delete methods now follow the new reference automatically.

The parent also needs enough metadata for future authorization checks. ensureCalendar() merges those fields so repeated setup calls preserve members already in the array.

  • Add ensureCalendar() immediately above watchEvents() by copying this method:
  Future<void> ensureCalendar() async {
    final user = _requireUser();

    await _calendarRef.set(
      {
        'name': 'My calendar',
        'ownerId': user.uid,
        'memberIds': FieldValue.arrayUnion([user.uid]),
        'updatedAt': FieldValue.serverTimestamp(),
      },
      SetOptions(merge: true),
    );
  }

What does this method protect?

  • The ownerId field records who controls the calendar.
  • The memberIds field starts with the owner's UID.
  • FieldValue.arrayUnion([user.uid]) keeps existing members when the owner is added again.
  • SetOptions(merge: true) updates only the supplied parent fields.
  • Save lib/services/calendar_repository.dart.

Before you run the app, predict whether the old rules will recognize the new calendar path.

  • Test the redirected event listener by running this command from the Part 1 project folder:
flutter run

What does this check prove?

The command rebuilds the app with the repository's new path. Select Chrome if Flutter asks you to choose a target device.

You will see the app report an event stream failure that mentions missing permission. This is the intended shortfall. The listener reached the new hierarchy, but the deployed rules still describe the old user-owned path.

Seeing a compile error instead?

Check that both getters remain inside CalendarRepository. Confirm that ensureCalendar() closes before watchEvents() begins.

Share the compiler output without any credentials when you ask for help checking the repository edit.

Use the tabs below to confirm that every existing CRUD method now works through _eventsRef.

✔️ Awesome, I've got everything!

Your repository now has one parent reference and one nested event reference. Keep the permission failure visible while you connect parent setup to the screen.

ⓧ I'd like to double check the full code

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

import '../models/calendar_event.dart';

class CalendarRepository {
  CalendarRepository({
    FirebaseFirestore? firestore,
    FirebaseAuth? auth,
  })  : _firestore = firestore ?? FirebaseFirestore.instance,
        _auth = auth ?? FirebaseAuth.instance;

  final FirebaseFirestore _firestore;
  final FirebaseAuth _auth;

  User _requireUser() {
    final user = _auth.currentUser;
    if (user == null) {
      throw StateError('A signed-in user is required.');
    }
    return user;
  }

  DocumentReference<Map<String, dynamic>> get _calendarRef {
    final user = _requireUser();
    return _firestore.collection('calendars').doc(user.uid);
  }

  CollectionReference<Map<String, dynamic>> get _eventsRef {
    return _calendarRef.collection('events');
  }

  Future<void> ensureCalendar() async {
    final user = _requireUser();

    await _calendarRef.set(
      {
        'name': 'My calendar',
        'ownerId': user.uid,
        'memberIds': FieldValue.arrayUnion([user.uid]),
        'updatedAt': FieldValue.serverTimestamp(),
      },
      SetOptions(merge: true),
    );
  }

  Stream<List<CalendarEvent>> watchEvents() {
    return _eventsRef
        .orderBy('startAt')
        .snapshots()
        .map(
          (snapshot) => snapshot.docs
              .map(CalendarEvent.fromSnapshot)
              .toList(growable: false),
        );
  }

  Future<void> createEvent({
    required String title,
    required String notes,
    required DateTime startAt,
  }) async {
    final user = _requireUser();

    await _eventsRef.add({
      'title': title,
      'notes': notes,
      'startAt': Timestamp.fromDate(startAt),
      'createdBy': user.uid,
      'createdAt': FieldValue.serverTimestamp(),
      'updatedAt': FieldValue.serverTimestamp(),
    });
  }

  Future<void> updateEvent({
    required String eventId,
    required String title,
    required String notes,
  }) async {
    await _eventsRef.doc(eventId).update({
      'title': title,
      'notes': notes,
      'updatedAt': FieldValue.serverTimestamp(),
    });
  }

  Future<void> deleteEvent(String eventId) async {
    await _eventsRef.doc(eventId).delete();
  }
}

What should you compare?

Check the reference getters and ensureCalendar() first. Your existing CRUD methods should still use _eventsRef so the path change remains centralized.

Bootstrap the owner-member calendar

The screen needs one setup future and one stable event stream. Creating both in initState() keeps rebuilds from restarting either asynchronous operation.

  • Switch back to lib/screens/home_screen.dart.
  • Replace the state fields and initState() section with the following code:
  late final Future<void> _calendarReady;
  late final Stream<List<CalendarEvent>> _eventsStream;
  late DateTime _selectedDay;

  @override
  void initState() {
    super.initState();
    _selectedDay = DateTime.now();
    _calendarReady = _repository.ensureCalendar();
    _eventsStream = _repository.watchEvents();
  }

Why create both values here?

  • The _calendarReady future represents the single parent setup attempt.
  • The _eventsStream value preserves one realtime listener across widget rebuilds.
  • The screen can now wait for the parent operation before rendering event data.

A FutureBuilder handles parent setup before the existing StreamBuilder renders events. It also gives setup failures a visible place in the interface.

  • Replace the body: section in build() with the nested FutureBuilder<void> and StreamBuilder<List<CalendarEvent>> structure shown in the full-file reference below.

✔️ Awesome, I've got everything!

Your setup future now gates the existing event stream. Save the file before testing the final state.

ⓧ I'd like to double check the full code

import 'package:flutter/material.dart';

import '../models/calendar_event.dart';
import '../services/calendar_repository.dart';

class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});

  @override
  State<HomeScreen> createState() => _HomeScreenState();
}

class _HomeScreenState extends State<HomeScreen> {
  final CalendarRepository _repository = CalendarRepository();

  late final Future<void> _calendarReady;
  late final Stream<List<CalendarEvent>> _eventsStream;
  late DateTime _selectedDay;

  @override
  void initState() {
    super.initState();
    _selectedDay = DateTime.now();
    _calendarReady = _repository.ensureCalendar();
    _eventsStream = _repository.watchEvents();
  }

  bool _isSameDay(DateTime first, DateTime second) {
    return first.year == second.year &&
        first.month == second.month &&
        first.day == second.day;
  }

  DateTime _selectedDayAtNoon() {
    return DateTime(
      _selectedDay.year,
      _selectedDay.month,
      _selectedDay.day,
      12,
    );
  }

  List<CalendarEvent> _eventsForSelectedDay(List<CalendarEvent> events) {
    return events
        .where((event) => _isSameDay(event.startAt, _selectedDay))
        .toList(growable: false);
  }

  Future<void> _showEventDialog({CalendarEvent? event}) async {
    final titleController = TextEditingController(text: event?.title ?? '');
    final notesController = TextEditingController(text: event?.notes ?? '');
    final formKey = GlobalKey<FormState>();

    final shouldSave = await showDialog<bool>(
      context: context,
      builder: (dialogContext) {
        return AlertDialog(
          title: Text(event == null ? 'Create event' : 'Edit event'),
          content: Form(
            key: formKey,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                TextFormField(
                  controller: titleController,
                  autofocus: true,
                  decoration: const InputDecoration(labelText: 'Title'),
                  validator: (value) {
                    if (value == null || value.trim().isEmpty) {
                      return 'Enter an event title.';
                    }
                    return null;
                  },
                ),
                TextFormField(
                  controller: notesController,
                  decoration: const InputDecoration(labelText: 'Notes'),
                  maxLines: 3,
                ),
              ],
            ),
          ),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(dialogContext, false),
              child: const Text('Cancel'),
            ),
            FilledButton(
              onPressed: () {
                if (formKey.currentState!.validate()) {
                  Navigator.pop(dialogContext, true);
                }
              },
              child: const Text('Save'),
            ),
          ],
        );
      },
    );

    if (shouldSave != true) {
      titleController.dispose();
      notesController.dispose();
      return;
    }

    final title = titleController.text.trim();
    final notes = notesController.text.trim();
    titleController.dispose();
    notesController.dispose();

    try {
      if (event == null) {
        await _repository.createEvent(
          title: title,
          notes: notes,
          startAt: _selectedDayAtNoon(),
        );
      } else {
        await _repository.updateEvent(
          eventId: event.id,
          title: title,
          notes: notes,
        );
      }

      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(event == null ? 'Event created.' : 'Event updated.')),
      );
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Write failed: $error')),
      );
    }
  }

  Future<void> _confirmDelete(CalendarEvent event) async {
    final shouldDelete = await showDialog<bool>(
      context: context,
      builder: (dialogContext) {
        return AlertDialog(
          title: const Text('Delete event?'),
          content: Text('Delete "${event.title}"?'),
          actions: [
            TextButton(
              onPressed: () => Navigator.pop(dialogContext, false),
              child: const Text('Cancel'),
            ),
            FilledButton(
              onPressed: () => Navigator.pop(dialogContext, true),
              child: const Text('Delete'),
            ),
          ],
        );
      },
    );

    if (shouldDelete != true) return;

    try {
      await _repository.deleteEvent(event.id);
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Event deleted.')),
      );
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Delete failed: $error')),
      );
    }
  }

  Widget _buildEventList(List<CalendarEvent> events) {
    final selectedEvents = _eventsForSelectedDay(events);

    if (selectedEvents.isEmpty) {
      return const Center(child: Text('No events for this date.'));
    }

    return ListView.separated(
      itemCount: selectedEvents.length,
      separatorBuilder: (_, __) => const Divider(height: 1),
      itemBuilder: (context, index) {
        final event = selectedEvents[index];
        final detail = event.notes.isEmpty
            ? 'ID: ${event.id}'
            : '${event.notes}\nID: ${event.id}';

        return ListTile(
          title: Text(event.title),
          subtitle: Text(detail),
          isThreeLine: event.notes.isNotEmpty,
          onTap: () => _showEventDialog(event: event),
          trailing: IconButton(
            tooltip: 'Delete event',
            onPressed: () => _confirmDelete(event),
            icon: const Icon(Icons.delete_outline),
          ),
        );
      },
    );
  }

  Widget _buildCalendar(List<CalendarEvent> events) {
    return Column(
      children: [
        SizedBox(
          height: 330,
          child: CalendarDatePicker(
            key: ValueKey(_selectedDay),
            initialDate: _selectedDay,
            firstDate: DateTime(2020),
            lastDate: DateTime(2100),
            onDateChanged: (day) {
              setState(() {
                _selectedDay = day;
              });
            },
          ),
        ),
        const Divider(height: 1),
        Padding(
          padding: const EdgeInsets.all(12),
          child: Text(
            'Events for ${_selectedDay.month}/${_selectedDay.day}/${_selectedDay.year}',
            style: Theme.of(context).textTheme.titleMedium,
          ),
        ),
        Expanded(child: _buildEventList(events)),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Secure Realtime Calendar')),
      body: FutureBuilder<void>(
        future: _calendarReady,
        builder: (context, calendarSnapshot) {
          if (calendarSnapshot.hasError) {
            return Center(
              child: Padding(
                padding: const EdgeInsets.all(24),
                child: SelectableText(
                  'Calendar setup failed: ${calendarSnapshot.error}',
                  textAlign: TextAlign.center,
                ),
              ),
            );
          }

          if (calendarSnapshot.connectionState != ConnectionState.done) {
            return const Center(child: CircularProgressIndicator());
          }

          return StreamBuilder<List<CalendarEvent>>(
            stream: _eventsStream,
            builder: (context, eventSnapshot) {
              if (eventSnapshot.hasError) {
                return Center(
                  child: Padding(
                    padding: const EdgeInsets.all(24),
                    child: SelectableText(
                      'Event stream failed: ${eventSnapshot.error}',
                      textAlign: TextAlign.center,
                    ),
                  ),
                );
              }

              if (!eventSnapshot.hasData) {
                return const Center(child: CircularProgressIndicator());
              }

              return _buildCalendar(eventSnapshot.data!);
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _showEventDialog,
        icon: const Icon(Icons.add),
        label: const Text('Add event'),
      ),
    );
  }
}

Where should you focus?

Compare the fields and initState() first. Then compare only the body: section inside build(). The existing dialogs and calendar UI remain unchanged.

  • Save lib/screens/home_screen.dart.
Observe the permission boundary

The app now creates the parent before starting its event listener. The deployed rules remain unchanged, which lets the database evaluate the new hierarchy exactly as it stands.

Before the final run, predict which operation the old rules will reject first.

  • Run the updated app from the Part 1 project folder with this command:
flutter run

What does the final run test?

The app first attempts ensureCalendar() through _calendarReady. The event listener starts only if that parent operation completes.

You should see Calendar setup failed: followed by a permission-related error. If the old rules happen to allow the parent but reject its child collection, you will instead see Event stream failed: followed by the permission-related error.

Why is this failure valuable?

The visible failure proves that access control lives in the database. Changing the client path did not silently grant access to the new calendar hierarchy.

Your original user-owned event documents remain untouched. The repository now points at a different path, so your rollback data stays available.

Still seeing the original calendar?

Confirm that _eventsRef returns _calendarRef.collection('events'). Confirm that the screen assigns _calendarReady from _repository.ensureCalendar().

Use the full-file references above when you ask for help tracing why the app still reads the old event path.

You have now made the authorization gap visible without deleting the original data. Next up, you will publish member-aware rules and prove that the owner is allowed while an unrelated UID is denied.

Enforce and Prove Member Security

Your repository now targets a calendar parent that carries its membership list. The old rules reject this hierarchy, which proves that the database is enforcing its boundary.

This step resolves that permission failure with Cloud Firestore Security Rules that consult the parent calendar. You will prove that an owner-member succeeds while an outsider fails.

In this step, get ready to:
  • Replace the old path rules with rules that authorize access through calendar membership.
  • Publish the rules before restoring realtime CRUD for the owner-member.
  • Prove an allowed owner request before proving a denied outsider request.
Replace the path rules

The new hierarchy needs authorization at two levels. Parent rules protect calendar membership while child rules apply that membership to every event operation.

  • Return to the Firebase Console from earlier.
  • Select Databases & Storage from the console navigation.
  • Select Firestore.
  • Select the Rules tab to reach the editor that contains the old Part 1 rules.

A local rules file gives you an exact copy of the deployed policy. It also makes the security boundary easier to review before publishing.

  • Switch back to the Part 1 project folder in Visual Studio Code.
  • Create firestore.rules inside that folder with the New File button in the Explorer sidebar.
  • Build the valid rules shell by pasting this code into firestore.rules:
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isSignedIn() {
      return request.auth != null;
    }

    function isCalendarMember(calendarId) {
      return isSignedIn()
        && request.auth.uid in get(
          /databases/$(database)/documents/calendars/$(calendarId)
        ).data.memberIds;
    }
  }
}

What do the helper functions protect?

  • The rules file uses rules version 2 for this policy.
  • The isSignedIn() function rejects requests without an authenticated user.
  • The isCalendarMember() function reads the parent calendar before checking its memberIds array.
  • Save firestore.rules.
  • Confirm that firestore.rules is listed in the Explorer sidebar.

Cannot find the rules file?

Check that firestore.rules sits beside the files at the top level of the Part 1 project folder. Move it there if it was created inside lib.

Ask for help with the file location.

The helper functions define who counts as a member. The next block defines what that member can do to the calendar parent and its event documents.

  • Find the closing brace immediately after the isCalendarMember() function in firestore.rules.
  • Place your cursor on the next line before the closing brace for the database document match.
  • Add the calendar rules by pasting this block:
    match /calendars/{calendarId} {
      allow create: if isSignedIn()
        && request.resource.data.ownerId == request.auth.uid
        && request.auth.uid in request.resource.data.memberIds;

      allow read: if isSignedIn()
        && request.auth.uid in resource.data.memberIds;

      allow update: if isSignedIn()
        && request.auth.uid == resource.data.ownerId
        && request.resource.data.ownerId == resource.data.ownerId
        && request.auth.uid in request.resource.data.memberIds;

      allow delete: if isSignedIn()
        && request.auth.uid == resource.data.ownerId;

      match /events/{eventId} {
        allow read: if isCalendarMember(calendarId);

        allow create: if isCalendarMember(calendarId)
          && request.resource.data.createdBy == request.auth.uid;

        allow update: if isCalendarMember(calendarId)
          && request.resource.data.createdBy == resource.data.createdBy;

        allow delete: if isCalendarMember(calendarId);
      }
    }

What do the calendar rules enforce?

  • Calendar creation requires the signed-in user to become the owner.
  • Calendar creation also requires that owner to appear in memberIds.
  • Calendar updates preserve the stored ownerId.
  • Nested event operations call isCalendarMember() before accessing the child document.
  • Event creation binds createdBy to the authenticated UID.
  • Event updates preserve the original createdBy value.
  • Save firestore.rules.
  • Compare your file with the complete reference below.

✔️ Awesome, I've got everything!

Your rules file contains the two helper functions plus the nested calendar policy. Make sure you saved firestore.rules before continuing.

ⓧ I'd like to double check the full code

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    function isSignedIn() {
      return request.auth != null;
    }

    function isCalendarMember(calendarId) {
      return isSignedIn()
        && request.auth.uid in get(
          /databases/$(database)/documents/calendars/$(calendarId)
        ).data.memberIds;
    }

    match /calendars/{calendarId} {
      allow create: if isSignedIn()
        && request.resource.data.ownerId == request.auth.uid
        && request.auth.uid in request.resource.data.memberIds;

      allow read: if isSignedIn()
        && request.auth.uid in resource.data.memberIds;

      allow update: if isSignedIn()
        && request.auth.uid == resource.data.ownerId
        && request.resource.data.ownerId == resource.data.ownerId
        && request.auth.uid in request.resource.data.memberIds;

      allow delete: if isSignedIn()
        && request.auth.uid == resource.data.ownerId;

      match /events/{eventId} {
        allow read: if isCalendarMember(calendarId);

        allow create: if isCalendarMember(calendarId)
          && request.resource.data.createdBy == request.auth.uid;

        allow update: if isCalendarMember(calendarId)
          && request.resource.data.createdBy == resource.data.createdBy;

        allow delete: if isCalendarMember(calendarId);
      }
    }
  }
}

Why does the parent lookup matter?

Each event sits below a calendar document. Looking up that parent gives every child event the same membership boundary.

The database now makes the access decision from stored membership data. A hidden button cannot grant access by itself.

  • Return to the Rules editor in Firebase Console.
  • Select all of the existing Part 1 rules in the editor.
  • Replace the selected rules by pasting the exact contents of firestore.rules.

The console editor should now show the same two helper functions and nested calendar match as your local file.

Rules do not match the reference?

Check that match /events/{eventId} remains inside match /calendars/{calendarId}. Confirm that every opening brace has a closing brace.

Ask for help comparing the rules structure.

Publish and recover the calendar

The console editor only holds a draft until you publish it. New queries can take up to one minute to receive the deployed rules.

Restarting the Flutter app creates a fresh listener after deployment. That fresh request tests whether the member-aware policy closes the permission gap.

  • Click Publish above the rules editor.
  • Wait up to one minute for new queries to receive the updated rules.
  • Return to the VS Code terminal that is running the app.
  • Stop the current Flutter process with the terminal interrupt control.

Before you rerun the app, predict whether the calendar will load or repeat the permission failure.

  • Restart the app from the Part 1 project folder by running this command:
flutter run

What should happen now?

The published rules allow ensureCalendar() to create or merge the owner-bound parent. The event listener can then read the nested collection because the signed-in UID belongs to memberIds.

  • Select Chrome if Flutter asks you to choose a device.
  • Sign in through the existing Part 1 flow.
  • Confirm that the calendar loads without the previous permission failure.
  • Switch back to the second browser session from earlier.
  • Sign in to that session with the same account if its session has expired.

That permission wall has completed its job. Your owner-member can now reach the shared calendar hierarchy through the database rules.

Still seeing a permission failure?

Confirm that you clicked Publish after replacing the rules. Wait one minute before restarting the app again.

Check that the signed-in user's UID appears in the parent calendar's memberIds array.

Ask for help tracing the rejected request.

A successful load proves that reads work. The full boundary also needs to permit owner-member writes while the realtime stream remains active.

  • Select a date in the first browser session.
  • Create a fresh event named Owner rules check with notes set to Keep for Rules Playground.
  • Confirm that Owner rules check appears in the second session without a refresh.

Realtime creation now works through the parent membership check. The second session proves that the listener received the new child document.

  • Repeat the add flow to create a second event named Delete me.
  • Select the Owner rules check event in the first session.
  • Replace its title with Owner update allowed.
  • Click Save.
  • Confirm that the second session shows Owner update allowed without a refresh.
  • Click the delete icon beside Delete me in the first session.
  • Click Delete in the confirmation dialog.
  • Confirm that Delete me disappears from the second session without a refresh.

Create, update, and confirmed delete now pass through the same member-aware boundary. Both browser sessions reflect every accepted write.

Prove both sides of the boundary

The Rules Playground simulates a request with a chosen authenticated identity. Using one document path for both tests isolates membership as the only changing condition.

Firebase Authentication gives each signed-in user a UID. The remaining event exposes its document ID directly in the calendar.

  • Return to the Owner update allowed event in the first browser session.
  • Record the ID shown beneath that event here: your event ID.
  • Select Authentication in Firebase Console.
  • Locate the account signed in to both browser sessions.
  • Record that account's UID here: your owner UID.

What do these values identify?

The owner UID identifies the authenticated member. It also identifies the parent calendar because this project uses that UID as calendarId.

The event ID identifies one child document below that calendar. Both tests target this exact document.

  • Return to the Rules tab in Firebase Console.
  • Click Rules Playground.
  • Choose an authenticated get simulation.
  • Enter calendars/your owner UID/events/your event ID as the document path.
  • Set the simulated UID to your owner UID.

Before you run the simulation, predict whether the owner UID satisfies the parent membership check.

  • Click Run.

You should see that the authenticated get is allowed. The owner UID belongs to the parent calendar's memberIds array.

  • Change only the simulated UID to non-member-test.

Before you rerun the simulation, predict whether the unchanged event path can be read by this unrelated UID.

  • Click Run again.

You should see that the same authenticated get is denied. The unchanged path proves that membership caused the different result.

Owner denied or outsider allowed?

If the owner is denied, compare the simulated UID with the parent calendar's memberIds value. Also confirm that the event ID belongs to the same owner calendar.

If non-member-test is allowed, confirm that the deployed child rules call isCalendarMember(calendarId).

Ask for help interpreting the authorization results.

You have proved the complete security boundary from both sides. The owner-member keeps full realtime CRUD while the unrelated UID remains outside the calendar.

Secret mission

Prove a Second Member Is Allowed

Your owner test proves that the calendar owner can access an event. Add a second member UID and run a three-way authorization test to prove that access follows membership while an unrelated UID remains blocked.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your calendar resources available, pause the local app, or delete the project data. The Firebase Spark plan keeps this project at no cost while it remains within the no-cost quotas.

Resources you used:

  • One Cloud Firestore calendar parent document at calendars/<owner-uid> with memberIds containing the owner UID plus member-demo.
  • Zero or more nested event documents under calendars/<owner-uid>/events.
  • The deployed member-aware Security Rules that protect the project database.

Keep everything running

No cleanup is required if you want this calendar to become the base for a future invitation or collaboration project. Your database remains ready for realtime CRUD and membership tests.

  • Retain calendars/<owner-uid> as the member-aware calendar boundary.
  • Retain the nested event documents for future realtime CRUD tests.
  • Keep the deployed member-aware rules to preserve database-enforced authorization.
  • Stay within the Spark plan quotas to keep the project at no cost.

Pause - I'll come back to this later

Pausing stops the local Flutter app while preserving its cloud state. Your calendar data and rules remain available when you return.

  • Switch back to the Visual Studio Code terminal that is running flutter run.
  • Press Ctrl+C to stop the local app process.
  • Sign out of each test browser session.
  • Keep the calendar parent document for your next session.
  • Keep the nested events for your next session.
  • Leave the deployed member-aware rules active.

Delete - I don't want to use this again

Remove the calendar data when you no longer need this project state. Cloud Firestore does not delete nested event documents when you delete their parent document.

  • Switch back to the Firestore data view from the Secret Mission.
  • Open the calendars collection.
  • Select the calendars/<owner-uid> document.
  • Open its events subcollection.
  • Delete every event document with the document deletion control.
  • Confirm that the events subcollection no longer lists any documents.

That's the careful part complete. No event documents are left behind.

  • Return to the calendars/<owner-uid> document.
  • Delete the calendar parent with the document deletion control.
  • Confirm that calendars/<owner-uid> no longer appears in the Firestore data view.

Security Rules apply to the Firebase project database instead of one calendar screen. The correct rules cleanup depends on whether another app shares this database.

  • Leave the member-aware rules in place if no other app needs the previous policy.
  • Return to the Rules page under Firestore in Databases & Storage if another app depends on the previous rules.
  • Replace the member-aware rules with the exact previous rules for that app.
  • Click Publish.
  • Allow up to one minute for new queries to receive the restored rules.

Nice Work!

Nice Work!

You did it! Your Flutter calendar now delivers realtime CRUD under member-aware Cloud Firestore Security Rules.

What you learned:

  • Built a stable repository-owned event stream from Cloud Firestore. Rendered every realtime snapshot through StreamBuilder.
  • Completed realtime event CRUD. Watched a title edit reach a second browser session without refreshing. Watched a confirmed deletion disappear there too.
  • Moved events into a member-aware calendar hierarchy. Saw the deliberate permission failure expose the outdated rules. Deployed parent-backed authorization. Proved the owner request was allowed. Proved non-member-test was denied.
  • Secret Mission: Added member-demo to the existing memberIds array without removing the owner. Proved membership-based access by allowing the added member. Confirmed non-member-test remained denied.

Ready to quiz yourself?