Perché Flutter Desktop oggi

Il supporto desktop di Flutter è stabile da diverse release ormai e non è più una curiosità sperimentale: molte aziende distribuiscono già tool interni, editor e client desktop scritti interamente in Dart. Il vantaggio è evidente — lo stesso codebase di un'app mobile può girare su Windows, macOS e Linux — ma il rischio è altrettanto evidente: pubblicare su desktop un'app pensata per il touch produce un risultato che sembra "un'app Android ingrandita".

In questo articolo vediamo cosa serve davvero per portare un progetto Flutter su desktop in modo credibile: configurazione della finestra, menu di sistema nativi, scorciatoie da tastiera, drag & drop di file, system tray e distribuzione.

Abilitare e verificare il supporto desktop

Il supporto è incluso nel canale stable. Verifichiamo che sia attivo:

flutter config --enable-windows-desktop
flutter config --enable-macos-desktop
flutter config --enable-linux-desktop

flutter devices

Se il progetto esiste già ma è stato creato senza le piattaforme desktop, basta rigenerare le cartelle native:

flutter create --platforms=windows,macos,linux .

Questo comando aggiunge windows/, macos/ e linux/ senza toccare lib/. A questo punto:

flutter run -d windows   # oppure macos / linux

Requisiti di build

Piattaforma Toolchain necessaria
Windows Visual Studio con workload "Desktop development with C++"
macOS Xcode + CocoaPods
Linux clang, cmake, ninja-build, libgtk-3-dev, pkg-config

Su Linux, su una Debian/Ubuntu pulita:

sudo apt install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev

Distinguere il desktop dal mobile nel codice

Evitate di sparpagliare if (Platform.isWindows) in tutta l'app. Meglio centralizzare in un piccolo helper, ricordando che dart:io non è disponibile su Web:

import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';

abstract final class AppPlatform {
  static bool get isDesktop =>
      !kIsWeb &&
      (Platform.isWindows || Platform.isMacOS || Platform.isLinux);

  static bool get isMobile =>
      !kIsWeb && (Platform.isAndroid || Platform.isIOS);

  /// Su desktop la scorciatoia primaria è Ctrl, su macOS è Cmd.
  static bool get usesCommandKey => !kIsWeb && Platform.isMacOS;
}

Per scelte puramente di layout, però, la domanda giusta non è "su che sistema operativo sono?" ma "quanto spazio ho?". Un tablet Android in landscape e un laptop hanno esigenze simili: usate LayoutBuilder per la struttura e AppPlatform solo per le funzionalità realmente specifiche della piattaforma (menu, finestre, tray).

Controllare la finestra con window_manager

Su desktop l'app è una finestra: dimensione minima, titolo, posizione e comportamento alla chiusura sono responsabilità nostra. Il pacchetto window_manager copre tutti questi aspetti.

dependencies:
  window_manager: ^0.4.3

Inizializzazione nel main:

import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (AppPlatform.isDesktop) {
    await windowManager.ensureInitialized();

    const options = WindowOptions(
      size: Size(1280, 800),
      minimumSize: Size(900, 600),
      center: true,
      title: 'Gestionale Pro',
      titleBarStyle: TitleBarStyle.normal,
    );

    await windowManager.waitUntilReadyToShow(options, () async {
      await windowManager.show();
      await windowManager.focus();
    });
  }

  runApp(const MyApp());
}

waitUntilReadyToShow evita il classico "flash" della finestra bianca prima che Flutter abbia disegnato il primo frame: la finestra viene mostrata solo quando è pronta. Ricordate di impostare windowOptions.show: false nel codice nativo se volete il controllo totale (su Windows si edita windows/runner/main.cpp, ma nella maggior parte dei casi la configurazione Dart basta).

Intercettare la chiusura per chiedere conferma

Un'app desktop che perde dati non salvati alla chiusura è inaccettabile. window_manager permette di intercettare l'evento:

class EditorPage extends StatefulWidget {
  const EditorPage({super.key});
  @override
  State<EditorPage> createState() => _EditorPageState();
}

class _EditorPageState extends State<EditorPage> with WindowListener {
  bool _hasUnsavedChanges = true;

  @override
  void initState() {
    super.initState();
    windowManager.addListener(this);
    windowManager.setPreventClose(true);
  }

  @override
  void dispose() {
    windowManager.removeListener(this);
    super.dispose();
  }

  @override
  void onWindowClose() async {
    if (!_hasUnsavedChanges) {
      await windowManager.destroy();
      return;
    }
    final confirm = await showDialog<bool>(
      context: context,
      builder: (_) => AlertDialog(
        title: const Text('Modifiche non salvate'),
        content: const Text('Vuoi uscire senza salvare?'),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Annulla'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Esci'),
          ),
        ],
      ),
    );
    if (confirm ?? false) await windowManager.destroy();
  }

  @override
  Widget build(BuildContext context) => const Placeholder();
}

