Flutter for Windows Desktop: Getting Started

Learn how to set up a development environment and create a simple Flutter calculator app for Windows Desktop.

Developers often use Flutter to develop mobile apps. Starting from version 2.10, it supports Windows as a target platform. Let’s check how to develop the production-level Windows apps with Flutter.

In this tutorial, you’ll learn how to set up a development environment and create a simple calculator app. We’ll focus on key concepts and basic business logic operations. In the process, you’ll learn:

  • Setting up environment.
  • Creating a Flutter desktop project.
  • Handling keyboard input.
  • Managing window size.

This tutorial covers the Windows platform only. But, almost everything should work on macOS and Linux. You only have to adjust the keyboard shortcuts.

 

Getting Started

To compile the Flutter desktop application for Windows, use a PC with Windows OS. A virtual machine like VMware or VirtualBox with a Windows guest also will work. Unfortunately, Flutter isn’t currently supporting cross-compilation.

To build Flutter apps on Windows you’ll need:

  1. Flutter SDK
  2. IDE which supports Flutter eg. Android Studio
  3. Visual Studio 2022

Installing Visual Studio

Note: Visual Studio is not the same as Visual Studio Code!

This tutorial doesn’t cover Flutter SDK and IDE setup processes. Instead, we assume you can already build Flutter mobile apps. If not, check the Windows install documentation of Flutter SDK and Android Studio.

When installing Visual Studio, select Desktop development with C++ workload. It should contain the MSVC v143 for your PC ABI (x64/x86 in case of Intel and compatible CPUs):

Enabling Desktop Support in Flutter

Before you open or create a desktop project, enable the Windows support in Flutter. This is a global setting. You have to do it only once. Execute the following command in the terminal:

flutter config --enable-windows-desktop

Open and run the starter project from the attachment to this tutorial. You can press Shift+F10 in Android Studio. This will check whether your setup is correct. If all is OK, you should see the standard Flutter Demo app:

If you see CMake compilation errors, it usually means inconsistent Visual Studio installation. Specific versions of Flutter only supports specific versions of Visual Studio and MSVC. In the case of Flutter 2.10, it’s Visual Studio 2022 and MSVC v143.

Creating the Project

You’ll create the FCalc app, which is a simple calculator. It supports only integers (no fractions), two arithmetic operations: + and -, and clearing (AC).
You can enter data by clicking buttons, using a keyboard or pasting them from the system clipboard. The final layout looks like this:

Developing the Button

Start from the widget for a button. It’s an elementary building block of the calculator. First, add the following code in a separate file tile.dart.

 

import 'package:flutter/material.dart';

class Tile extends StatelessWidget {
  final String symbol;
  final Function(String) onTap;

  const Tile(
    this.symbol,
    this.onTap, {
    Key? key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) => Material(
        child: InkWell(
          //1
          onTap: () => onTap(symbol),
          //2
          child: Center(
            child: Text(
              symbol,
              style: const TextStyle(
                fontSize: 26,
                color: Color(0xFF333333), //grey
              ),
            ),
          ),
        ),
        color: const Color(0xFFFCB526), //yellow
      );
}

 

It contains the following elements:

  1. A tap handler, mouse clicks and presses of widgets that currently have a focus (moved using Tab).
  2. Centered label using Ray Wenderlich brand colors.

The Material and InkWell widgets are here to provide a visual feedback on taps.

 

Developing the Grid of Buttons

Create a CalculatorBody widget with grid of Tile widgets and a placeholder for a display. It’s a skeleton of the calculator. Note the order of digits: 7, 8, 9, 6, 5 and so on. After that, place the following code inside a calculator_body.dart file, and press Alt+Enter to add missing imports:

 

class CalculatorBody extends StatelessWidget {

  CalculatorBody({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) => Column(
        //1
        textDirection: TextDirection.ltr,
        crossAxisAlignment: CrossAxisAlignment.end,

        children: [
          //TODO display
          GridView.count(
            //2
            shrinkWrap: true,
            //3
            padding: const EdgeInsets.all(20),
            //4
            crossAxisSpacing: 10,
            mainAxisSpacing: 10,
            //5
            crossAxisCount: 3,
            children: [
              Tile('7', (symbol) {
                //TODO action
              }),
              //TODO rest of the tiles
            ],
          ),
        ],
      );
}

Here’s what the code above does:

