Last updated: August 27, 2026

Bahasa Indonesia | English

Learning Objectives

After completing this codelab, students will be able to:

Declarative UI

In a declarative approach, code describes the interface based on the current state. When state changes, Flutter rebuilds the relevant UI. Developers focus on the relationship between data and presentation instead of manually changing each UI element.

Widget build(BuildContext context) {
  return Text('Hello, $name');
}

Basic widgets

Material 3 and Cupertino

MaterialApp and Material 3 widgets support Android and cross-platform design patterns. CupertinoApp and Cupertino widgets provide iOS-style experiences. Choose components based on user experience requirements, not only platform names.

Responsive layout

Responsive layouts adapt their structure to the available space. Use LayoutBuilder or MediaQuery to read dimensions and change the number of columns or layout direction. Avoid fixed pixel sizes for elements that must adapt.

Theme, dark mode, and accessibility

A theme centralizes colors, typography, and component shapes. Provide theme and darkTheme on MaterialApp. For accessibility, use sufficient contrast, meaningful labels, readable text sizes, and do not communicate information through color alone.

What you will build

Before starting, observe the target of this codelab: a student dashboard that shows one column on narrow screens and two columns on wide screens, with consistent light and dark themes.

  1. Run the finished app on a phone-sized emulator (e.g. 5"), then a tablet (e.g. 10"); compare the column counts.
  2. Enable dark mode on the emulator/device and observe the automatic theme change.
  3. Notice the declarative pattern: the UI is not updated element by element; only state changes and Flutter rebuilds the view.

Before the responsive dashboard, practice the basic widgets by building a simple profile card. Create a new project or temporarily replace lib/main.dart:

import 'package:flutter/material.dart';

void main() => runApp(const ProfileApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(child: ProfileCard()),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Container(
      width: 320,
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.indigo.shade50,
        borderRadius: BorderRadius.circular(16),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Row(
            children: [
              const CircleAvatar(child: Icon(Icons.person)),
              const SizedBox(width: 12),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: const [
                    Text('Student Name',
                        style: TextStyle(fontWeight: FontWeight.bold)),
                    Text('...type your name here...'),
                  ],
                ),
              ),
            ],
          ),
          const SizedBox(height: 12),
          const Row(children: [
            Expanded(child: Text('Student ID')),
            Text('...type your student ID here...'),
          ]),
          const Row(children: [
            Expanded(child: Text('Class')),
            Text('...type your class here...'),
          ]),
        ],
      ),
    );
  }
}

Warm-up experiments

  1. Remove Expanded from the name row, observe the overflow warning or layout behavior, then restore it.
  2. Replace mainAxisSize: MainAxisSize.min with the default value and observe the card height change.
  3. Add one more data row (e.g. Email) using the same Row + Expanded pattern.

Set up the project

flutter create responsive_dashboard
cd responsive_dashboard
flutter run

Open lib/main.dart. Create the following simple profile dashboard and run it on an emulator or physical device.

import 'package:flutter/material.dart';

void main() => runApp(const DashboardApp());

class DashboardApp extends StatelessWidget {
  const DashboardApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark, colorSchemeSeed: Colors.indigo),
      themeMode: ThemeMode.system,
      home: const DashboardPage(),
    );
  }
}

class DashboardPage extends StatelessWidget {
  const DashboardPage({super.key});
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Student Dashboard')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final columns = constraints.maxWidth >= 700 ? 2 : 1;
          return GridView.count(
            padding: const EdgeInsets.all(16),
            crossAxisCount: columns,
            crossAxisSpacing: 16,
            mainAxisSpacing: 16,
            childAspectRatio: 2.6,
            children: const [
              DashboardCard(title: 'Assignments', value: '8'),
              DashboardCard(title: 'Attendance', value: '92%'),
              DashboardCard(title: 'Portfolio', value: 'Ready'),
              DashboardCard(title: 'Current week', value: '02'),
            ],
          );
        },
      ),
    );
  }
}

class DashboardCard extends StatelessWidget {
  const DashboardCard({required this.title, required this.value, super.key});
  final String title;
  final String value;
  @override
  Widget build(BuildContext context) {
    return Card(child: Padding(padding: const EdgeInsets.all(20), child: Row(children: [Expanded(child: Text(title)), Text(value, style: Theme.of(context).textTheme.headlineSmall)])));
  }
}

