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.entitlementseRelease.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
MouseRegioneInkWell(che gestisce già l'hover) per dare feedback visivo. Impostatecursor: SystemMouseCursors.clicksugli 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(oVisualDensity.adaptivePlatformDensity) nel tema. - Scorciatoie da tastiera:
Shortcuts+Actionssono il modo canonico; ricordateCtrlsu Windows/Linux eCmd(meta) su macOS. - Tooltip: su desktop sono attesi e gratuiti — avvolgete le
IconButtoninTooltip. - Navigazione: sostituite la
BottomNavigationBarconNavigationRailo 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.
Windows — flutter 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.
macOS — flutter 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
Linux — flutter 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
- Assumere che il touch non esista: molti laptop Windows hanno schermo touch. Progettate per entrambi gli input.
- Ignorare il ridimensionamento estremo: gli utenti desktop trascinano le finestre a metà schermo. Impostate sempre
minimumSizee testate a 900×600. - Dimenticare i permessi macOS: senza
com.apple.security.network.clientnegli entitlements, le chiamate HTTP falliscono solo in release. È l'errore più segnalato di sempre. - 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.
- 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.