Menu nativi con PlatformMenuBar

Su macOS la barra dei menu è in cima allo schermo ed è un requisito di fatto per qualunque app seria. Flutter offre PlatformMenuBar che, su macOS, produce un menu realmente nativo (su Windows e Linux il widget viene ignorato, quindi lì serve una menu bar disegnata in Flutter o MenuBar di Material 3).

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

Widget buildWithMenus(BuildContext context, Widget child) {
  return PlatformMenuBar(
    menus: [
      PlatformMenu(
        label: 'File',
        menus: [
          PlatformMenuItem(
            label: 'Nuovo documento',
            shortcut: const SingleActivator(LogicalKeyboardKey.keyN, meta: true),
            onSelected: () => debugPrint('Nuovo'),
          ),
          PlatformMenuItem(
            label: 'Salva',
            shortcut: const SingleActivator(LogicalKeyboardKey.keyS, meta: true),
            onSelected: () => debugPrint('Salva'),
          ),
          const PlatformMenuItemGroup(
            members: [
              PlatformProvidedMenuItem(
                type: PlatformProvidedMenuItemType.quit,
              ),
            ],
          ),
        ],
      ),
    ],
    child: child,
  );
}

Per Windows e Linux, la MenuBar di Material 3 offre un'esperienza coerente disegnata da Flutter:

MenuBar(
  children: [
    SubmenuButton(
      menuChildren: [
        MenuItemButton(
          shortcut: const SingleActivator(LogicalKeyboardKey.keyN, control: true),
          onPressed: () {},
          child: const Text('Nuovo documento'),
        ),
        MenuItemButton(
          onPressed: () {},
          child: const Text('Esci'),
        ),
      ],
      child: const Text('File'),
    ),
  ],
)

Drag & drop di file dal sistema operativo

Trascinare un file dall'Esplora risorse dentro l'app è un gesto che gli utenti desktop danno per scontato. Il pacchetto desktop_drop lo rende banale:

dependencies:
  desktop_drop: ^0.5.0
  cross_file: ^0.3.4
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';

class DropZone extends StatefulWidget {
  const DropZone({super.key, required this.onFiles});
  final ValueChanged<List<XFile>> onFiles;

  @override
  State<DropZone> createState() => _DropZoneState();
}

class _DropZoneState extends State<DropZone> {
  bool _hovering = false;

  @override
  Widget build(BuildContext context) {
    return DropTarget(
      onDragEntered: (_) => setState(() => _hovering = true),
      onDragExited: (_) => setState(() => _hovering = false),
      onDragDone: (detail) {
        setState(() => _hovering = false);
        widget.onFiles(detail.files);
      },
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 150),
        decoration: BoxDecoration(
          border: Border.all(
            color: _hovering
                ? Theme.of(context).colorScheme.primary
                : Theme.of(context).dividerColor,
            width: _hovering ? 2 : 1,
          ),
          borderRadius: BorderRadius.circular(12),
        ),
        padding: const EdgeInsets.all(48),
        child: const Center(child: Text('Trascina qui i tuoi file')),
      ),
    );
  }
}

Per il percorso inverso — aprire o salvare file con il selettore di sistema — usate file_selector, che è mantenuto dal team Flutter e funziona su tutte e tre le piattaforme desktop:

import 'package:file_selector/file_selector.dart';

Future<void> esportaReport(String csv) async {
  final location = await getSaveLocation(
    suggestedName: 'report.csv',
    acceptedTypeGroups: const [
      XTypeGroup(label: 'CSV', extensions: ['csv']),
    ],
  );
  if (location == null) return; // l'utente ha annullato

  final data = XFile.fromData(
    utf8.encode(csv),
    mimeType: 'text/csv',
    name: 'report.csv',
  );
  await data.saveTo(location.path);
}

Attenzione su macOS: se l'app è in sandbox, dovete abilitare i permessi di lettura/scrittura nei file macos/Runner/DebugProfile.entitlements e Release.entitlements (com.apple.security.files.user-selected.read-write), altrimenti il salvataggio fallisce silenziosamente.

System tray e app che restano in background

Molte utility desktop vivono nella tray e non nella taskbar. Con tray_manager è questione di poche righe:

import 'package:tray_manager/tray_manager.dart';

Future<void> initTray() async {
  await trayManager.setIcon(
    Platform.isWindows ? 'assets/tray.ico' : 'assets/tray.png',
  );
  await trayManager.setContextMenu(Menu(items: [
    MenuItem(key: 'show', label: 'Apri finestra'),
    MenuItem.separator(),
    MenuItem(key: 'exit', label: 'Esci'),
  ]));
}

Combinato con windowManager.hide() in onWindowClose, ottenete il classico comportamento "chiudi = minimizza nella tray".

Adattare la UI alle abitudini desktop