  1. Aligns the display on the right side. Even if the locale uses right-to-left direction, e.g., Arabic or Hebrew.
  2. Shrink the content to prevent infinite expansion.
  3. Set the gaps between the grid and window borders.
  4. Set the distance between adjacent buttons.
  5. Set number of columns.

 

Building the Business Logic

The business logic should have some unit tests and formal descriptions, e.g., using a state machine diagram. This article focuses on getting started on desktop app development, not on the arithmetic or the clean code. So it will only describe the algorithm shortly.

Start from creating the class for a calculator with ValueNotifier for an output. All the business logic will go there. Put the code in calculator.dart file.

class Calculator {
  final displayNotifier = ValueNotifier('0');
}

Note that CalculatorBody was a stateless widget. Only the value notifier holds the state.

You need two arithmetic operations. Add a simple enum for them in the same calculator.dart file:

enum Operator { plus, minus }

 

Developing the Calculation Engine

Now you’re ready to add the core logic, specifically the ability to append digits and operators to the current state. Add the following code to the Calculator class:

Operator? _pendingOperation;
int? _operand;
bool _wasDigitJustAppended = false;

void appendOperator(Operator operation) {
  if (_wasDigitJustAppended) {
    //1
    switch (_pendingOperation) {
      case Operator.plus:
        _updateDisplay(_operand! + int.parse(displayNotifier.value));
        break;
      case Operator.minus:
        _updateDisplay(_operand! - int.parse(displayNotifier.value));
        break;
      //2
      case null:
        _operand = int.parse(displayNotifier.value);
        break;
    }
  }
  _wasDigitJustAppended = false;
   //3
  _pendingOperation = operation;
}

void _updateDisplay(int result) {
  _operand = result;
  displayNotifier.value = result.toString();
}

void appendDigit(String digit) {
  //4
  if (!_wasDigitJustAppended) {
    displayNotifier.value = '';
  }
  //5
  _wasDigitJustAppended = true;
  displayNotifier.value += digit;
}

At the beginning, a display shows 0. There are no pending operations and no operands (numbers) on the stack. That’s because a user has not pressed any key yet. The result depends on what kind of input has arrived (a digit or an operation) and what kind of action was previously taken. If no previous actions were taken, we take the initial state into account.

The algorithm works as follows (the order of the lines of code doesn’t match the order of a typical use scenario):

  1. If an operation is pending, compute the result out of the first operand. Then, the current display content.
  2. If a user has pressed the plus or minus (and only digits before), remember the number entered so far.
  3. Remember the last operation as a new pending one.
  4. If a previous key (before a digit) was the plus or minus sign, clear the display. It contains the result of some operation, not the digits of the current number.
  5. If a user has pressed the digit, append it to a display.

Note the text (String) is a storage of a content of a display. We convert texts to numbers (int type) only before the arithmetic operations. It’s easier to build the multidigit numbers as texts if the user can append single digits.

 

Binding UI with Business Logic

To connect the logic and UI, inject the Calculator into a CalculatorBody:

final _calculator = Calculator();

Prepend the GridView in a column with ValueListenableBuilder. Now the calculator has the ability to display the current state.

Padding(
  padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 10),
  child: ValueListenableBuilder<String>(
    valueListenable: _calculator.displayNotifier,
    //1
    builder: (_, String value, __) => Text(
      value,
      //2
      overflow: TextOverflow.ellipsis,
      maxLines: 1,
      style: const TextStyle(
        fontSize: 36,
        fontWeight: FontWeight.bold,
        color: Color(0xFF158443),  //green
      ),
    ),
  ),
),

Note important code fragments:

  1. The buildercallback fires on each displayNotifier change.
  2. The ellipsis overflow causes the &hellip to display if the text is too long.

Go back to the build method of a CalculatorBody and bind the actions to the buttons:

Tile('7', _calculator.appendDigit),
Tile('7', _calculator.appendDigit),
//rest of the digits
Tile('+', (_) => _calculator.appendOperator(Operator.plus)),
Tile('-', (_) => _calculator.appendOperator(Operator.minus)),

Now you have wired all the actions but the clear AC (I’ll return to this later in the article).

Finally, create the entry point of the app in a main.dart file. After that, you’re able to run the app (press Shift+F10 or a green play button in Android Studio).

Future<void> main() async {
  runApp(const FCalcApp());
}

class FCalcApp extends StatelessWidget {
  const FCalcApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) =>
      MaterialApp(home: Scaffold(body: CalculatorBody()));
}

As a result, you have the simplest, working calculator:

Developing Features for Desktop

In the current state, the app works on desktops, just like on mobile platforms. On mobile, however, the default input device is a touchscreen; on desktop, it’s a keyboard and a mouse. You can also connect the keyboard and mouse to smartphones via USB-OTG or Bluetooth. Flutter supports them out of the box.

 

