Share Your Firebase Calendar

Share a Flutter Firestore calendar with UID-based access and realtime updates.

Introduction

30 Second Summary

Sharing a personal calendar gets awkward when two people need the same plan but each sees a separate copy. One update can leave the other person working from old information.

In this project, you will extend your existing Part 2 TableCalendar so two signed-in accounts can work from the same schedule without changing its schema. You will grant shared access through Firebase Authentication UIDs in the existing Cloud Firestore member_ids array.

What You'll Build

You will watch an event created in one browser profile appear instantly in the same calendar opened by another account.

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

  • A calendar chooser that lets either signed-in account open the owner's existing calendar using its ID.
  • An atomic sharing flow that adds the second account's UID to member_ids. Both profiles then see the same events update in real time.
  • Creator-aware permissions that let members manage only their own events. The owner keeps control of every event.
  • Secret Mission: Revoke the second UID with FieldValue.arrayRemove. Prove access stops while every shared event stays intact.

Are there any prerequisites?

This project assumes you completed Parts 1 and 2.

Your existing Firebase Authentication flow plus realtime event CRUD must already work.

Before We Start

Part 3 extends the existing Part 2 schema without profiles, join requests, or members subcollections. Its compatibility boundary preserves the calendar fields name, owner_id, member_ids, and updated_at. It also preserves the event fields title, date, created_at, created_by, and updated_at. The owner UID remains the calendar ID because Part 2 already stores the calendar there.

Verify the Part 2 Baseline

Your Part 2 Flutter app already uses the owner’s Firebase Authentication UID as its Cloud Firestore calendar ID. TableCalendar already renders the nested realtime event stream.

A verified main baseline in VS Code protects that working schema from accidental drift. You’ll prove the existing owner flow still works before changing any files.

In this step, get ready to:
  • Confirm the current Part 2 source baseline.
  • Prepare the existing project for a browser run.
  • Prove the owner’s existing event flow with a baseline event.
Confirm the existing source baseline

The project should begin from the unchanged Part 2 files on main. This gives every later collaboration test a known starting point.

  • Press the Windows key to open Windows search.
  • Type VS Code into the search field.
  • Press Enter to open VS Code.
  • Click File in the top menu.
  • Select Open Folder.
  • Select your existing Personal-Calendar-App folder from its current location.
  • Click Select Folder.

VS Code now shows the existing Windows project from Parts 1 and 2.

  • Click the Source Control icon in the left activity bar.
  • Confirm that no pending file changes are listed.
  • Check the branch name in the lower-left corner.
  • Click the displayed branch name if it differs from main.
  • Select main from the branch list.

Seeing local changes?

Stop before replacing any files. Local changes may contain work from Part 2 that you still need.

Confirm that you opened the correct repository folder. Compare your changes with the current main branch before continuing.

Help me review unexpected local changes safely.

  • Return to the Explorer sidebar.
  • Confirm that pubspec.yaml appears at the top level of Personal-Calendar-App.
  • Confirm that firestore.rules appears at the top level of Personal-Calendar-App.
  • Expand the lib folder in the Explorer sidebar.
  • Confirm that lib/main.dart, lib/screens/home_screen.dart, lib/models/calendar_event.dart, and lib/services/calendar_repository.dart are present.
  • Select pubspec.yaml in the Explorer sidebar.
  • Confirm that its dependency list contains the pinned entries shown below.

Which dependencies should be pinned?

  • The Firebase initialization package is firebase_core: 5.1.0.
  • The authentication package is firebase_auth: 7.0.1.
  • The database package is cloud_firestore: 7.1.0.
  • The calendar package is table_calendar: ^3.3.0.

These existing pins keep the baseline compatible with the Part 2 code. This step leaves every source file unchanged.

Prepare the browser build

The existing Flutter toolchain uses pubspec.yaml to resolve project packages. A successful restore confirms that the Dart SDK can process the project before the browser build starts.

  • Open the integrated PowerShell terminal in VS Code.
  • Confirm that the terminal starts inside your existing Personal-Calendar-App folder.
  • Restore the dependencies declared in pubspec.yaml by running:
flutter pub get

What does this command do?

  • The command reads the dependency declarations in pubspec.yaml.
  • Flutter restores the packages needed by the existing Part 2 app.
  • A successful result confirms that the project can use its existing Flutter toolchain.

The dependency resolution should finish without an error. You’ll return to the PowerShell prompt when it is complete.

Dependency restore failing?

Confirm that PowerShell is inside the folder containing pubspec.yaml. A different folder gives Flutter no project manifest to read.

Check that the dependency entries still match the pinned baseline above.

Help me troubleshoot the dependency restore.

  • List the browser targets detected by Flutter by running:
flutter devices

What does this command check?

  • Flutter checks which devices can run the current project.
  • A supported Windows browser appears as a web target.
  • That target confirms that the Part 2 app can run locally in a browser.

You should see Chrome (web) or Edge (web) in the detected device list.

Browser target missing?

Confirm that Google Chrome or Edge is installed on Windows. Close the browser before rerunning the device check if Flutter still cannot detect it.

Help me find the missing web target.

Run the existing owner flow

Flutter’s web-server mode prints a local address for the running app. You use that address to open the existing Part 2 sign-in flow manually.

Before you start the app, do you expect the existing owner calendar to open without any schema changes?

  • Start the existing app in web-server mode by running:
flutter run -d web-server

What does this command do?

  • Flutter builds the current project for the web.
  • The web server prints a local address in the PowerShell terminal.
  • The command keeps running so the browser can load the app.

The terminal prints the local web address when the build is ready. Keep this PowerShell process running for the next step.

Use the same owner account from Part 2. No new credentials are needed for this baseline check.

  • Use the printed address to open the app in Google Chrome or Edge.
  • Sign in through the existing app flow with the Part 2 owner account.
  • Select a date in TableCalendar.
  • Click the plus button in the lower-right corner.
  • Enter Part 3 schema baseline as the event title.
  • Click Save.

You’ll see Part 3 schema baseline in the event list for the selected date. It appears through the existing realtime event flow.

What did this prove?

The owner can still authenticate against the existing Firebase configuration. The app can still create an event under calendars/{ownerUid}/events.

The result confirms that the Part 2 repository pattern remains intact. It also confirms that TableCalendar still receives realtime event updates.

🙋‍♀️ Baseline event not appearing?

Confirm that you selected the same date where you created the event. TableCalendar filters the visible event list by the selected date.

Keep the PowerShell process running while the browser is open. Use the exact local address printed by the web-server command.

Help me debug the missing baseline event.

Your Part 2 owner flow is working with the original schema. Next, you’ll let each signed-in account choose an explicit calendar ID.

Choose a Calendar and Test Access

Your Part 2 owner can already create events in the existing TableCalendar workflow. The app still derives every calendar ID from the current Firebase Authentication UID.

