Skip to main content

Workshop 2: Match Maker: Firebase and swiping

About 30 min

Workshop 2: Match Maker: Firebase and swiping

Continue the app from workshop 1. You can start from the main branch of the Match Maker repositoryopen in new window. We will replace the static example with email/password login, profiles from Firestore, saved swipe choices and working bottom navigation.

Follow the live build and keep the structure from the course: models, pages, repositories, services and widgets. Use setState for screen state and StreamBuilder for Firebase streams. No extra state-management or swipe package is needed.

Each new or changed Dart file is shown below as a starter snippet. Copy the UI, models and form boilerplate; complete the marked LIVE blocks together with the teacher. The seed scripts are provided as a ZIP in step 2; firebase_options.dart is generated for your own project.

Copy the UI, write the behaviour together

A comment such as // LIVE W2-03C marks code to write during the live build. Write the missing code together with the teacher. Replace the marker comment and its complete temporary implementation. The block ID identifies the code we are working on during the live build.

  • COPY: styling, layout, reusable widgets, models, validators, controllers, the profile form and seed scripts are supplied.
  • LIVE: the first stateful widget, Firebase startup, repositories, authentication, stream handling, filtering and saving swipes are built together.
  • An UnimplementedError('W2-...') is deliberate: the operation is unfinished. Replace it before using that feature. Temporary widget placeholders can render; buttons with a null callback stay disabled until connected.
  • Complete every LIVE block in the current step before running its checkpoint. Step 3 repositories are exercised after login and the real pages are connected.
  • In step 5, reuse the main() completed in step 4 when copying the second main.dart snippet; do not replace it with the startup placeholder again.

After each completed snippet, use the visual summary and code table to explain the flow in your own words. These explain the completed version, including the code written during the live build.

What you will build

  • Register, sign in and sign out with Firebase Authentication.
  • Read profiles in realtime and save your own profile in Firestore.
  • Swipe right to like and left to skip; buttons perform the same actions.
  • Open Ontdek, Likes, Chats and Profiel from the bottom menu.
  • Populate your own database with 50 fictional profiles using a seed script.

Likes shows your own likes. Chats is a separate information page for now; sending messages and detecting mutual matches are later extensions.

1. Connect your own Firebase project

We use the same setup as Firebase project and Firestore, but apply it to the existing Match Maker app. Keep working in that Flutter project; do not create a new Travel Notes app or replace the Match Maker theme.

Create a Firebase project

  1. Open the Firebase consoleopen in new window and sign in with your Google account.
  2. Click Create a project and use a clear name, for example match-maker.
  3. Google Analytics is not required for this workshop, so you can disable it.
  4. Finish creating the project and open it.
  5. Open Project settings → General and note your Project ID. This can differ from the display name. You will also need it for the seed script in step 2.

You use your own Firebase project. Do not select the teacher's project or reuse the Firebase project from the Travel Notes exercises.

Enable Cloud Firestore

  1. In your Firebase project, open Firestore Database under the database products.
  2. Click Create database.
  3. Choose Standard edition and the default database, with ID (default).
  4. Choose a nearby region.
  5. Choose Start in test mode for the initial setup and create the database.

You do not need to create collections or documents manually. The seed script in step 2 creates the demo profiles; the app saves your own profile and swipe choices.

Test mode is temporary

For this workshop, you use Firestore in test mode. This temporarily allows broad access to your database. Before publishing a real app, you need security rules. See Firestore security rules for that next step.

Enable email/password authentication

  1. Open Authentication in the same Firebase project.
  2. Click Get started if Authentication has not been configured yet.
  3. Open Sign-in method, select Email/Password and enable it.
  4. Save. Email-link sign-in is not needed.

The registration page we build in step 5 will create the user accounts.

Install the command line tools

If you already installed these tools in the earlier chapter, you can reuse them. Otherwise, install the Firebase CLI and FlutterFire CLI:

npm install -g firebase-tools
dart pub global activate flutterfire_cli

Visual summary: Install tools once, reuse them across projects

The Firebase CLI handles Firebase login; the FlutterFire CLI configures the Flutter app.
The Firebase CLI handles Firebase login; the FlutterFire CLI configures the Flutter app.
Key code or decisionWhy you use it
npm install -g firebase-toolsInstalls the firebase command globally, so you can run it from different project folders.
dart pub global activate flutterfire_cliInstalls flutterfire. This tool connects a Flutter project to Firebase; installing it does not connect your app yet.

Log in with the Google account that owns your Firebase project:

firebase login

Visual summary: Sign in as the project developer

firebase login connects your Google account to the CLI, separately from app authentication and seed credentials.
firebase login connects your Google account to the CLI, separately from app authentication and seed credentials.
Key code or decisionWhy you use it
firebase loginAuthorizes the Firebase CLI to work with projects your Google account can access.
Developer loginThis is your development account. App users will sign in separately through FirebaseAuth; the seed script uses a separate service-account key.

PATH

If flutterfire is not found, add Dart's global package folder to your PATH. The installation output shows the folder. Open a new terminal after changing PATH.

Connect the existing Flutter project

Open a terminal in the Match Maker project root, where pubspec.yaml is located:

flutter pub add firebase_core firebase_auth cloud_firestore
flutterfire configure

Visual summary: Add libraries, then choose the Firebase project

Add Firebase libraries, run FlutterFire configuration, and generate firebase_options.dart for your own project.
Add Firebase libraries, run FlutterFire configuration, and generate firebase_options.dart for your own project.
Key code or decisionWhy you use it
firebase_core, firebase_auth, cloud_firestoreProvide initialization, user authentication and database access respectively.
flutterfire configureGenerates platform-specific options for your own Firebase project. You still initialize Firebase in main() later.

Select your own Firebase project and the platforms you will use, including Web for the browser tests. FlutterFire registers or selects the platform apps and generates lib/firebase_options.dart. Re-run it when you add another platform or switch Firebase projects.

If your starter contains Firebase configuration for another project, configure it again for your own project. Check the projectId in the generated options.

We initialize Firebase in main.dart in step 4, while retaining the Match Maker theme. We connect the auth gate in step 5. Do not copy the Travel Notes main.dart or its SetupPage into this app.

See the official Flutter setup guideopen in new window for the CLI configuration flow.

Which configuration and keys do you need?

ItemWhere it comes fromUsed for
Firebase Project IDProject settings → GeneralSelect your project and run the seed script.
lib/firebase_options.dartGenerated by flutterfire configureConnect the Flutter app to your Firebase project. It contains values such as apiKey, appId, messagingSenderId and projectId.
Service-account JSON private keyProject settings → Service accounts → Firebase Admin SDKOnly the local Admin SDK seed script in step 2. Follow that step to download and configure it.

You do not need to copy a Firebase API key into Dart code manually or create a Flutter .env file for this setup. Use the generated options file. The Firebase client API key identifies the project; access to Firestore is governed by Authentication and security rules. See Firebase API keysopen in new window.

Client configuration and administrator credentials

The service-account JSON contains a private key and grants administrative access. Keep it outside the Flutter project and never commit it or add it to Flutter assets. It is separate from the client configuration in firebase_options.dart and must never be used by the Flutter app.

Photos for the demo profiles are already hosted in Supabase Storage. You only store their public URLs in Firestore. Firebase Storage and a Supabase SDK in Flutter are not needed. You do not need a Supabase account, key or .env file.

For Android, open android/app/src/main/AndroidManifest.xml in your Flutter project. Make sure it contains internet permission directly inside <manifest>:

<uses-permission android:name="android.permission.INTERNET" />

Visual summary: Allow the Android app to use the network