Alcuni accorgimenti fanno la differenza tra un'app che "sembra portata" e una che sembra nativa:

  • Hover: su desktop il puntatore esiste. Usate MouseRegion e InkWell (che gestisce già l'hover) per dare feedback visivo. Impostate cursor: SystemMouseCursors.click sugli elementi cliccabili non standard.
  • Scrollbar sempre visibili: avvolgete le liste in Scrollbar(thumbVisibility: true); su desktop la scrollbar che appare e sparisce disorienta.
  • Target più piccoli: i 48dp di Material pensati per il dito sono eccessivi col mouse. Impostate visualDensity: VisualDensity.compact (o VisualDensity.adaptivePlatformDensity) nel tema.
  • Scorciatoie da tastiera: Shortcuts + Actions sono il modo canonico; ricordate Ctrl su Windows/Linux e Cmd (meta) su macOS.
  • Tooltip: su desktop sono attesi e gratuiti — avvolgete le IconButton in Tooltip.
  • Navigazione: sostituite la BottomNavigationBar con NavigationRail o un drawer permanente quando la larghezza supera i ~900px.
MaterialApp(
  theme: ThemeData(
    useMaterial3: true,
    visualDensity: VisualDensity.adaptivePlatformDensity,
    scrollbarTheme: const ScrollbarThemeData(
      thumbVisibility: WidgetStatePropertyAll(true),
    ),
  ),
  home: const HomePage(),
);

Scroll con il mouse su liste orizzontali

Un dettaglio che spesso sfugge: su desktop lo scroll con trackpad orizzontale funziona, ma trascinare con il mouse su una ListView orizzontale no, perché di default il drag col mouse non è abilitato come input di scroll. Si risolve con uno ScrollBehavior personalizzato:

class DesktopScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
        PointerDeviceKind.touch,
        PointerDeviceKind.mouse,
        PointerDeviceKind.trackpad,
        PointerDeviceKind.stylus,
      };
}

// MaterialApp(scrollBehavior: DesktopScrollBehavior(), ...)

Distribuzione: dal binario all'installer

La build di release produce un eseguibile, non un pacchetto installabile. Ecco il percorso tipico per ciascuna piattaforma.

Windowsflutter build windows --release genera build/windows/x64/runner/Release/. Per la distribuzione, il pacchetto msix crea un installer firmato pronto per il Microsoft Store o per la distribuzione diretta:

dev_dependencies:
  msix: ^3.16.8

msix_config:
  display_name: Gestionale Pro
  publisher_display_name: Acme Srl
  identity_name: it.acme.gestionale
  msix_version: 1.0.0.0
  logo_path: assets/logo.png
  capabilities: internetClient
dart run msix:create

In alternativa, Inno Setup resta lo standard de facto per gli installer .exe classici.

macOSflutter build macos --release produce un .app. Per distribuirlo fuori dallo store servono firma con un Developer ID e notarizzazione presso Apple, altrimenti Gatekeeper blocca l'avvio:

xcrun notarytool submit MyApp.zip \
  --apple-id you@example.com --team-id TEAMID --wait
xcrun stapler staple MyApp.app

Linuxflutter build linux --release genera un bundle in build/linux/x64/release/bundle/. Da lì si confeziona uno Snap (snapcraft.yaml), un Flatpak o un semplice .deb. Snap e Flatpak sono le vie più comode perché portano con sé le dipendenze GTK.

Errori frequenti da evitare

  1. Assumere che il touch non esista: molti laptop Windows hanno schermo touch. Progettate per entrambi gli input.
  2. Ignorare il ridimensionamento estremo: gli utenti desktop trascinano le finestre a metà schermo. Impostate sempre minimumSize e testate a 900×600.
  3. Dimenticare i permessi macOS: senza com.apple.security.network.client negli entitlements, le chiamate HTTP falliscono solo in release. È l'errore più segnalato di sempre.
  4. Usare pacchetti solo mobile: prima di aggiungere una dipendenza verificate su pub.dev i badge di piattaforma. Molti plugin popolari (es. per la biometria o le notifiche) hanno supporto desktop parziale o assente.
  5. Non gestire le finestre multiple: Flutter sta introducendo il supporto multi-window nel framework; nel frattempo, se serve, valutate desktop_multi_window.

Conclusione

Portare un'app Flutter su desktop non è un flutter build e via: richiede di ripensare navigazione, input e ciclo di vita della finestra. La buona notizia è che l'ecosistema è maturo — window_manager, file_selector, desktop_drop e tray_manager coprono il 90% delle esigenze — e che il 95% della logica di business resta invariata.

Il consiglio operativo: partite da un layout responsive già solido, aggiungete le funzionalità desktop dietro un flag come AppPlatform.isDesktop e mettete la firma/notarizzazione nella pipeline CI fin dal primo giorno. È la parte più noiosa, ed è quella che tutti scoprono troppo tardi.