A second account needs an explicit calendar chooser before it can request the owner's calendar. You will build that route first.

In this step, get ready to:
  • Create a calendar chooser that displays the signed-in UID.
  • Pass an explicit calendar ID through the existing repository and calendar screen.
  • Use a second browser profile to test the membership boundary.
Create the calendar chooser

The chooser separates the signed-in account from the calendar it wants to open. Owners select their own UID while members enter an owner's UID.

  • In the Explorer sidebar in VS Code, click the new file icon beside the lib folder.
  • Name the file calendar_chooser_screen.dart.
  • Add the calendar chooser to lib/calendar_chooser_screen.dart with this full-file reference:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';

import 'screens/home_screen.dart';

class CalendarChooserScreen extends StatefulWidget {
  const CalendarChooserScreen({super.key, required this.user});

  final User user;

  @override
  State<CalendarChooserScreen> createState() => _CalendarChooserScreenState();
}

class _CalendarChooserScreenState extends State<CalendarChooserScreen> {
  final _calendarIdController = TextEditingController();
  String? _error;

  @override
  void dispose() {
    _calendarIdController.dispose();
    super.dispose();
  }

  void _openCalendar(String calendarId) {
    if (calendarId.isEmpty) {
      setState(() => _error = 'Enter the owner calendar ID.');
      return;
    }
    Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (context) => HomeScreen(
          user: widget.user,
          calendarId: calendarId,
          isOwner: calendarId == widget.user.uid,
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Choose a calendar'),
        actions: [
          IconButton(
            onPressed: FirebaseAuth.instance.signOut,
            icon: const Icon(Icons.logout),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(20),
        children: [
          Card(
            child: ListTile(
              leading: const Icon(Icons.badge),
              title: const Text('Your member UID'),
              subtitle: SelectableText(widget.user.uid),
            ),
          ),
          const SizedBox(height: 12),
          FilledButton.icon(
            onPressed: () => _openCalendar(widget.user.uid),
            icon: const Icon(Icons.person),
            label: const Text('Open my calendar'),
          ),
          const SizedBox(height: 20),
          TextField(
            controller: _calendarIdController,
            decoration: const InputDecoration(
              labelText: 'Owner calendar ID',
              border: OutlineInputBorder(),
            ),
          ),
          const SizedBox(height: 10),
          OutlinedButton.icon(
            onPressed: () => _openCalendar(
              _calendarIdController.text.trim(),
            ),
            icon: const Icon(Icons.group),
            label: const Text('Open shared calendar'),
          ),
          if (_error != null) ...[
            const SizedBox(height: 12),
            Text(_error!),
          ],
        ],
      ),
    );
  }
}

How Does the Chooser Route Calendars?

  • The user field supplies the authenticated account's UID.
  • The Open my calendar button passes that UID to _openCalendar().
  • The Owner calendar ID field accepts a different account's calendar ID.
  • The isOwner value records whether the selected calendar ID matches the signed-in UID.
  • Save lib/calendar_chooser_screen.dart by pressing Ctrl+S.
  • Confirm calendar_chooser_screen.dart appears under the lib folder in the Explorer sidebar.
Route calendar IDs through the app

The chooser can collect a calendar ID. The repository and home screen must now use that selected ID for every Cloud Firestore path.

  • Replace the contents of lib/main.dart with this full-file reference:
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

import 'calendar_chooser_screen.dart';
import 'firebase_options.dart';
import 'login_screen.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Shared Calendar',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const AuthGate(),
    );
  }
}

class AuthGate extends StatelessWidget {
  const AuthGate({super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<User?>(
      stream: FirebaseAuth.instance.authStateChanges(),
      builder: (context, snapshot) {
        if (snapshot.connectionState == ConnectionState.waiting) {
          return const Scaffold(
            body: Center(child: CircularProgressIndicator()),
          );
        }
        final user = snapshot.data;
        return user == null
            ? const LoginScreen()
            : CalendarChooserScreen(user: user);
      },
    );
  }
}

What Does This Route Change?

  • Firebase initialization still uses the existing DefaultFirebaseOptions.currentPlatform configuration.
  • The AuthGate still sends signed-out users to LoginScreen.
  • Authenticated users now receive CalendarChooserScreen with their current User object.
  • Save lib/main.dart by pressing Ctrl+S.
  • Replace the contents of lib/services/calendar_repository.dart with this step's repository reference:
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';

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

class CalendarRepository {
  CalendarRepository({
    required this.user,
    required this.calendarId,
    required this.isOwner,
  });

  final User user;
  final String calendarId;
  final bool isOwner;
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;

  DocumentReference<Map<String, dynamic>> get _calendarRef =>
      _firestore.collection('calendars').doc(calendarId);

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

  Future<void> prepareCalendar() async {
    if (isOwner) {
      await _calendarRef.set(
        {
          'name': 'My calendar',
          'owner_id': user.uid,
          'member_ids': FieldValue.arrayUnion([user.uid]),
          'updated_at': FieldValue.serverTimestamp(),
        },
        SetOptions(merge: true),
      );
      return;
    }
    await _calendarRef.get();
  }

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

  Future<void> createEvent({
    required String title,
    required DateTime date,
  }) async {
    await _eventsRef.add({
      'title': title,
      'date': Timestamp.fromDate(date),
      'created_at': FieldValue.serverTimestamp(),
      'created_by': user.uid,
    });
  }

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

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

What Does the Repository Change?

  • The constructor receives the authenticated user plus the selected calendarId.
  • The _calendarRef getter now targets calendars/{calendarId}.
  • The _eventsRef getter preserves the existing nested events path.
  • The prepareCalendar() method merges the parent document only for an owner.
  • A non-owner calls get() against the selected calendar. Existing rules decide whether that read succeeds.
  • Save lib/services/calendar_repository.dart by pressing Ctrl+S.

The home screen already contains the Part 2 calendar workflow. Its new responsibility is to receive the chosen values and surface a failed calendar read.

  • In lib/screens/home_screen.dart replace the existing HomeScreen declaration with this version:
class HomeScreen extends StatefulWidget {
  const HomeScreen({
    super.key,
    required this.user,
    required this.calendarId,
    required this.isOwner,
  });

  final User user;
  final String calendarId;
  final bool isOwner;

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

What Do These Fields Hold?

  • The user field identifies the authenticated account making requests.
  • The calendarId field identifies the selected parent document.
  • The isOwner field controls whether the app may merge the parent document.
  • At the beginning of _HomeScreenState replace the repository fields and initState() with this code:
  late final CalendarRepository _repository;
  late final Stream<List<CalendarEvent>> _eventsStream;
  late Future<void> _calendarReady;
  CalendarFormat _calendarFormat = CalendarFormat.month;
  DateTime _focusedDay = DateTime.now();
  DateTime? _selectedDay;