The Android manifest declares INTERNET permission for Firebase requests and profile photos.
The Android manifest declares INTERNET permission for Firebase requests and profile photos.
Key code or decisionWhy you use it
android.permission.INTERNETDeclares network access for the Android app, including Firebase requests and online profile photos.
Inside <manifest>Add the permission alongside <application>, rather than inside it. This is an Android setting; your Web app does not use this manifest.

Check: your generated Firebase options contain your project ID. Do not copy the teacher's Firebase configuration.

2. Seed 50 profiles in your own Firestore

Use 50 fictional profiles so you have enough people to swipe through. Complete this setup before writing the app code: your database will already contain the demo profiles when you start building the login flow and repositories.

You import the profiles into your own Firebase project. The generated profile photos are already hosted in a shared, public Supabase Storage bucket. You do not need a Supabase account, a Supabase key or Firebase Storage.

Download the seed scripts

Install Node.js 22 or later. Check your version:

node --version

Visual summary: Check the runtime before running the seed scripts

Check that the Node.js runtime for the seed script is version 22 or later.
Check that the Node.js runtime for the seed script is version 22 or later.
Key code or decisionWhy you use it
node --versionPrints the installed Node.js version. Check that the major version is at least 22 before continuing.
Node.jsRuns the local JavaScript seed script. Your Flutter app itself runs Dart and does not need Node.js at runtime.

Download the Match Maker Firestore seed scripts. Extract the ZIP into your Flutter project root. It contains the folder scripts/seed-profiles/ with the scripts, dependencies and profile data.

From your Flutter project root, run:

npm --prefix scripts/seed-profiles ci

Visual summary: Install the dependencies in the seed-script folder

npm targets the seed-script folder and installs the versions recorded in its lockfile.
npm targets the seed-script folder and installs the versions recorded in its lockfile.
Key code or decisionWhy you use it
--prefix scripts/seed-profilesTargets the Node.js project inside that folder while your terminal stays in the Flutter project root.
ciInstalls the dependency versions recorded in package-lock.json. This prepares the script; it does not write profiles to Firestore.

Prepare your Firebase project

Create the default Cloud Firestore database in your own Firebase project if it does not exist yet. Copy your Project ID from Project settings → General. Use the project ID, not the display name, app ID or API key.

In the same project, open Project settings → Service accounts → Firebase Admin SDK. Choose Generate new private key and save the JSON file outside your Flutter project, for example in your Downloads folder.

Keep the private key local

This JSON file grants administrative access to your Firebase project. Do not share it, commit it to Git or include it in your Flutter assets. Use your own key. The .env file used by the teacher for Supabase uploads is not needed for seeding Firestore.

Run the seeder

In PowerShell, point to the JSON file you downloaded. Replace the example path and your-firebase-project-id with your own values:

$env:GOOGLE_APPLICATION_CREDENTIALS = "C:\Users\your-name\Downloads\service-account.json"
node scripts/seed-profiles/seed.mjs your-firebase-project-id

Visual summary: Seed your database from PowerShell

PowerShell sets the private-key path, then the seed script checks photos and writes 50 documents to your project.
PowerShell sets the private-key path, then the seed script checks photos and writes 50 documents to your project.
Key code or decisionWhy you use it
$env:GOOGLE_APPLICATION_CREDENTIALSPoints the Admin SDK to a local JSON key in this terminal session. The command contains the file path, not the private-key contents.
node .../seed.mjs your-firebase-project-idRuns the script against the selected Firebase project. Replace the example ID and use credentials that have access to that project.
Fixed document IDsRunning the script again updates the same demo profiles. It does not create app accounts or reset swipe history.

On macOS or Linux:

export GOOGLE_APPLICATION_CREDENTIALS="$HOME/Downloads/service-account.json"
node scripts/seed-profiles/seed.mjs your-firebase-project-id

Visual summary: Seed your database from macOS or Linux

macOS or Linux sets the private-key path, then the seed script checks photos and writes 50 documents to your project.
macOS or Linux sets the private-key path, then the seed script checks photos and writes 50 documents to your project.
Key code or decisionWhy you use it
export GOOGLE_APPLICATION_CREDENTIALS=...Points the Admin SDK to a local JSON key in this terminal session. The command contains the file path, not the private-key contents.
node .../seed.mjs your-firebase-project-idRuns the script against the selected Firebase project. Replace the example ID and use credentials that have access to that project.
Fixed document IDsRunning the script again updates the same demo profiles. It does not create app accounts or reset swipe history.

The environment variable applies to the current terminal session. Set it again if you open a new terminal. Logging in with firebase login alone does not configure these Admin SDK credentials.

The script first checks that all 50 photos are publicly available. It then saves the profiles in one batch under profiles/demo-001 through profiles/demo-050. It does not create Authentication accounts or change your swipe history.

Check: open Firestore Database → Data → profiles in the Firebase console. There should be 50 documents with IDs starting with demo-. Open one and check name, age, location, interests and imageUrl.

Running the script again updates the same 50 demo documents without creating duplicates. Other profile documents remain unchanged.

Use the data in the app

You cannot view the profiles in the app yet. After completing the app code through step 7, sign in and open Ontdek to check the seeded data. The model must read the imageUrl field, and the profile card must use Image.network(profile.imageUrl) for these profiles.

The seed script uses administrator credentials. The app uses the temporary test-mode access you enabled in step 1.

Already swiped profiles stay hidden in Ontdek. Re-running the seeder does not reset those choices. Use another app account to repeat the swipe exercise.

Troubleshooting

Message or symptomCheck
Could not load the default credentialsSet GOOGLE_APPLICATION_CREDENTIALS to the full path of your downloaded JSON file.
File not foundCheck the JSON filename and path; keep paths containing spaces inside quotes.
Permission deniedUse your own project ID and a service account with write access to that project.
Database not foundCreate the default Cloud Firestore database in the selected project.
Photo unavailableCheck your internet connection; the script stops before writing profiles if a photo cannot be reached.
Documents exist but the app shows no profilesCheck the app's Firebase project, login state, Firestore rules and saved swipes.

You can validate the dataset without making network requests or database changes:

node scripts/seed-profiles/seed.mjs your-firebase-project-id --dry-run

Visual summary: Check the dataset without importing it

A dry run validates the local profile data without checking the network or writing to Firestore.
A dry run validates the local profile data without checking the network or writing to Firestore.
Key code or decisionWhy you use it
--dry-runChecks the dataset locally without making network requests or changing Firestore.
What this provesIt checks the data, but does not prove that your credentials work or that the photos are currently reachable.

See the Firebase Admin SDK setup documentationopen in new window for more information about these credentials.

3. Replace the local repository with Firestore data

Replace lib/models/profile.dart with the Firestore model. Photos now use imageUrl, which contains a public image URL. A profile without a photo uses an empty string; the card shows a placeholder.

class Profile {
  const Profile({
    required this.id,
    required this.name,
    required this.age,
    required this.location,
    required this.imageUrl,
    required this.interests,
  });
  final String id;
  final String name;
  final int age;
  final String location;
  final String imageUrl;
  final List<String> interests;

  factory Profile.fromMap(String id, Map<String, dynamic> data) => Profile(
    id: id,
    name: data['name'] as String? ?? '',
    age: (data['age'] as num?)?.toInt() ?? 18,
    location: data['location'] as String? ?? '',
    imageUrl: data['imageUrl'] as String? ?? '',
    interests: List<String>.from(data['interests'] ?? []),
  );

  Map<String, dynamic> toMap() => {
    'name': name,
    'age': age,
    'location': location,
    'imageUrl': imageUrl,
    'interests': interests,
  };
}

Visual summary: Translate between Firestore data and a Dart object

