Skip to main content

Workshop 1: Match Maker: widgets and layout

About 8 min

Workshop 1: Match Maker: widgets and layout

Build Match Maker together with us, one visible change at a time: a header, profile photo with overlays, two action buttons and bottom navigation. Buttons have empty callbacks; the online badge is example data.

Follow the live build and type along. Each step names the file and the layout to build; keep the rest of your code. The model and repository code in step 6 are provided so you can copy the example data.

What you will build

The finished app on a phone-sized screen and in a wide window. The content stays centred and the bottom navigation remains separate from the scrolling page.

Finished Match Maker app on a phone-sized screen, showing Lina's profile, action buttons and bottom navigation
Phone-sized screen
Finished Match Maker app in a wide window with a centred profile and constrained content width
Wide window

The diagrams below explain the layout with English labels. Colours and placeholder content in these illustrations may differ from the finished app. The app screenshots show the Dutch interface used in the workshop code.

1. Create the project and add the photos

Create a fresh project:

flutter create match_maker
cd match_maker

Download all four profile photos and their credits as a ZIP. Extract it into your project root: the ZIP already contains assets/images/ and assets/README.md.

You can also download the photos individually. Save them in assets/images/ using these filenames:

Keep the photo credits as assets/README.md. In the existing flutter: section of pubspec.yaml, keep uses-material-design and add the assets entry (do not create a second flutter: section):

flutter:
  uses-material-design: true
  assets:
    - assets/images/
flutter pub get

Create these folders inside lib/: models, repositories, pages and widgets. Use Dart 3.12 or later; check your installed Flutter/Dart SDK before class.

2. Start with one screen

Create lib/pages/discover.dart with a stateless DiscoverPage. Its Scaffold contains a SafeArea, a Center and a Text widget showing 'Match Maker'.

Replace the generated lib/main.dart during the live build. Let main() start MyApp, a stateless widget that returns a MaterialApp. Import DiscoverPage and use it as home. Set the app title to "It's a Match!", hide the debug banner and define matchPink as Color(0xFFE94B78) for the next step.

flutter run -d chrome

Check: the greeting is visible. Change it and hot reload.

3. Set the app theme

In MyApp, add a ThemeData to MaterialApp. Use Material 3 and a ColorScheme.fromSeed with matchPink as both seed and primary colour. Set the scaffold background to Color(0xFFFBF7F5). Apply Color(0xFF302531) to the body and display text colours of the light text theme.

Check: the screen background becomes warm white.

4. Build the page frame

In DiscoverPage, replace the build() method during the live build. Follow this widget tree, keeping the greeting as the column's temporary child:

Page frame: Scaffold, SafeArea, Center, ConstrainedBox, SingleChildScrollView, Column and Text
Page frame: Scaffold, SafeArea, Center, ConstrainedBox, SingleChildScrollView, Column and Text

Set SafeArea.bottom to false; the navigation added later handles the bottom inset. Limit the content to 480 logical pixels with ConstrainedBox. The SingleChildScrollView lets the body scroll vertically. Give the Column a minimum main-axis size and stretch its children across the available width.

Centred Match Maker greeting on a narrow and a wide screen
Centred Match Maker greeting on a narrow and a wide screen

Center positions the content; ConstrainedBox limits its width. On a narrow screen, the content uses the available width. On a wide screen, it stays at most 480 logical pixels wide. The diagram also shows the 20-pixel card padding added in step 7.

Maximum width: a 320-wide page with a 280-wide card and a 480-wide page with a 440-wide card centred in 1000 available pixels
Maximum width: a 320-wide page with a 280-wide card and a 480-wide page with a 440-wide card centred in 1000 available pixels

Check: on a wide window, the content stays centred and at most 480 logical pixels wide.

5. Add the header and introduction

Create lib/widgets/match_header.dart with a stateless MatchHeader. Build a padded Row with a rounded logo container, a heart icon, a gap, an expanded title and two IconButton widgets. Use the theme's primary colour for the logo and empty callbacks for the buttons.