  @override
  void initState() {
    super.initState();
    _selectedDay = _focusedDay;
    _repository = CalendarRepository(
      user: widget.user,
      calendarId: widget.calendarId,
      isOwner: widget.isOwner,
    );
    _eventsStream = _repository.watchEvents();
    _calendarReady = _repository.prepareCalendar();
  }

  void _retryAccess() {
    setState(() => _calendarReady = _repository.prepareCalendar());
  }

How Does the Home Screen Use the Selection?

  • The state creates one CalendarRepository with the values supplied by the chooser.
  • The _eventsStream continues listening to nested events in realtime.
  • The _calendarReady future completes only after the selected parent document can be prepared or read.
  • The _retryAccess() method repeats that access check after membership changes.
  • In the existing FutureBuilder replace its error branch with this access message and retry control:
          if (calendarSnapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SelectableText(
                    'Calendar access failed: ${calendarSnapshot.error}',
                  ),
                  const SizedBox(height: 12),
                  FilledButton(
                    onPressed: _retryAccess,
                    child: const Text('Retry access'),
                  ),
                ],
              ),
            );
          }

Why Show the Read Failure?

The message exposes the result of the selected calendar read. The retry button lets a member repeat the same request without re-entering the calendar ID.

  • Wrap the existing event stream area in a Column after the loading check.
  • Place this calendar ID card above the existing event stream:
              Card(
                child: ListTile(
                  leading: const Icon(Icons.key),
                  title: const Text('Calendar ID'),
                  subtitle: SelectableText(widget.calendarId),
                ),
              ),

What Does the Card Confirm?

The card displays the exact calendar document selected by the chooser. Both browser profiles can use it to confirm they are requesting the same calendar.

  • Keep the existing event StreamBuilder inside an Expanded widget below the card.
  • Save lib/screens/home_screen.dart by pressing Ctrl+S.

✔️ My calendar routing is updated

Good progress. Your chooser now supplies the user, calendar ID, and owner status to the existing calendar workflow.

ⓧ I'd like to double check the changes

  • Confirm lib/main.dart imports calendar_chooser_screen.dart.
  • Confirm AuthGate passes its authenticated user to CalendarChooserScreen.
  • Confirm CalendarRepository receives user plus calendarId plus isOwner.
  • Confirm _calendarRef uses calendarId.
  • Confirm HomeScreen shows Calendar access failed when prepareCalendar() fails.
Test the membership wall

The new route lets any signed-in account name a calendar. The existing Security Rules still require that account's UID inside member_ids.

  • Switch back to the PowerShell terminal that is running the web server.
  • Stop the current process by pressing Ctrl+C.
  • Restart the updated app by running:
flutter run -d web-server

What Does This Command Do?

The command starts the updated Flutter app in web-server mode. It prints a URL that you open manually in a supported browser.

  • Record the printed address as your web-server URL.
  • Open your web-server URL in the owner's browser profile.
  • Click Open my calendar on the chooser screen.
  • Select the date containing Part 3 schema baseline.

You should see the existing event and the owner's calendar ID. That confirms the explicit route preserved the Part 2 data.

  • Return to the chooser with the browser's back button.
  • Record the displayed UID as your owner UID.
  • Switch to a second Chrome profile.
  • Open your web-server URL in that second profile.
  • Create or sign in to a second Firebase Authentication account.
  • Record its displayed UID as your second account UID.
  • Enter your owner UID in the Owner calendar ID field.

Before you test access, do you think knowing the correct owner calendar ID is enough to open it?

  • Click Open shared calendar.

You should see Calendar access failed with a permission-denied result. This is the intended membership wall because the second UID is absent from member_ids.

Why Is This Failure Useful?

The second account knows the correct document ID. The denied read proves that an identifier provides a destination while array membership provides authorization.

The owner can still open the same calendar because their UID is already in member_ids. No calendar or event data was copied.

🙋‍♀️ Not Seeing the Expected Access Result?

  • Confirm the second profile uses a different Firebase Authentication account.
  • Confirm the Owner calendar ID field contains the UID displayed in the owner's chooser.
  • Confirm prepareCalendar() calls get() when isOwner is false.

Ask for help with the route if your result differs. Help me debug why my second Firebase account does not reach the expected calendar permission boundary.

You have separated authentication from calendar selection and proved the existing authorization boundary works. Next, the owner will add the second account's real UID to member_ids.

Add the Second Member

The second account has the correct calendar ID. However, Cloud Firestore still blocks it because its Firebase Authentication UID is missing from the owner's member_ids array.

The owner now adds that real UID with an atomic array update. Both Google Chrome profiles can then read the same calendar without copying its events.

In this step, get ready to:
  • Add repository methods that stream the calendar parent and update its membership array.
  • Build an owner-only panel that displays current members and accepts another UID.
  • Add the second account and confirm both profiles load the same calendar.
Add membership methods to the repository

An atomic array update changes one field without replacing the calendar document. FieldValue.arrayUnion([memberUid]) also avoids adding the same UID twice.

  • Switch back to lib/services/calendar_repository.dart in VS Code.
  • Find the closing brace of prepareCalendar().
  • Add the calendar stream and membership update directly below it by pasting this code:
  Stream<DocumentSnapshot<Map<String, dynamic>>> watchCalendar() {
    return _calendarRef.snapshots();
  }

  Future<void> addMember(String memberUid) async {
    await _calendarRef.update({
      'member_ids': FieldValue.arrayUnion([memberUid]),
      'updated_at': FieldValue.serverTimestamp(),
    });
  }

What do these methods do?

  • The watchCalendar() method streams the selected calendar parent through snapshots().
  • The addMember() method adds the supplied UID to member_ids.
  • The array transform preserves existing members while preventing duplicate values.
  • The server timestamp records when the parent calendar was updated.
  • Save lib/services/calendar_repository.dart by pressing Ctrl+S.
  • Return to the owner profile after the running app reloads.

You should still see the owner's existing calendar and the Part 3 schema baseline event. This confirms the new repository methods compile without changing the current data path.

App not reloading?

Compare the placement of watchCalendar() and addMember() with the complete repository below. Both methods must remain inside CalendarRepository.

Check the running terminal for the first referenced file and line. A misplaced closing brace can leave either method outside the class.

Help me check my repository methods.

✔️ Awesome, I've got everything!

Your repository can now stream the parent calendar and add a UID atomically. Double check that lib/services/calendar_repository.dart is saved.

ⓧ I'd like to double check the full code

Compare your complete lib/services/calendar_repository.dart file with this version:

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

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

class CalendarRepository {
  CalendarRepository({
    required this.user,
    required this.calendarId,
    required this.isOwner,
  });

  final User user;
  final String calendarId;
  final bool isOwner;
  final FirebaseFirestore _firestore = FirebaseFirestore.instance;

  DocumentReference<Map<String, dynamic>> get _calendarRef =>
      _firestore.collection('calendars').doc(calendarId);

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