Supporting Mouse Input

Flutter treats left-button mouse clicks like taps on a touchscreen. You don’t need to do anything special to handle clicks. The onTap and similar callbacks of various widgets will work. But, a feature is visible only when using a mouse. Look what happens with the color if you drag a pointer on the button:

That’s a hover event. The widgets from a standard Flutter library such as an InkWell support the hovers. If you’re implementing your own widgets or involving raw gesture detection, it should also handle hover events. If you need to track the pointer movement, use a special MouseRegion class.

Supporting Keyboard Input

There are three kinds of keyboard input in Flutter:

  1. Entering editable text in text fields.
  2. Focus movement, e.g., Tab to move forward and activate the focused element such as Enter.
  3. Direct keystrokes (not related to editable text input).

In the case of text fields such as TextField), Flutter handles all the low-level work related to keyboard input for you. Users can enter or paste text from the clipboard using the standard system key shortcuts such as Ctrl+C on Windows.

The focus is important in mobile and desktop apps. On the latter, users can move it to the adjacent focusable elements using Tab and Shift+Tab. On mobile, there can be an IME action on a soft keyboard. Also on desktop, the focused element gains the hovered state.

Handling Keystrokes

To intercept arbitrary keystrokes, use the KeyboardListner or Focus widgets. Also, the receiver widget has to be in the focused state (that one related to hover). The entire focus system in Flutter is a broad and advanced topic. You can read more in the official Understanding Flutter’s focus system documentation. The simplest solution is a Focus widget. Add it as the outermost node in the CalculatorBody (in a calculator_body.dart file):