Reading converts a Firestore ID and map into a Profile; writing converts Profile fields back to a map without duplicating the ID.
Reading converts a Firestore ID and map into a Profile; writing converts Profile fields back to a map without duplicating the ID.
Key code or decisionWhy you use it
idComes from the Firestore document ID. Keeping it on the model lets you identify a card and save a swipe for that profile.
fromMap(id, data)Converts the database map into typed Dart fields. Defaults cover missing values; num.toInt() handles numeric age values.
toMap()Converts the model back to fields for Firestore. The document ID is passed to .doc(...), so it is not duplicated in the map.
imageUrl and interestsStore a public URL and a list of strings. Widgets receive data rather than needing to understand Firestore documents.

Replace lib/repositories/profile_repository.dart:

Live code: W2-03A, W2-03B, W2-03C. Copy the surrounding code; replace each marked placeholder together.

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

class ProfileRepository {
  final profiles = FirebaseFirestore.instance.collection('profiles');
  String get uid => FirebaseAuth.instance.currentUser!.uid;

  // LIVE W2-03A: Watch the shared profiles
  Stream<List<Profile>> watchProfiles() {
    throw UnimplementedError('W2-03A');
  }

  // LIVE W2-03B: Fetch your own profile once
  Future<Profile?> fetchMyProfile() async {
    throw UnimplementedError('W2-03B');
  }

  // LIVE W2-03C: Save your own profile
  Future<void> saveProfile(Profile profile) {
    throw UnimplementedError('W2-03C');
  }

}

Visual summary: Keep profile database access out of the widgets

Collection snapshots feed Discover, while one-time get and set operations load and save your own profile document.
Collection snapshots feed Discover, while one-time get and set operations load and save your own profile document.
Key code or decisionWhy you use it
uid getterUses the signed-in user. Call these methods after the auth gate has opened the protected app.
watchProfiles()Returns a stream. Each snapshot becomes a list of models; filtering out your own document happens in Dart after the collection is read.
fetchMyProfile()Returns a one-time Future<Profile?>. null means no profile document exists yet, so you can start a new form.
saveProfile() and .set(...)Always target profiles/{your-uid}. Saving replaces that document with the model fields instead of generating another profile document.

All profiles live in a shared profiles collection. The signed-in user's document uses their Authentication UID as its document ID. Do not store email addresses or passwords in these profile documents.

watchProfiles() listens for changes and leaves out your own profile. fetchMyProfile() loads the form values, and saveProfile() saves them.

Create lib/repositories/swipe_repository.dart:

Live code: W2-03D, W2-03E, W2-03F. Copy the surrounding code; replace each marked placeholder together.

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

class SwipeRepository {
  // LIVE W2-03D: Select the current user swipe collection
  CollectionReference<Map<String, dynamic>> get swipes {
    throw UnimplementedError('W2-03D');
  }

  // LIVE W2-03E: Watch the saved choices
  Stream<Map<String, bool>> watchSwipes() {
    throw UnimplementedError('W2-03E');
  }

  // LIVE W2-03F: Save one choice per profile
  Future<void> saveSwipe(Profile profile, {required bool liked}) {
    throw UnimplementedError('W2-03F');
  }

}

Visual summary: Store one decision per user and profile

The user UID selects an account-specific history path, and the profile ID selects a single swipe document; a stream maps saved choices to booleans.
The user UID selects an account-specific history path, and the profile ID selects a single swipe document; a stream maps saved choices to booleans.
Key code or decisionWhy you use it
users/{uid}/swipes/{profileId}Separates each account’s history. Using the profile ID as the document ID means a later save overwrites that same choice.
watchSwipes()Builds a map keyed by profile ID. A key can be absent, present with false, or present with true: unseen, skipped, or liked.
saveSwipe(..., liked: ...)The card gesture and action buttons call the same repository method, so both interactions save the same format.
FieldValue.serverTimestamp()Asks Firestore to record the save time on the server instead of trusting the device clock.

Each user stores their choices under users/{uid}/swipes/{profileId}. A fixed document ID means the same profile has one choice per user.

The data structure is:

profiles/{uid or demo-id}
  name: string
  age: number
  location: string
  interests: list<string>
  imageUrl: string (public photo URL, or empty if no photo is set)

users/{uid}/swipes/{profileId}
  liked: boolean
  createdAt: timestamp

Visual summary: Separate shared profiles from each account’s choices

Shared profile documents hold display data, while each user has swipe documents keyed by the same profile IDs.
Shared profile documents hold display data, while each user has swipe documents keyed by the same profile IDs.
Key code or decisionWhy you use it
profiles/{uid or demo-id}Holds profile details. Your own document ID is your auth UID; fictional profiles use stable demo-... IDs.
users/{uid}/swipes/{profileId}Stores only the choice. The matching profile ID connects it to the shared profile without copying the profile fields.
Path versus protectionThese paths organize the data. With the temporary test-mode rules, this structure alone does not enforce who may read or write it.

The old ProfileRepository.lina no longer exists. In step 4, you replace the old discover page with a placeholder. In step 7, you connect it to the streams.

4. Create dummy pages and connect the navigation

Build the page structure before adding forms or reading Firestore data. You can already switch between Ontdek, Likes, Chats and Profiel at the end of this step. These temporary pages do not call the repositories yet.

Add the temporary pages

Replace lib/pages/discover.dart. Keep the constructor below when you implement the real page in step 7, so HomePage can stay the same:

import 'package:flutter/material.dart';
import '../widgets/match_header.dart';

class DiscoverPage extends StatelessWidget {
  const DiscoverPage({super.key, this.likesOnly = false, this.onProfile});

  final bool likesOnly;
  final VoidCallback? onProfile;

  
  Widget build(BuildContext context) => Center(
    child: ConstrainedBox(
      constraints: const BoxConstraints(maxWidth: 480),
      child: Column(
        children: [
          MatchHeader(onProfile: onProfile),
          Expanded(
            child: Center(
              child: Text(likesOnly ? 'Hier komen jouw likes.' : 'Hier komen de profielen.'),
            ),
          ),
        ],
      ),
    ),
  );
}

Visual summary: Test the page contract before adding database logic

HomePage supplies the tab mode and profile callback; the dummy page renders a header and placeholder without database access.
HomePage supplies the tab mode and profile callback; the dummy page renders a header and placeholder without database access.
Key code or decisionWhy you use it
likesOnlyLets the same page represent two tabs. Only the placeholder text changes for now; later it selects a filter.
VoidCallback? onProfilePasses an action from the parent into the header. The page does not need to know how the parent stores the selected tab.
StatelessWidgetThe dummy page has no changing local data. Keeping its constructor stable lets you replace the internals later without rewriting HomePage.
ExpandedGives the placeholder the remaining height below the header. HomePage supplies the surrounding scaffold and navigation.

Create lib/pages/profile.dart. You will replace this placeholder with the form in step 6:

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) => const Center(
    child: Text('Hier komt jouw profiel.'),
  );
}

Visual summary: Give the profile tab a working destination

The profile tab opens a placeholder now; a later form keeps the same ProfilePage constructor.
The profile tab opens a placeholder now; a later form keeps the same ProfilePage constructor.
Key code or decisionWhy you use it
const ProfilePage()Gives navigation a real widget to open before the form exists.
Center and TextMake it immediately visible which destination you selected. No repository is called, so this step does not require an app login.
Stable constructorYou will replace the stateless placeholder with a stateful form while keeping the parent’s const ProfilePage() call.

Connect the menu and header

Replace lib/widgets/match_navigation.dart with a Material 3 NavigationBar:

import 'package:flutter/material.dart';

class MatchNavigation extends StatelessWidget {
  const MatchNavigation({
    super.key,
    required this.selectedIndex,
    required this.onSelected,
  });
  final int selectedIndex;
  final ValueChanged<int> onSelected;
  