  Future<void> prepareCalendar() async {
    if (isOwner) {
      await _calendarRef.set(
        {
          'name': 'My calendar',
          'owner_id': user.uid,
          'member_ids': FieldValue.arrayUnion([user.uid]),
          'updated_at': FieldValue.serverTimestamp(),
        },
        SetOptions(merge: true),
      );
      return;
    }
    await _calendarRef.get();
  }

  Stream<DocumentSnapshot<Map<String, dynamic>>> watchCalendar() {
    return _calendarRef.snapshots();
  }

  Future<void> addMember(String memberUid) async {
    await _calendarRef.update({
      'member_ids': FieldValue.arrayUnion([memberUid]),
      'updated_at': FieldValue.serverTimestamp(),
    });
  }

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

  Future<void> createEvent({
    required String title,
    required DateTime date,
  }) async {
    await _eventsRef.add({
      'title': title,
      'date': Timestamp.fromDate(date),
      'created_at': FieldValue.serverTimestamp(),
      'created_by': user.uid,
    });
  }

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

  Future<void> deleteEvent(String eventId) async {
    await _eventsRef.doc(eventId).delete();
  }
}
Build the owner membership panel

The membership panel listens to the parent calendar document. Each successful update appears in the member list through the existing realtime stream.

The next snippets form one continuous MembershipPanel class. Paste them in order at the bottom of lib/screens/home_screen.dart before saving the file.

  • Switch back to lib/screens/home_screen.dart in VS Code.
  • Scroll below the closing brace of the HomeScreen state class.
  • Start the owner membership panel by pasting this code:
class MembershipPanel extends StatefulWidget {
  const MembershipPanel({super.key, required this.repository});

  final CalendarRepository repository;

  @override
  State<MembershipPanel> createState() => _MembershipPanelState();
}

What does this wrapper do?

The panel receives the existing CalendarRepository instance from HomeScreen. Its state object manages the UID input and update progress.

  • Continue directly below the previous snippet by pasting the panel state and submission logic:
class _MembershipPanelState extends State<MembershipPanel> {
  final _memberUidController = TextEditingController();
  bool _busy = false;

  @override
  void dispose() {
    _memberUidController.dispose();
    super.dispose();
  }

  Future<void> _addMember() async {
    final memberUid = _memberUidController.text.trim();
    if (memberUid.isEmpty) return;
    setState(() => _busy = true);
    try {
      await widget.repository.addMember(memberUid);
      _memberUidController.clear();
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Could not add member: $error')),
      );
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

How does member submission work?

  • The text controller holds the UID entered by the owner.
  • The _busy value prevents another submission while the update is running.
  • The _addMember() method trims the UID before passing it to the repository.
  • A failed update produces a message without closing the calendar screen.
  • Continue the same state class by adding the start of its streamed interface:
  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: widget.repository.watchCalendar(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const LinearProgressIndicator();
        final data = snapshot.data!.data();
        final members = List<String>.from(
          data?['member_ids'] as List<dynamic>? ?? const <dynamic>[],
        );

        return Card(
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              children: [

How does the list stay current?

The StreamBuilder rebuilds whenever watchCalendar() emits a new parent document. It converts the existing member_ids field into a Dart string list.

  • Complete the state class by adding the UID field and member list directly below the previous snippet:
                TextField(
                  controller: _memberUidController,
                  decoration: const InputDecoration(
                    labelText: 'Member UID',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 8),
                FilledButton(
                  onPressed: _busy ? null : _addMember,
                  child: const Text('Add member'),
                ),
                for (final memberUid in members)
                  ListTile(
                    dense: true,
                    leading: const Icon(Icons.person),
                    title: SelectableText(memberUid),
                  ),
              ],
            ),
          ),
        );
      },
    );
  }
}

What does the panel show?

  • The Member UID field accepts the second account's identifier.
  • The Add member button calls _addMember().
  • Each existing value in member_ids appears as selectable text.

The panel must appear only when isOwner is true. This keeps ordinary members away from the membership control.

  • Find the calendar ID card inside the build() method of HomeScreen.
  • Add the owner-only panel directly below that card by pasting these lines:
              if (widget.isOwner)
                MembershipPanel(repository: _repository),

Why is the panel owner-only?

The isOwner check controls whether the membership interface renders. The repository still relies on Firestore Security Rules as the authorization boundary.

  • Save lib/screens/home_screen.dart by pressing Ctrl+S.
  • Return to the owner profile after the app reloads.

You should see a Member UID field and an Add member button below the calendar ID. The current owner's UID should already appear in the streamed member list.

Membership panel missing?

Confirm the owner opened their own UID with Open my calendar. That route sets isOwner to true.

Check that MembershipPanel(repository: _repository) sits inside the body column below the calendar ID card.

Help me debug the missing membership panel.

✔️ Awesome, I've got everything!

The owner can now see the membership panel and the current member_ids values. Double check that lib/screens/home_screen.dart is saved.

ⓧ I'd like to double check the full code

Compare the complete MembershipPanel section at the bottom of lib/screens/home_screen.dart with this version:

class MembershipPanel extends StatefulWidget {
  const MembershipPanel({super.key, required this.repository});

  final CalendarRepository repository;

  @override
  State<MembershipPanel> createState() => _MembershipPanelState();
}

class _MembershipPanelState extends State<MembershipPanel> {
  final _memberUidController = TextEditingController();
  bool _busy = false;

  @override
  void dispose() {
    _memberUidController.dispose();
    super.dispose();
  }

  Future<void> _addMember() async {
    final memberUid = _memberUidController.text.trim();
    if (memberUid.isEmpty) return;
    setState(() => _busy = true);
    try {
      await widget.repository.addMember(memberUid);
      _memberUidController.clear();
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Could not add member: $error')),
      );
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: widget.repository.watchCalendar(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const LinearProgressIndicator();
        final data = snapshot.data!.data();
        final members = List<String>.from(
          data?['member_ids'] as List<dynamic>? ?? const <dynamic>[],
        );

        return Card(
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              children: [
                TextField(
                  controller: _memberUidController,
                  decoration: const InputDecoration(
                    labelText: 'Member UID',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 8),
                FilledButton(
                  onPressed: _busy ? null : _addMember,
                  child: const Text('Add member'),
                ),
                for (final memberUid in members)
                  ListTile(
                    dense: true,
                    leading: const Icon(Icons.person),
                    title: SelectableText(memberUid),
                  ),
              ],
            ),
          ),
        );
      },
    );
  }
}
Grant access and verify both profiles

The second account's UID is the membership value the rules recognize. Adding that exact value turns the failed calendar read into an authorized read.

  • Switch to the second Chrome profile.
  • Use the browser's back control to return to CalendarChooserScreen.
  • Record the value shown under Your member UID here: second account UID.
  • Use the browser's forward control to return to the failed calendar screen.
  • Switch back to the owner profile.
  • Enter second account UID in the Member UID field.
  • Click Add member.

