Flutter Tutorial

Flutter Beginner Lesson 124 of 135 4 min read

Calculator App in Flutter (Beginner Project with Source Code)

Build a calculator app in Flutter: a button grid with Row, Column and Expanded, + − × ÷, percent and error handling. Beginner project, full runnable code.

On this page

The calculator is the classic first Flutter project. It practises layout (a grid of buttons with Row, Column and Expanded), state with setState, and logic separated from the UI.

The full app

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(
      debugShowCheckedModeBanner: false,
      home: CalculatorPage(),
    ));

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

  @override
  State<CalculatorPage> createState() => _CalculatorPageState();
}

class _CalculatorPageState extends State<CalculatorPage> {
  String display = '0';
  double? first;     // the number before the operator
  String? operator;  // + − × ÷
  bool fresh = true; // the next digit starts a new number

  static const rows = [
    ['C', '±', '%', '÷'],
    ['7', '8', '9', '×'],
    ['4', '5', '6', '−'],
    ['1', '2', '3', '+'],
    ['0', '.', '='],
  ];

  double calculate(double a, double b, String op) => switch (op) {
        '+' => a + b,
        '−' => a - b,
        '×' => a * b,
        '÷' => b == 0 ? double.nan : a / b,
        _ => b,
      };

  String format(double v) {
    if (v.isNaN || v.isInfinite) return 'Error';
    if (v == v.roundToDouble() && v.abs() < 1e12) return v.toInt().toString();
    var s = v.toStringAsFixed(8).replaceFirst(RegExp(r'0+$'), '');
    return s.endsWith('.') ? s.substring(0, s.length - 1) : s;
  }

  void press(String key) {
    setState(() {
      if (display == 'Error') {
        display = '0';
        first = null;
        operator = null;
        fresh = true;
        if (key == 'C') return;
      }
      switch (key) {
        case 'C':
          display = '0';
          first = null;
          operator = null;
          fresh = true;
        case '±':
          if (display != '0') {
            display = display.startsWith('-') ? display.substring(1) : '-$display';
          }
        case '%':
          display = format(double.parse(display) / 100);
        case '+' || '−' || '×' || '÷':
          if (first != null && operator != null && !fresh) {
            display = format(calculate(first!, double.parse(display), operator!));
          }
          first = double.tryParse(display);
          operator = key;
          fresh = true;
        case '=':
          if (first != null && operator != null) {
            display = format(calculate(first!, double.parse(display), operator!));
            first = null;
            operator = null;
            fresh = true;
          }
        case '.':
          if (fresh) {
            display = '0.';
            fresh = false;
          } else if (!display.contains('.')) {
            display += '.';
          }
        default: // a digit
          if (fresh || display == '0') {
            display = key;
            fresh = false;
          } else if (display.length < 12) {
            display += key;
          }
      }
    });
  }

  Color colorFor(String key, ColorScheme c) {
    if ('÷×−+='.contains(key)) return c.primary;
    if ('C±%'.contains(key)) return c.secondaryContainer;
    return c.surfaceContainerHighest;
  }

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    return Scaffold(
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(12),
          child: Column(
            children: [
              Expanded(
                child: Container(
                  alignment: Alignment.bottomRight,
                  padding: const EdgeInsets.all(16),
                  child: Column(
                    mainAxisAlignment: MainAxisAlignment.end,
                    crossAxisAlignment: CrossAxisAlignment.end,
                    children: [
                      Text(
                        first == null ? '' : '${format(first!)} ${operator ?? ''}',
                        style: TextStyle(fontSize: 22, color: colors.outline),
                      ),
                      FittedBox(
                        child: Text(display, style: const TextStyle(fontSize: 64, fontWeight: FontWeight.w300)),
                      ),
                    ],
                  ),
                ),
              ),
              for (final row in rows)
                Row(
                  children: [
                    for (final key in row)
                      Expanded(
                        flex: key == '0' ? 2 : 1,
                        child: Padding(
                          padding: const EdgeInsets.all(5),
                          child: SizedBox(
                            height: 68,
                            child: FilledButton(
                              style: FilledButton.styleFrom(
                                backgroundColor: colorFor(key, colors),
                                foregroundColor: '÷×−+='.contains(key) ? colors.onPrimary : colors.onSurface,
                                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
                              ),
                              onPressed: () => press(key),
                              child: Text(key, style: const TextStyle(fontSize: 26)),
                            ),
                          ),
                        ),
                      ),
                  ],
                ),
            ],
          ),
        ),
      ),
    );
  }
}

How it works

Layout. The screen is a Column: an Expanded display area at the top, then one Row per line of buttons. Each button is wrapped in Expanded, so a row’s buttons share the width equally; flex: 2 makes 0 twice as wide. Learn more in Row and Column and Expanded.

State. Four fields describe everything: what is on the display, the first number, the operator, and whether the next digit starts a new number. Every button calls press, which changes those fields inside setState.

Logic. calculate is a switch expression, and case '+' || '−' || '×' || '÷': uses an or-pattern to handle four buttons in one place. Pressing an operator twice in a row (2 + 3 +) calculates the running total, like a real calculator.

Errors. Dividing by zero gives NaN, which format shows as “Error”; the next key press clears it.

Make it yours

  1. Add a history list of past calculations below the display.
  2. Support the keyboard on desktop and web with a Focus widget and onKeyEvent.
  3. Add a dark mode switch (themes and dark mode).
  4. Move the logic into a separate Calculator class and write unit tests for it.
Practise in the playground Updated