  Widget build(BuildContext context) => NavigationBar(
    selectedIndex: selectedIndex,
    onDestinationSelected: onSelected,
    destinations: const [
      NavigationDestination(
        icon: Icon(Icons.explore_outlined),
        label: 'Ontdek',
      ),
      NavigationDestination(icon: Icon(Icons.favorite_outline), label: 'Likes'),
      NavigationDestination(
        icon: Icon(Icons.chat_bubble_outline),
        label: 'Chats',
      ),
      NavigationDestination(icon: Icon(Icons.person_outline), label: 'Profiel'),
    ],
  );
}

Visual summary: Display selection; report taps to the parent

The parent passes the active index down; a destination tap sends a new index back up through a callback.
The parent passes the active index down; a destination tap sends a new index back up through a callback.
Key code or decisionWhy you use it
selectedIndexIs supplied by the parent. The navigation widget displays the current choice rather than keeping a second copy of it.
ValueChanged<int> onSelectedSends the tapped index back to HomePage. This is a callback carrying one integer.
NavigationDestination orderDefines indices 0 to 3. Keep that order aligned with the pages inside IndexedStack.

Create lib/pages/home.dart:

Live code: W2-01. Copy the surrounding code; replace each marked placeholder together.

import 'package:flutter/material.dart';
import '../widgets/match_navigation.dart';
import 'discover.dart';
import 'profile.dart';

// LIVE W2-01: Build the first StatefulWidget
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  
  Widget build(BuildContext context) => const Center(
    child: Text('LIVE W2-01: connect the navigation together.'),
  );
}

// COPY: the layout is provided; connect it in W2-01.
Widget buildHomeLayout({
  required int selectedIndex,
  required ValueChanged<int> onSelected,
  required VoidCallback onProfile,
}) => Scaffold(
    body: SafeArea(
      child: IndexedStack(
        index: selectedIndex,
        children: [
          DiscoverPage(onProfile: onProfile),
          DiscoverPage(
            likesOnly: true,
            onProfile: onProfile,
          ),
          const Center(
            child: Padding(
              padding: EdgeInsets.all(24),
              child: Text(
                'Chats\nBerichten versturen komt in een volgende uitbreiding.',
                textAlign: TextAlign.center,
              ),
            ),
          ),
          const ProfilePage(),
        ],
      ),
    ),
    bottomNavigationBar: MatchNavigation(
      selectedIndex: selectedIndex,
      onSelected: onSelected,
    ),
  );

Visual summary: Own the selected tab in one place

HomePage stores selectedIndex, and IndexedStack shows one of four mounted children; both navigation and the header can change the index.
HomePage stores selectedIndex, and IndexedStack shows one of four mounted children; both navigation and the header can change the index.
Key code or decisionWhy you use it
selectedIndexIs local screen state. Changing tabs does not need a Firestore write or an extra state-management package.
IndexedStackKeeps the children in the widget tree while showing one. Hidden pages remain mounted; their state and later stream listeners remain active.
DiscoverPage(likesOnly: true)Reuses the page implementation for Likes. The two page instances keep their own widget state.
onProfile: () => setState(...)Makes the header shortcut select index 3 through the same state used by the bottom menu.
Scaffold and SafeAreaProvide one page frame and bottom navigation. Child pages render content without adding another navigation bar.

selectedIndex is local state. IndexedStack changes the visible page while retaining the state of the other pages. The navigation belongs to HomePage; remove the old bottom navigation and surrounding Scaffold from DiscoverPage.

Replace lib/widgets/match_header.dart. The profile button uses a callback to open the profile tab. The unused preferences button is removed:

import 'package:flutter/material.dart';

class MatchHeader extends StatelessWidget {
  const MatchHeader({super.key, this.onProfile});

  final VoidCallback? onProfile;

  
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.fromLTRB(20, 12, 16, 10),
    child: Row(
      children: [
        Container(
          width: 38,
          height: 38,
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.primary,
            borderRadius: BorderRadius.circular(12),
          ),
          child: const Icon(Icons.favorite_rounded, color: Colors.white),
        ),
        const SizedBox(width: 10),
        const Expanded(
          child: Text(
            "It's a Match!",
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
          ),
        ),
        IconButton(
          tooltip: 'Jouw profiel',
          onPressed: onProfile,
          icon: const Icon(Icons.person_outline_rounded),
        ),
      ],
    ),
  );
}

Visual summary: Let the header request navigation through a callback

The header’s profile button invokes a supplied callback, leaving HomePage responsible for changing tabs.
The header’s profile button invokes a supplied callback, leaving HomePage responsible for changing tabs.
Key code or decisionWhy you use it
VoidCallback? onProfileMakes the header reusable: it announces a tap, while the parent decides what that tap does.
Expanded around the titleUses the remaining row width and leaves room for the profile button.
onPressed: onProfileConnects the button directly to the supplied callback. If it is null, Flutter disables the button.

Replace lib/main.dart. This keeps the theme from workshop 1 and initializes Firebase before runApp. For now, open HomePage directly to test the navigation:

Live code: W2-02. Copy the surrounding code; replace each marked placeholder together.

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';
import 'pages/home.dart';

// LIVE W2-02: Initialize Firebase before starting the app
Future<void> main() async {
  throw UnimplementedError('W2-02');
}


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

  
  Widget build(BuildContext context) {
    const matchPink = Color(0xFFE94B78);
    final textTheme = ThemeData.light().textTheme.apply(
      bodyColor: const Color(0xFF302531),
      displayColor: const Color(0xFF302531),
    );

    return MaterialApp(
      title: "It's a Match!",
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: matchPink,
          primary: matchPink,
        ),
        scaffoldBackgroundColor: const Color(0xFFFBF7F5),
        textTheme: textTheme,
      ),
      home: const HomePage(),
    );
  }
}

Visual summary: Initialize Firebase, then test the app shell

Startup prepares Flutter, awaits Firebase initialization, and opens the dummy navigation shell with the shared app theme.
Startup prepares Flutter, awaits Firebase initialization, and opens the dummy navigation shell with the shared app theme.
Key code or decisionWhy you use it
WidgetsFlutterBinding.ensureInitialized()Prepares Flutter before using a plugin during startup, before runApp().
await Firebase.initializeApp(...)Completes Firebase initialization before widgets can use Firebase services. The generated options select the platform configuration.
home: const HomePage()Temporarily opens the dummy pages directly. They do not use account-dependent repository calls yet.
ThemeDataKeeps the Match Maker colours and typography in one place for all pages.
flutter run -d chrome

Visual summary: Run the navigation checkpoint in Chrome

Run the Flutter app in Chrome and manually check all dummy tabs and the profile shortcut.
Run the Flutter app in Chrome and manually check all dummy tabs and the profile shortcut.
Key code or decisionWhy you use it
-d chromeSelects Chrome as the target device for this run.
What you checkEach tab shows the expected placeholder, and the header’s profile button opens Profiel. Firebase-backed page content is added later.

Check: switch between all four tabs and use the profile icon in the header. You should see a different placeholder on each tab. Chats remains an information page throughout this workshop. Login is added in the next step.

5. Add login and registration

Create lib/services/auth_service.dart:

Live code: W2-04. Copy the surrounding code; replace each marked placeholder together.

import 'package:firebase_auth/firebase_auth.dart';

// LIVE W2-04: Connect Firebase Authentication
class AuthService {
  Stream<User?> get authStateChanges => throw UnimplementedError('W2-04');
  User? get currentUser => throw UnimplementedError('W2-04');

  Future<void> signIn({required String email, required String password}) {
    throw UnimplementedError('W2-04');
  }

  Future<void> register({required String email, required String password}) {
    throw UnimplementedError('W2-04');
  }