Header layout with logo, spacing, expanded title and two icon buttons
Header layout with logo, spacing, expanded title and two icon buttons

Key components:

  • Padding adds space around the header: 20 left, 12 top, 16 right and 10 bottom.
  • Row places the elements side by side.
  • A Container of 38 × 38 creates the rounded logo background.
  • SizedBox(width: 10) separates the logo from the title.
  • Expanded gives the title the remaining width and pushes the buttons to the right.
  • Two IconButton widgets provide the preferences and profile buttons.

Import MatchHeader in discover.dart. Replace the temporary greeting in the outer column with the header and a padded introduction. The introduction uses a left-aligned Column with 'Een klik begint hier.', a small gap and 'Nieuwe mensen. Mooie connecties.'.

Page column with MatchHeader followed by a padded introduction
Page column with MatchHeader followed by a padded introduction

Check: the logo, title, two icon buttons and introduction are visible. The buttons do not change the screen.

6. Add the profile data

Copy these two files so we can spend the live build on the layout.

Create lib/models/profile.dart:

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

Create lib/repositories/profile_repository.dart:

import '../models/profile.dart';

/// Lokale voorbeeldgegevens; later kan deze repository een service gebruiken.
class ProfileRepository {
  const ProfileRepository();

  List<Profile> getProfiles() => const [alex, noor, sam, lina];

  // Fictieve volwassen profielen voor de mini-challenge.
  static const alex = Profile(
    name: 'Alex',
    age: 23,
    location: 'Antwerpen · 3 km',
    imageAsset: 'assets/images/alex.jpg',
    interests: ['Koffie', 'Reizen', 'Muziek'],
  );
  static const noor = Profile(
    name: 'Noor',
    age: 24,
    location: 'Gent · 8 km',
    imageAsset: 'assets/images/noor.jpg',
    interests: ['Fotografie', 'Koken', 'Wandelen'],
  );
  static const sam = Profile(
    name: 'Sam',
    age: 25,
    location: 'Mechelen · 12 km',
    imageAsset: 'assets/images/sam.jpg',
    interests: ['Sport', 'Films', 'Reizen'],
  );
  static const lina = Profile(
    name: 'Lina',
    age: 24,
    location: 'Leuven · 6 km',
    imageAsset: 'assets/images/lina.jpg',
    interests: ['Dansen', 'Koffie', 'Citytrips'],
  );
}

7. Give the profile card its shape

Create lib/widgets/profile_card.dart with a stateless ProfileCard that requires a Profile. Start with an empty card and follow this widget tree:

ProfileCard tree: shadow Container, LayoutBuilder, ConstrainedBox, ClipRRect, Stack and temporary SizedBox
ProfileCard tree: shadow Container, LayoutBuilder, ConstrainedBox, ClipRRect, Stack and temporary SizedBox

The outer Container paints the rounded shadow. Use a radius of 28, a shadow blur of 24 and an offset of 10 downwards. Keep the shadow outside ClipRRect so it is not clipped. Inside, use LayoutBuilder to read the available card width and set the minimum height to that width multiplied by 1.25. Round the clipped content with the same radius.

Use a Stack aligned to the bottom left, with a temporary SizedBox of height 220 as its child.

Illustration of the empty rounded profile card before adding its content
Illustration of the empty rounded profile card before adding its content

Import ProfileRepository and ProfileCard in discover.dart. After the introduction, add a ProfileCard using ProfileRepository.lina, inside horizontal padding of 20.

The minimum height leaves room for the photo while allowing the card to grow with its text. At a card width of 440, the minimum height is 550. Content that needs 650 makes the card taller.

Minimum card height: short content leaves extra space in a 550-high card while long content grows the card to 650
Minimum card height: short content leaves extra space in a 550-high card while long content grows the card to 650

Check: an empty rounded area with a shadow appears. Its minimum height follows its width.

8. Add the profile text and interests

In ProfileCard, replace only the temporary SizedBox in the stack. Build the text as a non-positioned child so it can determine the card's height. Follow this structure:

Profile text tree: Align, Padding, DefaultTextStyle, Column, name, location Row and interests Wrap
Profile text tree: Align, Padding, DefaultTextStyle, Column, name, location Row and interests Wrap

Align the content to the bottom left. Use padding of 24 on the left, right and bottom, and 110 at the top to reserve space for the badge. Set white as the default text colour. Keep the column at its minimum height and align its children to the start.

Show the profile's name and age, then a location row with an icon and an Expanded text. Convert the interests to rounded tags inside a Wrap with spacing and run spacing of 7. This lets the tags move onto another line on narrow cards.

Use this starting structure. Add the name, age and location row, and fill the Wrap.children list during the live build.

Align(
  alignment: Alignment.bottomLeft,
  child: Padding(
    // Reserve space at the top for the online badge.
    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: [
          // TODO: add the name and age, spacing, and location row.
          const SizedBox(height: 16),
          Wrap(
            spacing: 7,
            runSpacing: 7,
            children: [
              // TODO: build the interest tags.
            ],
          ),
        ],
      ),
    ),
  ),
),
Illustration of profile text and interest tags aligned at the bottom of a card
Illustration of profile text and interest tags aligned at the bottom of a card

The illustration uses a coloured background and English interest labels to make the layout clear. Our card still has no photo or background at this step.

Check: the name, location and interests appear (the white text is easier to see after the next step). Narrow the window: interests move onto another line.

9. Add the photo, gradient and badge

In ProfileCard, insert this as the first child of the Stack, before Align:

Positioned.fill(
  child: Image.asset(
    profile.imageAsset,
    fit: BoxFit.cover,
    alignment: const Alignment(0, -0.4),
    semanticLabel: 'Profielfoto van ${profile.name}',
  ),
),

Insert this after the photo, before Align:

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),
        ],
      ),
    ),
  ),
),

Insert this after the gradient, before Align:

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 Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(Icons.circle, color: Color(0xFF338563), size: 7),
        SizedBox(width: 6),
        Text(
          'ONLINE',
          style: TextStyle(
            fontSize: 10,
            fontWeight: FontWeight.w800,
            letterSpacing: 1,
          ),
        ),
      ],
    ),
  ),
),

The final Stack.children order is photo, gradient, online badge, profile text. Keep the text as a non-positioned child. Compare the result with the finished app at the top of this page.

Check: Lina's photo fills the card, the badge sits at the top left and the profile text stays readable at the bottom. The photo and gradient fill the card even when extra text makes it taller.

10. Add the action buttons

Action buttons: centred reject and like buttons with a 28-pixel gap and circular shadows
Action buttons: centred reject and like buttons with a 28-pixel gap and circular shadows

Create lib/widgets/action_buttons.dart:

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) => const Row(
    mainAxisAlignment: MainAxisAlignment.center,
    children: [
      _ActionButton(
        label: 'Weigeren',
        icon: Icons.close_rounded,
        color: Color(0xFFE36B67),
      ),
      SizedBox(width: 28),
      _ActionButton(
        label: 'Liken',
        icon: Icons.favorite_rounded,
        color: Colors.white,
        primary: true,
      ),
    ],
  );
}

class _ActionButton extends StatelessWidget {
  const _ActionButton({
    required this.label,
    required this.icon,
    required this.color,
    this.primary = false,
  });
  final String label;
  final IconData icon;
  final Color color;
  final bool primary;

  
  Widget build(BuildContext context) => Container(
    decoration: BoxDecoration(
      shape: BoxShape.circle,
      boxShadow: [
        BoxShadow(
          color: primary ? const Color(0x40E94B78) : const Color(0x0F302531),
          blurRadius: 18,
          offset: const Offset(0, 6),
        ),
      ],
    ),
    child: CircleAvatar(
      radius: primary ? 35 : 31,
      backgroundColor: primary ? const Color(0xFFE94B78) : Colors.white,
      child: IconButton(
        tooltip: label,
        iconSize: primary ? 32 : 34,
        color: color,
        // Statische UI: likes, weigeringen en matches volgen later.
        onPressed: () {},
        icon: Icon(icon),
      ),
    ),
  );
}