The member list should update to show the owner UID and the second account UID. The existing calendar document now authorizes both accounts through member_ids.

Second UID not appearing?

Confirm the value came from the second profile's Your member UID card. An email address or the owner's UID does not identify the second account.

If the update fails, confirm the owner profile opened its own calendar. The calendar parent rules permit only the owner to update member_ids.

Help me debug why the member UID was not added.

Before you select Retry access, do you expect the same calendar read to fail again or load successfully?

  • Switch to the second Chrome profile.
  • Click Retry access.
  • Arrange both browser windows side by side.

You should see the owner's calendar ID in both profiles. The Part 3 schema baseline event should appear in both TableCalendar views.

That is the membership wall cleared. Two authenticated accounts can now read the same existing calendar and nested event collection.

Retry still denied?

Compare the UID displayed in the owner's member list with the UID shown in the second profile. Every character must match.

Confirm the second profile is retrying the owner's calendar ID. Opening the second account's own calendar targets a different document.

Help me diagnose the denied calendar retry.

Both profiles now collaborate through the unchanged Part 2 calendar. Next, you'll test what one member can do to another member's events.

Expose the Delete Weakness

Both accounts now open the same TableCalendar through the existing member_ids array. Their shared event stream proves that the second Firebase Authentication account has access.

The original Part 2 Cloud Firestore Security Rules let any calendar member delete any event. You need to reproduce that unsafe behavior before tightening the permission.

In this step, get ready to:
  • Show each event's creator beneath its title.
  • Create events from both browser profiles to prove realtime collaboration.
  • Delete the owner's event from the second profile to expose the permission weakness.
Show who created each event

Every existing event stores its creator in created_by. Reading that field lets each profile distinguish its own events from events created by the other account.

  • Switch back to lib/models/calendar_event.dart in VS Code.
  • Replace the file contents with this updated model:
import 'package:cloud_firestore/cloud_firestore.dart';

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

  final String id;
  final String title;
  final DateTime date;
  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',
      date: (data['date'] as Timestamp).toDate().toUtc(),
      createdBy: data['created_by'] as String? ?? 'unknown',
    );
  }
}

What does this model change?

  • The createdBy property stores the creator UID from each event document.
  • The created_by lookup preserves the existing snake-case Firestore field.
  • The unknown fallback keeps older or incomplete event documents readable.
  • Save lib/models/calendar_event.dart.
  • Switch back to the PowerShell terminal running the Flutter app.
  • Trigger a hot reload by entering r.

You should see the hot reload complete in PowerShell. Both calendar views should remain open with their existing state preserved.

Model not compiling?

Check that createdBy appears in the constructor. Confirm that it also appears as a field and inside CalendarEvent.fromSnapshot.

Compare every quote and comma against the complete model below. A missing constructor argument prevents the hot reload from completing.

Help me fix the creator field in my CalendarEvent model.

✔️ Awesome, I've got everything!

Your event model now reads the existing creator UID. Keep the app running for the UI update.

ⓧ I'd like to double check the full code

Compare your complete lib/models/calendar_event.dart file against this reference:

import 'package:cloud_firestore/cloud_firestore.dart';

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

  final String id;
  final String title;
  final DateTime date;
  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',
      date: (data['date'] as Timestamp).toDate().toUtc(),
      createdBy: data['created_by'] as String? ?? 'unknown',
    );
  }
}

This complete model preserves the existing event data while exposing created_by to the calendar UI.

  • Switch back to lib/screens/home_screen.dart in VS Code.
  • Find the final event = dayEvents[index]; line inside _buildEventList.
  • Add this creator label directly below that line:
final creator = event.createdBy == widget.user.uid
    ? 'you'
    : event.createdBy;

How does the label work?

The comparison checks whether the signed-in UID matches the event's createdBy value. A match produces you while another creator produces their UID.

  • Find the title: Text(event.title), line in the same ListTile.
  • Add the creator subtitle directly below the title so the two lines look like this:
title: Text(event.title),
subtitle: Text('Created by $creator'),

What does the subtitle show?

Each event row now displays creator attribution beneath its title. The current account sees Created by you for its own events.

The other account sees the creator's UID. This makes cross-member actions visible during the permission test.

  • Save lib/screens/home_screen.dart.
  • Switch back to the PowerShell terminal running the Flutter app.
  • Trigger another hot reload by entering r.
  • Return to the owner profile in Chrome.

The existing Part 3 schema baseline event should show Created by you beneath its title.

  • Return to the second Chrome profile.

The same baseline event should display the owner's UID beneath its title. That is the creator attribution working across both accounts.

Creator label missing?

Confirm that subtitle: Text('Created by $creator'), sits inside the event ListTile.

If every event shows unknown, check that the model reads the existing created_by field with the underscore included.

Help me debug why creator attribution is missing.

Create events from both profiles

The existing snapshots() listener sends event changes to every authorized profile. Creating one event from each account proves that both profiles are using the same nested event collection.

  • Select today's date in the owner profile.
  • Click the add icon in the lower corner.
  • Enter Owner protected event in the Event title field.
  • Click Save.

The owner profile should show Owner protected event with Created by you beneath it. The second profile should receive the same event without a refresh and display the owner's UID.

  • Select the same date in the second profile.
  • Click the add icon in the lower corner.
  • Enter Member realtime event in the Event title field.
  • Click Save.

The second profile should label Member realtime event as Created by you. The owner profile should receive it without a refresh and display the second account's UID.

Event missing from one profile?

Confirm that both profiles show the same Calendar ID. Select the event's date in both calendars because the list only shows events for the selected day.

If one profile reports an event stream permission problem, confirm that its UID still appears in the owner's member_ids list.

Help me debug the shared event stream.

Delete the owner's event as the member

Both profiles can currently see the delete control for every event. The next check reveals whether the original member permission protects an event from a different creator.

Before you try this, do you think the second member can delete an event created by the owner?

  • Find Owner protected event in the second profile.
  • Click its delete icon.
  • Click Delete in the Delete event? dialog.

You'll see Owner protected event disappear from the second profile. It also disappears from the owner profile without a refresh.

What did this prove?

The original Part 2 delete permission authorizes every UID in the calendar's member_ids array. It does not compare the event's created_by value with the member attempting the deletion.

Realtime synchronization then broadcasts the successful deletion to both profiles. The missing event is direct evidence that the permission is too broad.

🙋‍♀️ Deletion did not go through?

Confirm that you clicked the delete icon in the second profile for Owner protected event. The creator UID beneath the event should belong to the owner.

If the request is denied, your published rules may already contain creator-aware protection. This checkpoint requires the original Part 2-style member delete permission.

Help me verify the delete weakness safely.