  Future<void> signOut() {
    throw UnimplementedError('W2-04');
  }
}

Visual summary: Wrap Firebase Authentication behind a small service

Pages call AuthService for account commands; FirebaseAuth emits User or null through a stream to the auth gate.
Pages call AuthService for account commands; FirebaseAuth emits User or null through a stream to the auth gate.
Key code or decisionWhy you use it
Future<void> methodsRepresent operations that finish later and can throw. The page awaits them to manage loading and show errors.
register()Creates the account and signs it in. Saving a profile document is a separate step handled by ProfileRepository.
authStateChangesProvides the initial authentication state and later sign-in/sign-out events to the auth gate.
currentUserReads the currently signed-in user, or null if signed out. The protected profile page uses the UID when saving.

Create lib/pages/login.dart using the same structure as Authentication and Forms and validation:

  • A StatefulWidget named LoginPage.
  • A Form with TextFormField widgets for email and password.
  • Controllers for both fields; dispose them when the page is removed.
  • isRegistering switches between login and registration.
  • isLoading disables submit and mode-switch buttons while a request runs.
  • Validate the email and a password of at least six characters before calling the service.
  • Trim the email, but keep the password unchanged.
  • Catch FirebaseAuthException and show its message.
  • Check mounted before calling setState after an await.

Copy this starter and complete its LIVE block together:

Live code: W2-05. Copy the surrounding code; replace each marked placeholder together.

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import '../services/auth_service.dart';

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

  
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final formKey = GlobalKey<FormState>();
  final emailController = TextEditingController();
  final passwordController = TextEditingController();
  final authService = AuthService();
  bool isRegistering = false;
  bool isLoading = false;
  String? errorMessage;

  
  void dispose() {
    emailController.dispose();
    passwordController.dispose();
    super.dispose();
  }

  // LIVE W2-05: Submit login or registration
  Future<void> _submit() async {
    throw UnimplementedError('W2-05');
  }

  
  Widget build(BuildContext context) => Scaffold(
    body: SafeArea(
      child: Center(
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(24),
          child: ConstrainedBox(
            constraints: const BoxConstraints(maxWidth: 400),
            child: Form(
              key: formKey,
              child: Column(
                mainAxisSize: MainAxisSize.min,
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  Text(
                    isRegistering ? 'Maak een account' : 'Welkom bij Match Maker',
                    style: Theme.of(context).textTheme.headlineSmall,
                  ),
                  const SizedBox(height: 24),
                  TextFormField(
                    controller: emailController,
                    enabled: !isLoading,
                    keyboardType: TextInputType.emailAddress,
                    autocorrect: false,
                    decoration: const InputDecoration(labelText: 'E-mail'),
                    validator: (value) {
                      final email = value?.trim() ?? '';
                      if (!RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$').hasMatch(email)) {
                        return 'Vul een geldig e-mailadres in.';
                      }
                      return null;
                    },
                  ),
                  const SizedBox(height: 16),
                  TextFormField(
                    controller: passwordController,
                    enabled: !isLoading,
                    obscureText: true,
                    autocorrect: false,
                    enableSuggestions: false,
                    decoration: const InputDecoration(labelText: 'Wachtwoord'),
                    validator: (value) => (value ?? '').length < 6
                        ? 'Gebruik minstens zes tekens.'
                        : null,
                  ),
                  if (errorMessage != null) ...[
                    const SizedBox(height: 16),
                    Text(
                      errorMessage!,
                      style: TextStyle(color: Theme.of(context).colorScheme.error),
                    ),
                  ],
                  const SizedBox(height: 24),
                  FilledButton(
                    onPressed: isLoading ? null : _submit,
                    child: Text(
                      isLoading
                          ? 'Even wachten...'
                          : isRegistering ? 'Registreren' : 'Aanmelden',
                    ),
                  ),
                  TextButton(
                    onPressed: isLoading
                        ? null
                        : () => setState(() {
                            isRegistering = !isRegistering;
                            errorMessage = null;
                          }),
                    child: Text(
                      isRegistering
                          ? 'Al een account? Meld je aan'
                          : 'Nog geen account? Registreer',
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    ),
  );
}

Visual summary: Validate first, then run one authentication request

The form validates input, disables controls during one request, and either shows an error or lets the auth gate respond to success.
The form validates input, disables controls during one request, and either shows an error or lets the auth gate respond to success.
Key code or decisionWhy you use it
GlobalKey<FormState> and validatorsLet the submit method validate both fields before making a request. The email check is basic format validation; Firebase still validates the account operation.
Controllers and dispose()Read the current field text, then release the controllers when the page is removed.
Email .trim(); password unchangedRemoves accidental spaces around the email without silently changing the password the user entered.
isRegistering and isLoadingChoose the operation and prevent duplicate submissions or mode changes while a request is running.
catch, finally, mountedShow request errors and restore loading state only while the widget is still mounted; a successful sign-in may already have removed it.
No route push on successThe auth gate reacts to the authentication event. This keeps login and logout on one navigation path.

Next, connect this page to the auth gate. After a successful request, the auth stream opens the app automatically.

Registration errors

A 400 Bad Request from accounts:signUp is an Authentication error. Check the response's error.message in the browser Network tab. For OPERATION_NOT_ALLOWED, enable Email/Password in the Firebase console. Firestore security rules do not control account registration.

Connect the auth gate

Create lib/pages/auth_gate.dart:

Live code: W2-06. Copy the surrounding code; replace each marked placeholder together.

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
import 'home.dart';
import 'login.dart';

class AuthGate extends StatefulWidget {
  const AuthGate({super.key});
  
  State<AuthGate> createState() => _AuthGateState();
}

// LIVE W2-06: Choose the page from the auth stream
class _AuthGateState extends State<AuthGate> {
  
  Widget build(BuildContext context) => const Scaffold(
    body: Center(child: Text('LIVE W2-06: connect the auth stream together.')),
  );
}

Visual summary: Choose a screen from the authentication stream

The auth gate handles waiting and errors, then shows LoginPage for null or a HomePage keyed by the signed-in UID.
The auth gate handles waiting and errors, then shows LoginPage for null or a HomePage keyed by the signed-in UID.
Key code or decisionWhy you use it
Stored authStreamKeeps the stream reference in state, so a rebuild does not request another stream from the service.
StreamBuilder<User?>Rebuilds from authentication events. Handle error and waiting states before deciding whether a user is present.
snapshot.hasDataA non-null User opens the app; a null user shows login after the waiting state is handled.
ValueKey(snapshot.data!.uid)Gives each account a distinct HomePage identity. An account change recreates that account’s page state.

Replace lib/main.dart. This keeps the theme from workshop 1 and initializes Firebase before runApp:

Live code: W2-02. Copy the surrounding code; replace each marked placeholder together.

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'firebase_options.dart';
import 'pages/auth_gate.dart';

// LIVE W2-02: Initialize Firebase before starting the app
Future<void> main() async {
  throw UnimplementedError('W2-02');
}


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

  
  Widget build(BuildContext context) {
    const matchPink = Color(0xFFE94B78);
    final textTheme = ThemeData.light().textTheme.apply(
      bodyColor: const Color(0xFF302531),
      displayColor: const Color(0xFF302531),
    );

    return MaterialApp(
      title: "It's a Match!",
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: matchPink,
          primary: matchPink,
        ),
        scaffoldBackgroundColor: const Color(0xFFFBF7F5),
        textTheme: textTheme,
      ),
      home: const AuthGate(),
    );
  }
}

Visual summary: Start with the auth gate instead of opening HomePage directly

After the same Firebase initialization and theme setup, MaterialApp opens AuthGate to choose login or the app.
After the same Firebase initialization and theme setup, MaterialApp opens AuthGate to choose login or the app.
Key code or decisionWhy you use it
import ...auth_gate.dart and home: const AuthGate()Replace the direct HomePage entry from step 4. The gate now decides whether an app account is signed in.
Startup and themeStay the same as the previous main snippet. Authentication changes the entry widget, not the Firebase setup or app styling.
Protected app flowAccount-dependent pages are now created after sign-in. Production data protection still requires Firestore rules; a UI gate alone does not secure a database.

The auth stream switches between the login page and the app. After registering or signing in, you return to the dummy pages from step 4. Login and logout do not need manual route pushes.

Check: restart the app. If you are signed out, the login page appears. Register an account and check that you can use the bottom navigation again.

6. Add the profile form and sign-out button

Replace the dummy page in lib/pages/profile.dart with a StatefulWidget named ProfilePage. Follow the form approach from Forms and validation:

  1. Use controllers for name, age, location, comma-separated interests and a photo URL.
  2. Call fetchMyProfile() from initState through an async helper; fill the controllers.
  3. Show loading, failure and retry states. Do not show an empty editable form when loading fails.
  4. Validate a non-empty name and location and an integer age from 18 to 120.
  5. Add an optional public photo URL. If entered, validate that it starts with http:// or https:// and has a host.
  6. On save, create a Profile with the signed-in user's UID and call saveProfile().
  7. Split and trim interests, and remove empty entries.
  8. Disable saving and signing out while a request is running; show a SnackBar on success or failure.
  9. Add an Uitloggen button that calls AuthService().signOut().

Copy this complete implementation; discuss the load and save calls together:

import 'package:flutter/material.dart';
import '../models/profile.dart';
import '../repositories/profile_repository.dart';
import '../services/auth_service.dart';

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

  
  State<ProfilePage> createState() => _ProfilePageState();
}

class _ProfilePageState extends State<ProfilePage> {
  final formKey = GlobalKey<FormState>();
  final nameController = TextEditingController();
  final ageController = TextEditingController();
  final locationController = TextEditingController();
  final interestsController = TextEditingController();
  final imageUrlController = TextEditingController();
  final repository = ProfileRepository();
  final authService = AuthService();
  bool isLoading = true;
  bool isSaving = false;
  String? loadError;

  
  void initState() {
    super.initState();
    _loadProfile();
  }

  
  void dispose() {
    nameController.dispose();
    ageController.dispose();
    locationController.dispose();
    interestsController.dispose();
    imageUrlController.dispose();
    super.dispose();
  }

  Future<void> _loadProfile() async {
    setState(() {
      isLoading = true;
      loadError = null;
    });
    try {
      final profile = await repository.fetchMyProfile();
      if (!mounted) return;
      if (profile != null) {
        nameController.text = profile.name;
        ageController.text = profile.age.toString();
        locationController.text = profile.location;
        interestsController.text = profile.interests.join(', ');
        imageUrlController.text = profile.imageUrl;
      }
    } catch (_) {
      if (mounted) {
        setState(() => loadError = 'Profiel laden mislukt. Probeer opnieuw.');
      }
    } finally {
      if (mounted) setState(() => isLoading = false);
    }
  }

  void _showMessage(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(message)),
    );
  }

  Future<void> _saveProfile() async {
    if (isSaving || !formKey.currentState!.validate()) return;
    setState(() => isSaving = true);
    try {
      final profile = Profile(
        id: authService.currentUser!.uid,
        name: nameController.text.trim(),
        age: int.parse(ageController.text.trim()),
        location: locationController.text.trim(),
        imageUrl: imageUrlController.text.trim(),
        interests: interestsController.text
            .split(',')
            .map((interest) => interest.trim())
            .where((interest) => interest.isNotEmpty)
            .toList(),
      );
      await repository.saveProfile(profile);
      if (mounted) _showMessage('Profiel opgeslagen.');
    } catch (_) {
      if (mounted) _showMessage('Opslaan mislukt. Probeer opnieuw.');
    } finally {
      if (mounted) setState(() => isSaving = false);
    }
  }

  Future<void> _signOut() async {
    if (isSaving) return;
    setState(() => isSaving = true);
    try {
      await authService.signOut();
    } catch (_) {
      if (mounted) _showMessage('Uitloggen mislukt. Probeer opnieuw.');
    } finally {
      if (mounted) setState(() => isSaving = false);
    }
  }

  
  Widget build(BuildContext context) {
    if (isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (loadError != null) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(loadError!, textAlign: TextAlign.center),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: _loadProfile,
              child: const Text('Opnieuw proberen'),
            ),
            TextButton(
              onPressed: isSaving ? null : _signOut,
              child: const Text('Uitloggen'),
            ),
          ],
        ),
      );
    }
    return Center(
      child: ConstrainedBox(
        constraints: const BoxConstraints(maxWidth: 480),
        child: SingleChildScrollView(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: formKey,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Text('Jouw profiel', style: Theme.of(context).textTheme.headlineSmall),
                const SizedBox(height: 24),
                TextFormField(
                  controller: nameController,
                  enabled: !isSaving,
                  decoration: const InputDecoration(labelText: 'Naam'),
                  validator: (value) => (value?.trim() ?? '').isEmpty
                      ? 'Vul je naam in.'
                      : null,
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: ageController,
                  enabled: !isSaving,
                  keyboardType: TextInputType.number,
                  decoration: const InputDecoration(labelText: 'Leeftijd'),
                  validator: (value) {
                    final age = int.tryParse(value?.trim() ?? '');
                    return age == null || age < 18 || age > 120
                        ? 'Vul een leeftijd van 18 tot 120 in.'
                        : null;
                  },
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: locationController,
                  enabled: !isSaving,
                  decoration: const InputDecoration(labelText: 'Woonplaats'),
                  validator: (value) => (value?.trim() ?? '').isEmpty
                      ? 'Vul je woonplaats in.'
                      : null,
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: interestsController,
                  enabled: !isSaving,
                  decoration: const InputDecoration(
                    labelText: 'Interesses',
                    helperText: 'Bijvoorbeeld: Koffie, Reizen, Muziek',
                  ),
                ),
                const SizedBox(height: 16),
                TextFormField(
                  controller: imageUrlController,
                  enabled: !isSaving,
                  keyboardType: TextInputType.url,
                  autocorrect: false,
                  decoration: const InputDecoration(
                    labelText: 'Foto-URL (optioneel)',
                    helperText: 'Een publieke link naar een afbeelding.',
                  ),
                  validator: (value) {
                    final url = value?.trim() ?? '';
                    if (url.isEmpty) return null;
                    final uri = Uri.tryParse(url);
                    if (uri == null ||
                        !['http', 'https'].contains(uri.scheme) ||
                        uri.host.isEmpty) {
                      return 'Vul een geldige http- of https-URL in.';
                    }
                    return null;
                  },
                ),
                const SizedBox(height: 24),
                FilledButton(
                  onPressed: isSaving ? null : _saveProfile,
                  child: Text(isSaving ? 'Even wachten...' : 'Profiel opslaan'),
                ),
                TextButton(
                  onPressed: isSaving ? null : _signOut,
                  child: const Text('Uitloggen'),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Visual summary: Load, edit and save your own profile

The profile page fetches once, fills controllers, validates and saves to your UID, while sign-out returns through the auth gate.
The profile page fetches once, fills controllers, validates and saves to your UID, while sign-out returns through the auth gate.
Key code or decisionWhy you use it
initState() and _loadProfile()Load once when the page is mounted. A missing document gives an empty new form; a failed request gives a retry screen instead.
Controllers and dispose()Keep editable field values outside build(). Filling controllers after the fetch displays the saved values without fetching on each rebuild.
Age and URL validatorsAccept an integer age from 18 to 120 and an optional HTTP(S) URL with a host. A valid URL format does not guarantee the image can be downloaded.
split, trim, whereTurn comma-separated interests into clean list entries and remove empty ones. The Firestore model receives a list, not one long string.
isSaving, try/catch/finally, mountedDisable edits, save and sign-out during a request, show feedback, and avoid updating a page that has been removed.
currentUser!.uidIdentifies the profile owner after the auth gate. The repository also targets that UID rather than creating a random document.

The form edits only the current user's profile. Demo profiles do not have corresponding Authentication accounts and are not edited through this form. You can leave the photo URL empty or use a public image URL, such as an imageUrl from one of your seeded profiles. Uploading photos is outside this workshop.

Check: open Profiel, save your details and sign out. Sign in again and check that the saved values return. Ontdek and Likes still show their placeholders.

7. Show and swipe Firestore profiles

Replace the dummy lib/pages/discover.dart with the version below. Keep the same constructor so both Ontdek and Likes in HomePage use the completed page.

First identify the responsibilities:

  • The profiles stream provides the shared profiles.
  • The swipes stream provides your saved choices.
  • Ontdek filters out every profile you already swiped.
  • Likes filters for choices where liked is true.
  • Loading, error and empty states appear before we build a card.
  • The streams are kept in state so a button press does not recreate the queries.

Dismissible detects horizontal swipes. We save the choice in confirmDismiss and return false: the Firestore stream removes the reviewed profile from the list. A failed write must leave the profile available to try again.

Live code: W2-07A, W2-07B, W2-08B, W2-08C, W2-08A. Copy the surrounding code; replace each marked placeholder together.

import 'package:flutter/material.dart';
import '../models/profile.dart';
import '../repositories/profile_repository.dart';
import '../repositories/swipe_repository.dart';
import '../widgets/profile_card.dart';
import '../widgets/action_buttons.dart';
import '../widgets/match_header.dart';

class DiscoverPage extends StatefulWidget {
  const DiscoverPage({super.key, this.likesOnly = false, this.onProfile});
  final bool likesOnly;
  final VoidCallback? onProfile;
  
  State<DiscoverPage> createState() => _DiscoverPageState();
}

class _DiscoverPageState extends State<DiscoverPage> {
  final SwipeRepository repository = SwipeRepository();
  // LIVE W2-07A: Keep the Firestore streams in state
  final Stream<List<Profile>>? profilesStream = null;
  final Stream<Map<String, bool>>? swipesStream = null;
  bool isSaving = false;

  
  Widget build(BuildContext context) => Center(
    child: ConstrainedBox(
      constraints: const BoxConstraints(maxWidth: 480),
      child: Column(
        children: [
          MatchHeader(onProfile: widget.onProfile),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              widget.likesOnly ? 'Jouw likes' : 'Een klik begint hier.',
              style: const TextStyle(fontSize: 27, fontWeight: FontWeight.w800),
            ),
          ),
          Expanded(
            child: StreamBuilder<List<Profile>>(
              stream: profilesStream,
              builder: (context, profilesSnapshot) => StreamBuilder<Map<String, bool>>(
                stream: swipesStream,
                builder: (context, swipesSnapshot) {
                  // LIVE W2-07B: Check snapshots and filter profiles
                  final List<Profile> profiles = [];
                  if (profiles.isEmpty) {
                    return Center(
                      child: Padding(
                        padding: const EdgeInsets.all(24),
                        child: Text(
                          widget.likesOnly
                              ? 'Nog geen likes. Swipe naar rechts bij Ontdek.'
                              : 'Geen nieuwe profielen. Vul je profiel in en laat iemand anders een account maken.',
                          textAlign: TextAlign.center,
                        ),
                      ),
                    );
                  }
                  if (widget.likesOnly) {
                    return ListView.builder(
                      itemCount: profiles.length,
                      itemBuilder: (context, index) => ListTile(
                        leading: const Icon(
                          Icons.favorite,
                          color: Color(0xFFE94B78),
                        ),
                        title: Text(
                          '${profiles[index].name}, ${profiles[index].age}',
                        ),
                        subtitle: Text(profiles[index].location),
                      ),
                    );
                  }
                  final profile = profiles.first;
                  return SingleChildScrollView(
                    padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
                    child: Column(
                      children: [
                        // confirmDismiss bewaart eerst in Firestore. De stream vervangt daarna de kaart.
                        Dismissible(
                          key: ValueKey(profile.id),
                          direction: isSaving
                              ? DismissDirection.none
                              : DismissDirection.horizontal,
                          background: const Center(
                            child: Icon(
                              Icons.favorite,
                              color: Colors.pink,
                              size: 60,
                            ),
                          ),
                          secondaryBackground: const Center(
                            child: Icon(
                              Icons.close,
                              color: Colors.red,
                              size: 60,
                            ),
                          ),
                          // LIVE W2-08B: Connect the swipe gesture
                          confirmDismiss: (direction) async => false,
                          child: ProfileCard(profile: profile),
                        ),
                        const SizedBox(height: 22),
                        // LIVE W2-08C: Connect the action buttons
                        ActionButtons(
                          onReject: null,
                          onLike: null,
                        ),
                        const SizedBox(height: 12),
                        Text(
                          isSaving
                              ? 'Opslaan...'
                              : 'Links: overslaan · Rechts: liken',
                        ),
                      ],
                    ),
                  );
                },
              ),
            ),
          ),
        ],
      ),
    ),
  );

  // LIVE W2-08A: Save a swipe with loading and error handling
  Future<void> _swipe(Profile profile, bool liked) async {
    throw UnimplementedError('W2-08A');
  }

}

Visual summary: Combine profile data with your saved choices

Two streams provide profiles and choices; filters select Discover or Likes content; gestures and buttons save through one method and stream updates refresh the result.
Two streams provide profiles and choices; filters select Discover or Likes content; gestures and buttons save through one method and stream updates refresh the result.
Key code or decisionWhy you use it
Streams stored in stateKeep subscriptions tied to the page instead of creating new stream objects on each button-triggered rebuild.
Nested StreamBuilder widgetsProvide the latest profile list and swipe map. Both must have data before you filter; errors are handled before building a card.
!swipes.containsKey(id) versus swipes[id] == trueOntdek excludes both skips and likes. Likes includes only true; checking for false would incorrectly mix skipped and unseen profiles.
ValueKey(profile.id)Ties a Dismissible card to its profile, so the next profile gets its own widget identity.
confirmDismiss returns falseThe gesture triggers a save, while the stream controls which card is shown. Firestore may emit local changes before the server confirms them; a rejected write can bring the profile back.
_swipe() and isSavingUse one write path for gestures and buttons, block overlapping requests, and show a SnackBar if the write fails.

Replace lib/widgets/action_buttons.dart with callbacks for the same save method:

import 'package:flutter/material.dart';

class ActionButtons extends StatelessWidget {
  const ActionButtons({
    super.key,
    required this.onReject,
    required this.onLike,
  });
  final VoidCallback? onReject;
  final VoidCallback? onLike;
  
  Widget build(BuildContext context) => Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      IconButton.filledTonal(
        tooltip: 'Weigeren',
        iconSize: 34,
        onPressed: onReject,
        icon: const Icon(Icons.close_rounded),
      ),
      const SizedBox(width: 28),
      IconButton.filled(
        tooltip: 'Liken',
        iconSize: 34,
        onPressed: onLike,
        icon: const Icon(Icons.favorite_rounded),
      ),
    ],
  );
}