Add this import to discover.dart:

import '../widgets/action_buttons.dart';

Append these widgets after the profile card's Padding in the outer column:

SizedBox(height: 22),
ActionButtons(),
SizedBox(height: 16),
Text(
  'Misschien is dit het begin van iets moois.',
  textAlign: TextAlign.center,
  style: TextStyle(fontSize: 11, color: Color(0xFF877A83)),
),
SizedBox(height: 18),

Check: the two round buttons are centred. Hover over or long-press them to read their tooltips. Clicking them does not switch profiles.

11. Add the fixed bottom navigation

Bottom navigation layout: a Container and Row with four Expanded items, each containing Semantics, TextButton and an icon-label Column
Bottom navigation layout: a Container and Row with four Expanded items, each containing Semantics, TextButton and an icon-label Column

Create lib/widgets/match_navigation.dart:

import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) => Container(
    padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
    decoration: const BoxDecoration(
      color: Colors.white,
      border: Border(top: BorderSide(color: Color(0xFFF0E7E9))),
    ),
    child: const Row(
      children: [
        _NavigationItem(
          icon: Icons.explore_rounded,
          label: 'Ontdek',
          selected: true,
        ),
        _NavigationItem(icon: Icons.favorite_outline_rounded, label: 'Likes'),
        _NavigationItem(
          icon: Icons.chat_bubble_outline_rounded,
          label: 'Chats',
        ),
        _NavigationItem(icon: Icons.person_outline_rounded, label: 'Profiel'),
      ],
    ),
  );
}

class _NavigationItem extends StatelessWidget {
  const _NavigationItem({
    required this.icon,
    required this.label,
    this.selected = false,
  });
  final IconData icon;
  final String label;
  final bool selected;

  
  Widget build(BuildContext context) => Expanded(
    child: Semantics(
      selected: selected,
      child: TextButton(
        // Workshop 1: enkel de UI; navigatie volgt later.
        onPressed: () {},
        style: TextButton.styleFrom(
          foregroundColor: selected
              ? Theme.of(context).colorScheme.primary
              : const Color(0xFF93868F),
        ),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(icon, size: 25),
            const SizedBox(height: 4),
            Text(
              label,
              style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
            ),
          ],
        ),
      ),
    ),
  );
}

Add this import to discover.dart:

import '../widgets/match_navigation.dart';

Add this argument to Scaffold, after body: (outside the scrolling column):

bottomNavigationBar: SafeArea(
  top: false,
  child: Align(
    alignment: Alignment.bottomCenter,
    heightFactor: 1,
    child: ConstrainedBox(
      constraints: const BoxConstraints(maxWidth: 480),
      child: const MatchNavigation(),
    ),
  ),
),

Check: four equally wide navigation items remain at the bottom while the body scrolls. Only Ontdek has the selected style.

12. Check and change one profile

Replace ProfileRepository.lina in discover.dart with alex, noor or sam from the same repository. Hot reload: the photo, name, age, location and interests must change together. Restore lina to match the screenshots.

Check a narrow phone window, a wide window and a short landscape window. The body should scroll, interests should wrap and navigation should remain visible. Try a longer location and more interests.

flutter analyze
Flutter analyzer messages: an error for invalid code, a warning for a possible problem and an informational lint suggestion
Flutter analyzer messages: an error for invalid code, a warning for a possible problem and an informational lint suggestion

The generated counter test no longer matches this UI. Update it before using flutter test. The finished page shows Lina (Lina, 24, Leuven · 6 km, Dansen).

Finished file structure:

lib/
  main.dart
  models/profile.dart
  repositories/profile_repository.dart
  pages/discover.dart
  widgets/match_header.dart
  widgets/profile_card.dart
  widgets/action_buttons.dart
  widgets/match_navigation.dart