Adding interaction: StatefulWidget and Cupertino

So far the dashboard is still a StatelessWidget. Convert DashboardApp into a StatefulWidget and add a CupertinoSwitch (a Cupertino widget) to the AppBar to toggle the theme manually — while directly comparing Material and Cupertino components:

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

  @override
  State<DashboardApp> createState() => _DashboardAppState();
}

class _DashboardAppState extends State<DashboardApp> {
  bool isDark = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      darkTheme: ThemeData(useMaterial3: true, brightness: Brightness.dark, colorSchemeSeed: Colors.indigo),
      themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
      home: DashboardPage(
        isDark: isDark,
        onDarkChanged: (value) => setState(() => isDark = value),
      ),
    );
  }
}

Adjust DashboardPage to receive the state and callback:

class DashboardPage extends StatelessWidget {
  const DashboardPage({
    required this.isDark,
    required this.onDarkChanged,
    super.key,
  });
  final bool isDark;
  final ValueChanged<bool> onDarkChanged;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Student Dashboard'),
        actions: [
          Row(
            children: [
              Icon(isDark ? Icons.dark_mode : Icons.light_mode),
              const SizedBox(width: 4),
              CupertinoSwitch(
                value: isDark,
                onChanged: onDarkChanged,
              ),
              const SizedBox(width: 12),
            ],
          ),
        ],
      ),
      body: LayoutBuilder(
        // ... the previous GridView code, unchanged
      ),
    );
  }
}

Layout experiments

  1. Change the 700 breakpoint and observe the column count.
  2. Change themeMode to ThemeMode.dark, then restore ThemeMode.system.
  3. Test the application at different emulator screen sizes.
  4. Add Semantics or meaningful labels to important screen-reader elements.

Main assignment

Extend the dashboard into an Academic Overview page:

AI Prompt Challenge

After completing the independent implementation, use AI only to compare two layout alternatives. Work through the following challenge:

  1. Design prompt. Submit this prompt (or a variation): "Compare two Flutter academic dashboard layouts: a GridView version and a LayoutBuilder + Column version. Explain the responsive and accessibility trade-offs."
  2. Concept-reinforcement prompt. "Explain when using Expanded actually causes an overflow inside a Row; show failing example code and its fix."
  3. Verification prompt. Ask the AI to audit its own output: "Review the layout recommendation above: does it stay responsive below 600px, does it reduce accessibility, and are all widgets available in the current stable Flutter?"
  4. Document it. Store the prompt, relevant output, selected decision, technical reasoning, and verification evidence (tests/screenshots) in this week's assignment README.

Passing criteria: the AI suggestion you adopt actually works, remains responsive, does not reduce accessibility, and you can explain every decision during code review, not merely copy the AI output.

Refactoring challenge

Once the main assignment works, clean up your code:

  1. Extract the information card into a reusable widget (e.g. InfoCard) that receives title and value, removing widget duplication.
  2. Replace hardcoded colors and sizes with Theme.of(context) so they follow the light/dark theme automatically.
  3. Move the breakpoint into a single named constant (e.g. const kWideBreakpoint = 700;) so it is defined only once.
  4. Run flutter analyze and ensure there are no new errors or warnings.

Basic testing

Add widget tests in the test/ folder to verify the responsive behavior. Override the screen size using tester.view:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:responsive_dashboard/main.dart';

void main() {
  testWidgets('Dashboard shows one column on a narrow screen', (tester) async {
    tester.view.physicalSize = const Size(400, 800);
    tester.view.devicePixelRatio = 1.0;
    addTearDown(tester.view.reset);

    await tester.pumpWidget(const DashboardApp());

    final width = tester.getSize(find.byType(Card)).width;
    expect(width, lessThan(700));
  });

  testWidgets('Dashboard shows two columns on a wide screen', (tester) async {
    tester.view.physicalSize = const Size(1200, 800);
    tester.view.devicePixelRatio = 1.0;
    addTearDown(tester.view.reset);

    await tester.pumpWidget(const DashboardApp());

    final width = tester.getSize(find.byType(Card)).width;
    expect(width, greaterThan(500));
  });
}

Run them with flutter test. Both tests must pass before submitting the assignment. Store the tests in the test/ folder of this week's assignment.

Verification checklist

Reflection

References