Visual summary: Send intent to the page; keep persistence in one place

Buttons invoke parent callbacks for like or reject, and null callbacks disable them while the shared save method is running.
Buttons invoke parent callbacks for like or reject, and null callbacks disable them while the shared save method is running.
Key code or decisionWhy you use it
VoidCallback? onReject and onLikeKeep this widget focused on buttons. The parent supplies callbacks for rejecting and liking; the widget itself does not call Firestore.
Nullable onPressedA null callback disables an IconButton. The parent can prevent another save without adding separate loading state here.
Same method as the gestureButtons and swipes reach _swipe(), so error handling and saved data stay consistent.

Replace lib/widgets/profile_card.dart. This keeps the rounded card, gradient and profile text from workshop 1, and loads photos from imageUrl. A missing or unavailable photo shows a placeholder:

import 'package:flutter/material.dart';
import '../models/profile.dart';

class ProfileCard extends StatelessWidget {
  const ProfileCard({super.key, required this.profile});

  final Profile profile;

  Widget _photoPlaceholder() => const ColoredBox(
    color: Color(0xFF877A83),
    child: Center(
      child: Icon(Icons.person, size: 80, color: Colors.white),
    ),
  );

  
  Widget build(BuildContext context) => Container(
    decoration: BoxDecoration(
      borderRadius: BorderRadius.circular(28),
      boxShadow: const [
        BoxShadow(
          color: Color(0x26302531),
          blurRadius: 24,
          offset: Offset(0, 10),
        ),
      ],
    ),
    child: LayoutBuilder(
      builder: (context, constraints) => ConstrainedBox(
        constraints: BoxConstraints(minHeight: constraints.maxWidth * 1.25),
        child: ClipRRect(
          borderRadius: BorderRadius.circular(28),
          child: Stack(
            alignment: Alignment.bottomLeft,
            children: [
              Positioned.fill(
                child: profile.imageUrl.isNotEmpty
                    ? Image.network(
                        profile.imageUrl,
                        fit: BoxFit.cover,
                        alignment: const Alignment(0, -0.4),
                        semanticLabel: 'Profielfoto van ${profile.name}',
                        errorBuilder: (context, error, stackTrace) => _photoPlaceholder(),
                      )
                    : _photoPlaceholder(),
              ),
              const Positioned.fill(
                child: DecoratedBox(
                  decoration: BoxDecoration(
                    gradient: LinearGradient(
                      begin: Alignment.topCenter,
                      end: Alignment.bottomCenter,
                      stops: [0, 0.35, 0.7, 1],
                      colors: [
                        Color(0x26000000),
                        Colors.transparent,
                        Color(0x66000000),
                        Color(0xE6191320),
                      ],
                    ),
                  ),
                ),
              ),
              Positioned(
                top: 20,
                left: 20,
                child: Container(
                  padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
                  decoration: BoxDecoration(
                    color: const Color(0xCCFFFFFF),
                    borderRadius: BorderRadius.circular(20),
                  ),
                  child: const Text(
                    'PROFIEL',
                    style: TextStyle(
                      fontSize: 10,
                      fontWeight: FontWeight.w800,
                      letterSpacing: 1,
                    ),
                  ),
                ),
              ),
              Padding(
                padding: const EdgeInsets.fromLTRB(24, 110, 24, 24),
                child: DefaultTextStyle(
                  style: const TextStyle(color: Colors.white),
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        '${profile.name}, ${profile.age}',
                        style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w800),
                      ),
                      const SizedBox(height: 8),
                      Row(
                        children: [
                          const Icon(Icons.location_on_outlined, size: 18, color: Colors.white),
                          const SizedBox(width: 6),
                          Expanded(child: Text(profile.location)),
                        ],
                      ),
                      const SizedBox(height: 16),
                      Wrap(
                        spacing: 7,
                        runSpacing: 7,
                        children: [
                          for (final interest in profile.interests)
                            Container(
                              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
                              decoration: BoxDecoration(
                                color: const Color(0x33FFFFFF),
                                borderRadius: BorderRadius.circular(20),
                              ),
                              child: Text(interest),
                            ),
                        ],
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    ),
  );
}

Visual summary: Layer the photo and text while letting the card grow

An outer shadow surrounds a rounded clipped Stack: photo, gradient, badge and non-positioned text; a minimum height allows the card to grow.
An outer shadow surrounds a rounded clipped Stack: photo, gradient, badge and non-positioned text; a minimum height allows the card to grow.
Key code or decisionWhy you use it
Outer Container versus ClipRRectPaint the shadow outside the clip, while clipping the image and overlays to the rounded card shape.
LayoutBuilder and ConstrainedBoxBase the minimum card height on its width. This is a minimum, so extra text can make the card taller.
Positioned.fillMakes the photo and gradient cover the Stack without deciding its height. The non-positioned profile text contributes to the card size.
Image.network and _photoPlaceholder()Load the public URL. An empty URL or a download error shows a person icon instead of a broken card.
Gradient and WrapKeep white text readable over the photo and move interest tags onto another line when there is not enough width.

The static ONLINE badge is now PROFIEL: we do not track online presence. For the seeded profiles, the location is a city; the app does not calculate distance.

8. Test the complete flow

flutter analyze
flutter run -d chrome

Visual summary: Use static analysis and manual testing for different questions

Static analysis checks Dart code, while running in Chrome lets you test Firebase behaviour and layout manually.
Static analysis checks Dart code, while running in Chrome lets you test Firebase behaviour and layout manually.
Key code or decisionWhy you use it
flutter analyzeChecks source code without exercising your Firebase project. It cannot prove that authentication is enabled, database access works, or photos load.
flutter run -d chromeRuns the app so you can check real behaviour and database changes with the checklist below. This is separate from flutter test.

Check the following:

  1. Register an account; the login page should switch to the protected app.
  2. Open Profiel, enter valid details and save. Check profiles/{your-uid} in Firestore.
  3. Open Ontdek: a seeded profile and its network photo should appear.
  4. Swipe right. Check users/{your-uid}/swipes/{profileId}: liked should be true.
  5. Check Likes and swipe left on another profile. A skipped profile must not appear in Likes.
  6. Try both action buttons and the header's profile icon.
  7. Refresh the browser: saved choices should remain and reviewed profiles stay hidden.
  8. Open Chats: the information page should be visible.
  9. Log out and sign in again. Create a second account to check that swipe histories stay separate.

Also check a narrow window and a short landscape window. The card should scroll without covering the bottom navigation. If loading or saving fails, the page should show a useful message rather than silently dropping the request.

The generated counter test no longer matches this app. Update it before running flutter test, as discussed in workshop 1.

Finished structure:

lib/
  main.dart
  firebase_options.dart
  models/profile.dart
  services/auth_service.dart
  repositories/profile_repository.dart
  repositories/swipe_repository.dart
  pages/auth_gate.dart
  pages/login.dart
  pages/home.dart
  pages/discover.dart
  pages/profile.dart
  widgets/match_header.dart
  widgets/profile_card.dart
  widgets/action_buttons.dart
  widgets/match_navigation.dart
scripts/
  seed-profiles/
    seed.mjs
    data.mjs
    profiles.json
    package.json
    package-lock.json

Visual summary: Follow the responsibilities across the folders

Pages coordinate UI and state, models describe data, repositories access Firestore, services handle auth, and seed scripts run separately.
Pages coordinate UI and state, models describe data, repositories access Firestore, services handle auth, and seed scripts run separately.
Key code or decisionWhy you use it
pages/ and widgets/Pages coordinate state and user actions. Widgets receive data and callbacks, keeping reusable UI separate from persistence.
models/, repositories/, services/Models describe data; repositories handle Firestore; the authentication service wraps FirebaseAuth.
firebase_options.dart and scripts/The generated options belong to the Flutter app. The Node.js seeder runs locally with separate administrative credentials.