Workshop 2: Match Maker: Firebase and swiping
Workshop 2: Match Maker: Firebase and swiping
Continue the app from workshop 1. You can start from the main branch of the Match Maker repository. 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 anullcallback 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 secondmain.dartsnippet; 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
- Open the Firebase console and sign in with your Google account.
- Click Create a project and use a clear name, for example
match-maker. - Google Analytics is not required for this workshop, so you can disable it.
- Finish creating the project and open it.
- 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
- In your Firebase project, open Firestore Database under the database products.
- Click Create database.
- Choose Standard edition and the default database, with ID
(default). - Choose a nearby region.
- 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
- Open Authentication in the same Firebase project.
- Click Get started if Authentication has not been configured yet.
- Open Sign-in method, select Email/Password and enable it.
- 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
| Key code or decision | Why you use it |
|---|---|
npm install -g firebase-tools | Installs the firebase command globally, so you can run it from different project folders. |
dart pub global activate flutterfire_cli | Installs 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
| Key code or decision | Why you use it |
|---|---|
firebase login | Authorizes the Firebase CLI to work with projects your Google account can access. |
| Developer login | This 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
| Key code or decision | Why you use it |
|---|---|
firebase_core, firebase_auth, cloud_firestore | Provide initialization, user authentication and database access respectively. |
flutterfire configure | Generates 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 guide for the CLI configuration flow.
Which configuration and keys do you need?
| Item | Where it comes from | Used for |
|---|---|---|
| Firebase Project ID | Project settings → General | Select your project and run the seed script. |
lib/firebase_options.dart | Generated by flutterfire configure | Connect the Flutter app to your Firebase project. It contains values such as apiKey, appId, messagingSenderId and projectId. |
| Service-account JSON private key | Project settings → Service accounts → Firebase Admin SDK | Only 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 keys.
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
| Key code or decision | Why you use it |
|---|---|
android.permission.INTERNET | Declares 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
| Key code or decision | Why you use it |
|---|---|
node --version | Prints the installed Node.js version. Check that the major version is at least 22 before continuing. |
| Node.js | Runs 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
| Key code or decision | Why you use it |
|---|---|
--prefix scripts/seed-profiles | Targets the Node.js project inside that folder while your terminal stays in the Flutter project root. |
ci | Installs 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
| Key code or decision | Why you use it |
|---|---|
$env: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-id | Runs the script against the selected Firebase project. Replace the example ID and use credentials that have access to that project. |
| Fixed document IDs | Running 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
| Key code or decision | Why 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-id | Runs the script against the selected Firebase project. Replace the example ID and use credentials that have access to that project. |
| Fixed document IDs | Running 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 symptom | Check |
|---|---|
| Could not load the default credentials | Set GOOGLE_APPLICATION_CREDENTIALS to the full path of your downloaded JSON file. |
| File not found | Check the JSON filename and path; keep paths containing spaces inside quotes. |
| Permission denied | Use your own project ID and a service account with write access to that project. |
| Database not found | Create the default Cloud Firestore database in the selected project. |
| Photo unavailable | Check your internet connection; the script stops before writing profiles if a photo cannot be reached. |
| Documents exist but the app shows no profiles | Check 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
| Key code or decision | Why you use it |
|---|---|
--dry-run | Checks the dataset locally without making network requests or changing Firestore. |
| What this proves | It checks the data, but does not prove that your credentials work or that the photos are currently reachable. |
See the Firebase Admin SDK setup documentation 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
| Key code or decision | Why you use it |
|---|---|
id | Comes 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 interests | Store 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
| Key code or decision | Why you use it |
|---|---|
uid getter | Uses 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
| Key code or decision | Why 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
| Key code or decision | Why 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 protection | These 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
| Key code or decision | Why you use it |
|---|---|
likesOnly | Lets the same page represent two tabs. Only the placeholder text changes for now; later it selects a filter. |
VoidCallback? onProfile | Passes an action from the parent into the header. The page does not need to know how the parent stores the selected tab. |
StatelessWidget | The dummy page has no changing local data. Keeping its constructor stable lets you replace the internals later without rewriting HomePage. |
Expanded | Gives 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
| Key code or decision | Why you use it |
|---|---|
const ProfilePage() | Gives navigation a real widget to open before the form exists. |
Center and Text | Make it immediately visible which destination you selected. No repository is called, so this step does not require an app login. |
| Stable constructor | You 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
| Key code or decision | Why you use it |
|---|---|
selectedIndex | Is supplied by the parent. The navigation widget displays the current choice rather than keeping a second copy of it. |
ValueChanged<int> onSelected | Sends the tapped index back to HomePage. This is a callback carrying one integer. |
NavigationDestination order | Defines 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
| Key code or decision | Why you use it |
|---|---|
selectedIndex | Is local screen state. Changing tabs does not need a Firestore write or an extra state-management package. |
IndexedStack | Keeps 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 SafeArea | Provide 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
| Key code or decision | Why you use it |
|---|---|
VoidCallback? onProfile | Makes the header reusable: it announces a tap, while the parent decides what that tap does. |
Expanded around the title | Uses the remaining row width and leaves room for the profile button. |
onPressed: onProfile | Connects 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
| Key code or decision | Why 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. |
ThemeData | Keeps the Match Maker colours and typography in one place for all pages. |
flutter run -d chrome
Visual summary: Run the navigation checkpoint in Chrome
| Key code or decision | Why you use it |
|---|---|
-d chrome | Selects Chrome as the target device for this run. |
| What you check | Each 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
| Key code or decision | Why you use it |
|---|---|
Future<void> methods | Represent 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. |
authStateChanges | Provides the initial authentication state and later sign-in/sign-out events to the auth gate. |
currentUser | Reads 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
StatefulWidgetnamedLoginPage. - A
FormwithTextFormFieldwidgets for email and password. - Controllers for both fields; dispose them when the page is removed.
isRegisteringswitches between login and registration.isLoadingdisables 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
FirebaseAuthExceptionand show its message. - Check
mountedbefore callingsetStateafter anawait.
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
| Key code or decision | Why you use it |
|---|---|
GlobalKey<FormState> and validators | Let 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 unchanged | Removes accidental spaces around the email without silently changing the password the user entered. |
isRegistering and isLoading | Choose the operation and prevent duplicate submissions or mode changes while a request is running. |
catch, finally, mounted | Show 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 success | The 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
| Key code or decision | Why you use it |
|---|---|
Stored authStream | Keeps 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.hasData | A 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
| Key code or decision | Why 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 theme | Stay the same as the previous main snippet. Authentication changes the entry widget, not the Firebase setup or app styling. |
| Protected app flow | Account-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:
- Use controllers for name, age, location, comma-separated interests and a photo URL.
- Call
fetchMyProfile()frominitStatethrough an async helper; fill the controllers. - Show loading, failure and retry states. Do not show an empty editable form when loading fails.
- Validate a non-empty name and location and an integer age from 18 to 120.
- Add an optional public photo URL. If entered, validate that it starts with
http://orhttps://and has a host. - On save, create a
Profilewith the signed-in user's UID and callsaveProfile(). - Split and trim interests, and remove empty entries.
- Disable saving and signing out while a request is running; show a
SnackBaron success or failure. - 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
| Key code or decision | Why 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 validators | Accept 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, where | Turn 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, mounted | Disable edits, save and sign-out during a request, show feedback, and avoid updating a page that has been removed. |
currentUser!.uid | Identifies 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
likedistrue. - 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
| Key code or decision | Why you use it |
|---|---|
| Streams stored in state | Keep subscriptions tied to the page instead of creating new stream objects on each button-triggered rebuild. |
Nested StreamBuilder widgets | Provide 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] == true | Ontdek 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 false | The 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 isSaving | Use 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
| Key code or decision | Why you use it |
|---|---|
VoidCallback? onReject and onLike | Keep this widget focused on buttons. The parent supplies callbacks for rejecting and liking; the widget itself does not call Firestore. |
Nullable onPressed | A null callback disables an IconButton. The parent can prevent another save without adding separate loading state here. |
| Same method as the gesture | Buttons 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
| Key code or decision | Why you use it |
|---|---|
Outer Container versus ClipRRect | Paint the shadow outside the clip, while clipping the image and overlays to the rounded card shape. |
LayoutBuilder and ConstrainedBox | Base the minimum card height on its width. This is a minimum, so extra text can make the card taller. |
Positioned.fill | Makes 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 Wrap | Keep 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
| Key code or decision | Why you use it |
|---|---|
flutter analyze | Checks source code without exercising your Firebase project. It cannot prove that authentication is enabled, database access works, or photos load. |
flutter run -d chrome | Runs the app so you can check real behaviour and database changes with the checklist below. This is separate from flutter test. |
Check the following:
- Register an account; the login page should switch to the protected app.
- Open Profiel, enter valid details and save. Check
profiles/{your-uid}in Firestore. - Open Ontdek: a seeded profile and its network photo should appear.
- Swipe right. Check
users/{your-uid}/swipes/{profileId}:likedshould betrue. - Check Likes and swipe left on another profile. A skipped profile must not appear in Likes.
- Try both action buttons and the header's profile icon.
- Refresh the browser: saved choices should remain and reviewed profiles stay hidden.
- Open Chats: the information page should be visible.
- 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
| Key code or decision | Why 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. |