You have reproduced the cross-member delete flaw through the same shared calendar used by both accounts. Next, you'll align the controls with creator-aware rules while preserving owner-wide access.

Enforce Creator and Owner Controls

The second profile just deleted an event created by the owner. That realtime disappearance proved that the original member delete permission trusts every member too broadly.

Cloud Firestore Security Rules must now distinguish the calendar owner from an event creator. The owner keeps control over every event.

The Flutter interface must reflect those permissions through the same creator check. Ordinary members receive controls only for events they created.

In this step, get ready to:
  • Add owner-aware helpers to the existing security rules.
  • Restrict event creation, updates, and deletion through creator-aware permissions.
  • Match the event controls to the published rules across both browser profiles.
Define creator-aware rule helpers

The current rules already know whether someone belongs to the calendar. They also need a dedicated owner check plus a validator for newly created events.

  • In the Explorer sidebar in VS Code, select firestore.rules.
  • Find the closing brace of isCalendarMember(calendarId).
  • Add these helpers immediately below it:
    function isCalendarOwner(calendarId) {
      return isSignedIn()
        && request.auth.uid == calendarData(calendarId).owner_id;
    }

    function validNewEvent(data) {
      return data.keys().hasAll([
          'title', 'date', 'created_at', 'created_by'
        ])
        && data.keys().hasOnly([
          'title', 'date', 'created_at', 'created_by'
        ])
        && data.title is string
        && data.date is timestamp
        && data.created_at is timestamp
        && data.created_by is string;
    }

What do these helpers enforce?

  • The isCalendarOwner() helper compares the signed-in UID with the existing owner_id field.
  • The hasAll() check requires every existing event field during creation.
  • The hasOnly() check rejects extra fields during creation.
  • The type checks require the expected string and timestamp values.
  • Save firestore.rules by pressing Ctrl+S.
  • Confirm the firestore.rules tab no longer shows an unsaved indicator.

Seeing a rule syntax warning?

Check that both helpers remain inside the database documents block. Confirm that the existing isCalendarMember() helper still closes before isCalendarOwner() begins.

Compare each brace with the snippet above. A missing closing brace can make every rule below it invalid.

Help me check these Firestore rule helpers.

Restrict event writes by ownership

The nested event rules are the enforcement layer for every client. They accept complete new events while limiting later changes to the title and update timestamp.

  • In firestore.rules, find the existing match /events/{eventId} block.
  • Replace that entire event block with this version:
      match /events/{eventId} {
        allow read: if isCalendarMember(calendarId);

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

        allow update: if (
            isCalendarOwner(calendarId)
            || (isCalendarMember(calendarId)
              && resource.data.created_by == request.auth.uid)
          )
          && request.resource.data.diff(resource.data).affectedKeys()
            .hasOnly(['title', 'updated_at'])
          && request.resource.data.title is string
          && request.resource.data.updated_at is timestamp;

        allow delete: if isCalendarOwner(calendarId)
          || (isCalendarMember(calendarId)
            && resource.data.created_by == request.auth.uid);
      }

How do these permissions work?

  • Event reads still require membership in the parent calendar.
  • Event creation requires created_by to match the signed-in UID.
  • Event updates allow the calendar owner or the UID stored in created_by.
  • The changed-field allowlist permits only title and updated_at to change.
  • Event deletion allows the calendar owner or the event creator.
  • Save firestore.rules by pressing Ctrl+S.
  • Confirm the file still contains the existing calendar-level rules above the nested event block.

Rules file looking incomplete?

Replace only the nested event block. Keep the surrounding calendar match block plus its create, read, update, and delete rules.

Confirm validNewEvent() appears above the calendar match block. The event create rule depends on that helper.

Help me compare my event permissions with the final rules.

✔️ Awesome, I've got everything!

Your local firestore.rules now separates owner access from creator access. Keep the file saved for publication.

ⓧ I'd like to double check the full code

Compare your complete firestore.rules file with this final version:

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

    function calendarData(calendarId) {
      return get(
        /databases/$(database)/documents/calendars/$(calendarId)
      ).data;
    }

    function isCalendarMember(calendarId) {
      return isSignedIn()
        && request.auth.uid in calendarData(calendarId).member_ids;
    }

    function isCalendarOwner(calendarId) {
      return isSignedIn()
        && request.auth.uid == calendarData(calendarId).owner_id;
    }

    function validNewEvent(data) {
      return data.keys().hasAll([
          'title', 'date', 'created_at', 'created_by'
        ])
        && data.keys().hasOnly([
          'title', 'date', 'created_at', 'created_by'
        ])
        && data.title is string
        && data.date is timestamp
        && data.created_at is timestamp
        && data.created_by is string;
    }

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

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

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

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

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

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

        allow update: if (
            isCalendarOwner(calendarId)
            || (isCalendarMember(calendarId)
              && resource.data.created_by == request.auth.uid)
          )
          && request.resource.data.diff(resource.data).affectedKeys()
            .hasOnly(['title', 'updated_at'])
          && request.resource.data.title is string
          && request.resource.data.updated_at is timestamp;

        allow delete: if isCalendarOwner(calendarId)
          || (isCalendarMember(calendarId)
            && resource.data.created_by == request.auth.uid);
      }
    }
  }
}
Align the event controls and publish

The rules now define who can manage each event. The event list needs the same decision so members never receive controls for another person's event.

  • In the Explorer sidebar in VS Code, select lib/screens/home_screen.dart.
  • Inside _buildEventList(), find the line that starts with final event = dayEvents[index];.
  • Replace the current event item logic with this final version:
        final event = dayEvents[index];
        final canManage =
            widget.isOwner || event.createdBy == widget.user.uid;
        final creator = event.createdBy == widget.user.uid
            ? 'you'
            : event.createdBy;

        return ListTile(
          leading: const Icon(Icons.event),
          title: Text(event.title),
          subtitle: Text('Created by $creator'),
          onTap: canManage ? () => _saveEvent(event: event) : null,
          trailing: canManage
              ? IconButton(
                  tooltip: 'Delete event',
                  onPressed: () => _deleteEvent(event),
                  icon: const Icon(Icons.delete_outline),
                )
              : null,
        );

What does this control check do?

  • The canManage value becomes true for the calendar owner.
  • The same value becomes true when event.createdBy matches the signed-in UID.
  • The event tap handler appears only when canManage is true.
  • The delete button follows the same decision.
  • Save lib/screens/home_screen.dart by pressing Ctrl+S.
  • Return to the second browser profile.
  • Select a date containing an event created by profile 2.
  • Confirm that profile 2 still shows edit and delete controls for its own event.
  • Select a date containing an event created by profile 1.
  • Confirm that profile 2 shows the creator UID without edit or delete controls.

Controls appearing on the wrong events?

Check that canManage uses widget.isOwner plus an exact comparison between event.createdBy and widget.user.uid.

Confirm onTap and trailing both depend on the same canManage value.