@override
Widget build(BuildContext context) => Focus( autofocus: true, onKey: _onKey, child: Column(...), //existing code ); KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { //TODO handle supported keys return KeyEventResult.ignored; }

 

Note the autofocus: true, the widget will get focus without clicking it or pressing Tab. The onKey callback has to return the status. Use KeyEventResult.handled when you don’t want the default system behavior to happen. If you’re not interested in the particular keystroke, then return KeyEventResult.ignored. The other consumers will receive it then. Create extension functions on RawKeyEvent in the new keycodes.dart file. Such extensions allow simplifying the code inside the onKey callback.


import
'package:flutter/services.dart'; const _deleteKeyId = 0x10000007F; const _backspaceKeyId = 0x100000008; const _cKeyId = 0x00000063; const _vKeyId = 0x00000076; extension KeyCodes on RawKeyEvent { bool isClear() => logicalKey.keyId == _deleteKeyId || logicalKey.keyId == _backspaceKeyId; bool isDigit() { //1 final codeUnit = character?.codeUnitAt(0) ?? 0; return codeUnit >= 0x30 && codeUnit <= 0x39; } bool isCopy() => //2 (isMetaPressed || isControlPressed) && logicalKey.keyId == _cKeyId; bool isPaste() => (isMetaPressed || isControlPressed) && logicalKey.keyId == _vKeyId; }

Here are key points of the code:

  1. Extract the ASCII code of the character key (fall back to 0 for non-character keys).
  2. Check Control and Meta (aka Cmd) keys so macOS keymap will also work.

 

Handling Key Events

In the case of keys with a visual representation such as digits, you can use the character property to get the actual keystroke value. For other keys such as Backspace, use the logicalKey. Note the callback fires on both key press and release for each key. Although, a character isn’t null only on press (down) events. For modifier keys, ShiftControlAltCmd there are special flags such as isControlPressed. When using a Focus widget, you have to detect the key combinations for each operating system separately. There isn’t a built-in method for detecting the paste action. On Windows and Linux, it’s usually Ctrl+C (but also Ctrl+Insert), on macOS, it’s Cmd+C.

Use extensions you created before in _onKey callback inside calculator_body.dart file:


KeyEventResult _onKey(FocusNode node, RawKeyEvent event) { if (event.isDigit()) { _calculator.appendDigit(event.character!); return KeyEventResult.handled; } else if (event.isClear()) { //TODO handle erasing return KeyEventResult.handled; } else if (event.character == '+') { _calculator.appendOperator(Operator.plus); return KeyEventResult.handled; } else if (event.character == '-') { _calculator.appendOperator(Operator.minus); return KeyEventResult.handled; } else if (event.isCopy()) { //TODO handle copying from clipboard return KeyEventResult.handled; } else if (event.isPaste()) { //TODO handle pasing from clipboard return KeyEventResult.handled; } return KeyEventResult.ignored; }

Let’s run the app (Shift+F10) and watch it work! From now, you can enter digits and operands using the physical keyboard:

 

Managing Window Size

The application window concept doesn’t exist natively in Flutter. You have to handle it separately for each platform. You can change the platform-specific files generated by Flutter in the windows folder, but it’s more convenient to operate from the Dart code. You can do that using the window_manager plugin. The plug-in takes care of executing OS-specific actions. It also supports desktop platforms other than Windows. Add the dependency:


fluter pub add window_manager

 

The calculator view should always be in portrait orientation (a vertically oriented rectangle). Moreover, you need reasonable minimum and maximum sizes so the layout doesn’t break. You can enforce those restrictions before calling runApp in the main.dart file:


import
'package:window_manager/window_manager.dart'; import 'package:flutter/material.dart'; import 'calculator_body.dart'; Future<void> main() async { //1 WidgetsFlutterBinding.ensureInitialized(); await windowManager.ensureInitialized(); //2 await windowManager.setMinimumSize(const Size(309, 600)); await windowManager.setMaximumSize(const Size(618, 1200)); await windowManager.setSize(const Size(309, 600)); await windowManager.setAspectRatio(0.54); await windowManager.setMaximizable(false); await windowManager.setTitle('FCalc'); //3 runApp(const FCalcApp()); } class FCalcApp extends StatelessWidget { const FCalcApp({Key? key}) : super(key: key); @override Widget build(BuildContext context) => MaterialApp(home: Scaffold(body: CalculatorBody())); }

 

Here is what the code above does:

  1. Initializes the Flutter and window manager internals.
  2. Sets window properties such as a size, aspect ratio and title.
  3. Starts the app.

Remember that all the window_manager methods are asynchronous. So you have to add awaits to wait until they finish working.

 

Preventing Window Maximization

setMaximumSize is effective only when changing window size, but users can still maximize a window using a system menu bar. You have to disable maximization by calling setMaximizable(false).

Note that the window_manager supports maximization disabling only on Windows. If you want that on macOS or Linux you have to change the platform-specific files in the macos or linux folders. Note they are Swift and C++, not the Dart source files! But, platforms other than Windows are out of the scope of this article.

 

Integrating with System Clipboard

The calculator display isn’t a plain editable text field but a read-only text. Its business logic updates it in a customized way — + and – don’t appear, so the built-in system copy-paste features don’t work out of the box. The keystroke listener is already there, and only the business logic is missing. Now, add the ability to insert many digits at a time to the Calculator class inside calculator.dart file:


void
replaceDigits(String? possibleDigits) { print(possibleDigits); if (possibleDigits != null && int.tryParse(possibleDigits) != null) { _wasDigitJustAppended = true; displayNotifier.value = possibleDigits; } }

 

If pasted data isn’t an integer, nothing happens. Next, fill the missing actions in CalculatorBody._onKey inside calculator_body.dart file:


} else if (event.isCopy()) { Clipboard.setData(ClipboardData(text: _calculator.displayNotifier.value)); return KeyEventResult.handled; } else if (event.isPaste()) { Clipboard.getData(Clipboard.kTextPlain) .then((data) => _calculator.replaceDigits(data?.text)); return KeyEventResult.handled; }

 

Finally, press Alt+Enter to import the Clipboard service. Note that class is already in the Flutter standard library. That’s because mobile platforms also have the clipboard. Keep in mind the getData method is asynchronous so you need to process the result in then callback. You can’t use await here because the onKey method is synchronous.

 

Polishing the Project

The last part of the app is the AC button. Keystroke listener is already there. First, add an action to Calculator inside calculator.dart file:

void clear() {
  _pendingOperation = null;
  _operand = null;
  _wasDigitJustAppended = false;
  displayNotifier.value = '0';
}

Here we reset the state, the pending operation and digits to initial values. Next, bind the method in CalculatorBody._onKey inside calculator_body.dart file:

} else if (event.isClear()) {
  _calculator.clear();
  return KeyEventResult.handled;
} else if (event.character == '+') {

Finally, add the Tile to the GridView:

Tile('AC', (_) => _calculator.clear()),

That’s all. You have a working calculator desktop app written in Flutter! You can run the app using Shift+F10.

 

Where to Go From Here

You can download the complete starter and final projects using the Download Materials button at the top or bottom of this article.

If you want to work further on this project, you can add support for more desktop platforms (Linux and macOS). You can also extend the calculator business logic by adding more arithmetic operations or fractional numbers support.

Buy Me a Coffee

0
Would love your thoughts, please comment.x
()
x