Help me debug the event control visibility.

✔️ Awesome, I've got everything!

Your event list now presents the same owner and creator boundaries enforced by the rules.

ⓧ I'd like to double check the full code

Compare your complete lib/screens/home_screen.dart file with this final version:

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';

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

class HomeScreen extends StatefulWidget {
  const HomeScreen({
    super.key,
    required this.user,
    required this.calendarId,
    required this.isOwner,
  });

  final User user;
  final String calendarId;
  final bool isOwner;

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

class _HomeScreenState extends State<HomeScreen> {
  late final CalendarRepository _repository;
  late final Stream<List<CalendarEvent>> _eventsStream;
  late Future<void> _calendarReady;
  CalendarFormat _calendarFormat = CalendarFormat.month;
  DateTime _focusedDay = DateTime.now();
  DateTime? _selectedDay;

  @override
  void initState() {
    super.initState();
    _selectedDay = _focusedDay;
    _repository = CalendarRepository(
      user: widget.user,
      calendarId: widget.calendarId,
      isOwner: widget.isOwner,
    );
    _eventsStream = _repository.watchEvents();
    _calendarReady = _repository.prepareCalendar();
  }

  void _retryAccess() {
    setState(() => _calendarReady = _repository.prepareCalendar());
  }

  DateTime _normalizeDate(DateTime date) {
    return DateTime.utc(date.year, date.month, date.day);
  }

  List<CalendarEvent> _getEventsForDay(
    List<CalendarEvent> events,
    DateTime day,
  ) {
    final normalizedDay = _normalizeDate(day);
    return events
        .where((event) => _normalizeDate(event.date) == normalizedDay)
        .toList(growable: false);
  }

  Future<String?> _requestTitle(CalendarEvent? event) async {
    final controller = TextEditingController(text: event?.title ?? '');
    final title = await showDialog<String>(
      context: context,
      builder: (dialogContext) => AlertDialog(
        title: Text(event == null ? 'New Event' : 'Edit Event'),
        content: TextField(
          controller: controller,
          autofocus: true,
          decoration: const InputDecoration(
            hintText: 'Event title',
            border: OutlineInputBorder(),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(dialogContext),
            child: const Text('Cancel'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(dialogContext, controller.text),
            child: const Text('Save'),
          ),
        ],
      ),
    );
    controller.dispose();
    return title?.trim();
  }

  Future<void> _saveEvent({CalendarEvent? event}) async {
    final title = await _requestTitle(event);
    if (title == null || title.isEmpty) return;

    try {
      if (event == null) {
        await _repository.createEvent(title: title, date: _selectedDay!);
      } else {
        await _repository.updateEvent(eventId: event.id, title: title);
      }
      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> _deleteEvent(CalendarEvent event) async {
    final confirmed = await showDialog<bool>(
          context: context,
          builder: (dialogContext) => AlertDialog(
            title: const Text('Delete event?'),
            content: Text('Delete "${event.title}" for every member?'),
            actions: [
              TextButton(
                onPressed: () => Navigator.pop(dialogContext, false),
                child: const Text('Cancel'),
              ),
              FilledButton(
                onPressed: () => Navigator.pop(dialogContext, true),
                child: const Text('Delete'),
              ),
            ],
          ),
        ) ??
        false;
    if (!confirmed) return;

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

  Widget _buildTableCalendar(List<CalendarEvent> events) {
    return TableCalendar<CalendarEvent>(
      firstDay: DateTime.utc(2020, 1, 1),
      lastDay: DateTime.utc(2030, 12, 31),
      focusedDay: _focusedDay,
      calendarFormat: _calendarFormat,
      selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
      eventLoader: (day) => _getEventsForDay(events, day),
      onDaySelected: (selectedDay, focusedDay) {
        setState(() {
          _selectedDay = selectedDay;
          _focusedDay = focusedDay;
        });
      },
      onFormatChanged: (format) {
        setState(() => _calendarFormat = format);
      },
      onPageChanged: (focusedDay) {
        _focusedDay = focusedDay;
      },
    );
  }

  Widget _buildEventList(List<CalendarEvent> events) {
    final dayEvents = _getEventsForDay(events, _selectedDay!);
    if (dayEvents.isEmpty) {
      return const Center(child: Text('No events for this day'));
    }

    return ListView.builder(
      itemCount: dayEvents.length,
      itemBuilder: (context, index) {
        final event = dayEvents[index];
        final canManage =
            widget.isOwner || event.createdBy == widget.user.uid;
        final creator = event.createdBy == widget.user.uid
            ? 'you'
            : event.createdBy;

        return ListTile(
          leading: const Icon(Icons.event),
          title: Text(event.title),
          subtitle: Text('Created by $creator'),
          onTap: canManage ? () => _saveEvent(event: event) : null,
          trailing: canManage
              ? IconButton(
                  tooltip: 'Delete event',
                  onPressed: () => _deleteEvent(event),
                  icon: const Icon(Icons.delete_outline),
                )
              : null,
        );
      },
    );
  }

  Widget _buildCalendar(List<CalendarEvent> events) {
    return Column(
      children: [
        _buildTableCalendar(events),
        const SizedBox(height: 8),
        Expanded(child: _buildEventList(events)),
      ],
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shared Calendar'),
        actions: [
          IconButton(
            onPressed: FirebaseAuth.instance.signOut,
            icon: const Icon(Icons.logout),
          ),
        ],
      ),
      body: FutureBuilder<void>(
        future: _calendarReady,
        builder: (context, calendarSnapshot) {
          if (calendarSnapshot.hasError) {
            return Center(
              child: Column(
                mainAxisSize: MainAxisSize.min,
                children: [
                  SelectableText(
                    'Calendar access failed: ${calendarSnapshot.error}',
                  ),
                  const SizedBox(height: 12),
                  FilledButton(
                    onPressed: _retryAccess,
                    child: const Text('Retry access'),
                  ),
                ],
              ),
            );
          }
          if (calendarSnapshot.connectionState != ConnectionState.done) {
            return const Center(child: CircularProgressIndicator());
          }

          return Column(
            children: [
              Card(
                child: ListTile(
                  leading: const Icon(Icons.key),
                  title: const Text('Calendar ID'),
                  subtitle: SelectableText(widget.calendarId),
                ),
              ),
              if (widget.isOwner)
                MembershipPanel(repository: _repository),
              Expanded(
                child: StreamBuilder<List<CalendarEvent>>(
                  stream: _eventsStream,
                  builder: (context, snapshot) {
                    if (snapshot.hasError) {
                      return Center(
                        child: SelectableText(
                          'Event stream failed: ${snapshot.error}',
                        ),
                      );
                    }
                    if (!snapshot.hasData) {
                      return const Center(child: CircularProgressIndicator());
                    }
                    return _buildCalendar(snapshot.data!);
                  },
                ),
              ),
            ],
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _saveEvent(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

class MembershipPanel extends StatefulWidget {
  const MembershipPanel({super.key, required this.repository});

  final CalendarRepository repository;

  @override
  State<MembershipPanel> createState() => _MembershipPanelState();
}

class _MembershipPanelState extends State<MembershipPanel> {
  final _memberUidController = TextEditingController();
  bool _busy = false;

  @override
  void dispose() {
    _memberUidController.dispose();
    super.dispose();
  }

  Future<void> _addMember() async {
    final memberUid = _memberUidController.text.trim();
    if (memberUid.isEmpty) return;
    setState(() => _busy = true);
    try {
      await widget.repository.addMember(memberUid);
      _memberUidController.clear();
    } catch (error) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Could not add member: $error')),
      );
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: widget.repository.watchCalendar(),
      builder: (context, snapshot) {
        if (!snapshot.hasData) return const LinearProgressIndicator();
        final data = snapshot.data!.data();
        final members = List<String>.from(
          data?['member_ids'] as List<dynamic>? ?? const <dynamic>[],
        );

        return Card(
          child: Padding(
            padding: const EdgeInsets.all(12),
            child: Column(
              children: [
                TextField(
                  controller: _memberUidController,
                  decoration: const InputDecoration(
                    labelText: 'Member UID',
                    border: OutlineInputBorder(),
                  ),
                ),
                const SizedBox(height: 8),
                FilledButton(
                  onPressed: _busy ? null : _addMember,
                  child: const Text('Add member'),
                ),
                for (final memberUid in members)
                  ListTile(
                    dense: true,
                    leading: const Icon(Icons.person),
                    title: SelectableText(memberUid),
                  ),
              ],
            ),
          ),
        );
      },
    );
  }
}

Your local rules and interface now agree. The final enforcement begins when you publish the saved rules through the Firebase Console.

  • Return to the Firebase Console for the project used by both profiles.
  • Select Databases & Storage in the left navigation.
  • Select Firestore.
  • Select the Rules tab.
  • Replace the online editor with the complete contents of your saved firestore.rules file.
  • Click Publish.

You should see the Rules page confirm that the latest rules are active for the database.

Rules not publishing?

Confirm you pasted the complete file from the first line through the final closing brace. Check that you are editing the same Firebase project used by both signed-in profiles.

Help me diagnose why these Firestore rules will not publish.

Before you recreate the owner event, which profile do you expect to receive its edit and delete controls? Hold that prediction.

  • Return to profile 1.
  • Select a date in TableCalendar.
  • Click the floating add button.
  • Enter Owner protected event in the event title field.
  • Click Save.

You should see the event creation confirmation. The new event should also reach profile 2 through the existing realtime listener.

  • Switch to profile 2.
  • Select the same date in TableCalendar.

You should see Owner protected event with profile 1's UID beneath it. You should see no delete button for that event.

  • Select Owner protected event in profile 2.
  • Confirm that the edit dialog does not open.
  • Select an event created by profile 2.
  • Select that event to open its edit dialog.
  • Click Save without changing the title.

You should see Event updated. in profile 2. This proves an ordinary member can still update an event with a matching created_by value.

  • Return to profile 1.
  • Select the event created by profile 2.
  • Select that event to open its edit dialog.
  • Click Save without changing the title.

You should see Event updated. in profile 1. You should also see delete controls for events from both accounts.

That permission gap is closed. The owner now manages every event while each ordinary member controls only their own work.

  • Arrange both Google Chrome profiles side by side on the date containing Owner protected event.

Secret mission

Revoke an Array Member

Membership should be reversible. Add confirmed owner-only revocation that removes the second UID while preserving every event in the shared calendar.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep your resources running, pause your local app, or remove the disposable Part 3 resources. This project stays at $0 while your Firebase Spark plan usage remains within its no-cost quotas.

Resources you used:

  • One running Flutter web server process in Windows PowerShell serving both Google Chrome profiles.
  • Part 3 test events stored in the existing nested Cloud Firestore events collection.
  • One second Firebase Authentication account created for profile 2.

The existing calendars/{ownerUid} parent stays in place because it belongs to your Part 2 baseline. Your Secret Mission has already removed profile 2 from member_ids.

Keep everything running

No action is needed. Choose this if you want to continue testing the collaboration flow or reference the completed project later.

  • Keep the Windows PowerShell process running to continue using the app in both Chrome profiles.
  • Keep the second test account to reuse it in a later sharing test.
  • Leave the final Security Rules published to preserve creator and owner controls.
  • Leave the existing calendar parent plus its nested events in Cloud Firestore.

Pause - I'll come back to this later

Stop the local server to free your terminal. Your project files plus Firebase data remain ready for your next session.

  • Press Ctrl+C in the Windows PowerShell window serving the app.
  • Close the app tabs in both Chrome profiles.
  • Leave the second test account in Firebase Authentication.
  • Leave the existing calendar data plus published rules unchanged.

Your local server is now stopped. The shared calendar remains available when you run the app again.

Delete - I don't want to use this again

Remove the disposable Part 3 resources while protecting the Part 2 baseline. The existing calendar parent plus all pre-existing Part 2 events stay in place.

Deleting the second test account is permanent. Its existing events remain under the owner's calendar until the owner deletes them.

  • Switch back to the owner's Chrome profile.
  • Select the date containing a Part 3 test event.
  • Click the delete icon beside that test event.
  • Click Delete in the confirmation dialog.
  • Repeat the deletion for each Part 3 test event you want to remove.

You'll see each deleted event disappear from the owner's TableCalendar view.

  • Return to your Firebase project in the Firebase Console.
  • Go to the Firebase Authentication user list for that project.
  • Select the account used in profile 2.
  • Use the account deletion action in the user list to delete the selected account.
  • Confirm the account deletion in the dialog.

You'll no longer see the profile 2 account in the Authentication user list. Its UID is already absent from the owner's membership array.

  • Switch back to the Windows PowerShell window serving the app.
  • Press Ctrl+C to stop the Flutter web server.
  • Close the remaining app tabs in both Chrome profiles.

That's the cleanup complete. Your second test identity is gone while the existing calendar plus final access controls remain intact.

Nice Work!

Nice Work!

You did it! Your Flutter web calendar now gives two Firebase Authentication accounts controlled access to one existing Cloud Firestore calendar.

What you learned:

  • Preserved the Part 2 schema across the calendar parent and its nested events. Kept the existing field names without adding new collections.
  • Routed two authenticated accounts to the same owner calendar through its existing membership array. Watched both profiles receive realtime event changes in TableCalendar.
  • Reproduced the original cross-member delete weakness on the main path. Enforced creator and owner controls through matching Security Rules and interface controls.
  • Secret Mission: revoked the second account's UID with an atomic array update. Confirmed the owner could still read every existing event after access ended.

Ready to quiz yourself?