There’s no debate that Flutter’s desktop support is very limited. And the Flutter community actually did an amazing job creating an ecosystem of packages to make Flutter Desktop production ready.
Building a professional-grade desktop application with Flutter requires more than just writing Dart code. Here at Spotube, we've spent years perfecting the craft of cross-platform desktop development, tackling everything from window management to system integration to multi-platform packaging. This series of article breaks down the technical decisions and implementation patterns that make Spotube a production-ready desktop application. We’ll focus on desktop specific features like Window Management, System Tray support and Desktop Notifications
1. Window Management & Lifecycle 🪟
At Spotube, window management is one of the most critical aspects of the desktop experience. We use the window_manager package to give us fine-grained control over window behavior across platforms.
Initialization & Configuration
At Spotube, we always create a wrapper for external libraries for easier refactoring and very easy mocking for unit testing.
In lib/services/wm_tools/wm_tools.dart, we initialize the window manager with platform-aware settings. Because windowManager cannot be called in Android, iOS or Web. This will cause exceptions that might crash the app.
We use Singletons Design Pattern for the services and freezed for immutable data classes
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'wm_tools.freezed.dart';
part 'wm_tools.g.dart';
// Some helper constants. Give emphasis on the kIsWeb constant, it
// must be checked before doing anything with [Platform] object.
// Platform (or more specifically dart:io) is not available on flutter web
final kIsAndroid = kIsWeb ? false : Platform.isAndroid;
final kIsIOS = kIsWeb ? false : Platform.isIOS;
final kIsMobile = kIsAndroid || kIsIOS;
@freezed
class WindowSize with _$WindowSize {
const factory WindowSize({
required double height,
required double width,
required bool maximized,
}) = _WindowSize;
factory WindowSize.fromJson(Map<String, dynamic> json) =>
_$WindowSizeFromJson(json);
}
Above is our Freezed data class WindowSize which we use to store our Window Size Information. And we must do that to ensure when user resizes the window our app remembers to restore the screen to that size on next startup.
Below is the KVStoreService which we use to save stuff to shared_preferences aka local key-value storage. This is also a Singleton and we use a separate class so we have single source of truth for all our key-value data entry and we don’t make mess using only key strings.
abstract class KVStoreService {
static SharedPreferences? _sharedPreferences;
static SharedPreferences get sharedPreferences => _sharedPreferences!;
static Future<void> initialize() async {
_sharedPreferences = await SharedPreferences.getInstance();
}
static WindowSize? get windowSize {
final raw = sharedPreferences.getString('windowSize');
if (raw == null) {
return null;
}
return WindowSize.fromJson(jsonDecode(raw));
}
static Future<void> setWindowSize(WindowSize value) async =>
await sharedPreferences.setString(
'windowSize',
jsonEncode(
value.toJson(),
),
);
}
Now finally, this is our actual WindowManagerTools singleton service. windowManager is also a singleton from the window_manager package because currently Flutter only has a single window support.
class WindowManagerTools with WidgetsBindingObserver {
// Classic/idiomatic Dart way of implementing singletons
static WindowManagerTools? _instance;
static WindowManagerTools get instance => _instance!;
// We make the constructor private
WindowManagerTools._();
// This function must be called in the main.dart's main() function.
static Future<void> initialize() async {
await windowManager.ensureInitialized();
_instance = WindowManagerTools._();
// Adding this WindowManagerTools singleton as a WidgetsBinding observer
// to recieve life-cycle updates when window is resized or moved
WidgetsBinding.instance.addObserver(instance);
await windowManager.waitUntilReadyToShow(
const WindowOptions(
title: "\"Spotube\","
backgroundColor: Colors.transparent,
// minimum size must be set otherwise users can shrink the windows into a
// single pixel. I don't think anyone wants add responsiveness down to
// 1 pixel lmao.
minimumSize: Size(300, 700),
titleBarStyle: TitleBarStyle.hidden, // This hides the titlebar
// Opens the app in Center of the Monitor/screen
center: true,
),
() async {
// Restoring window size
final savedSize = KVStoreService.windowSize;
await windowManager.setResizable(true);
if (savedSize?.maximized == true &&
!(await windowManager.isMaximized())) {
await windowManager.maximize();
} else if (savedSize != null) {
await windowManager.setSize(Size(savedSize.width, savedSize.height));
}
await windowManager.focus();
await windowManager.show();
},
);
}
// To ignore same size events and avoid unnecessary resizing operations
Size? _prevSize;
// This is why we extended from WidgetsBindingObserver which gives us
// these life-cycle methods that we can listen to
@override
void didChangeMetrics() async {
super.didChangeMetrics();
// We are ignoring mobile and web platforms
if (kIsMobile || kIsWeb) return;
// windowManager.getSize gets the current size of the Window
final size = await windowManager.getSize();
final windowSameDimension =
_prevSize?.width == size.width && _prevSize?.height == size.height;
if (windowSameDimension || _prevSize == null) {
_prevSize = size;
return;
}
final isMaximized = await windowManager.isMaximized();
await KVStoreService.setWindowSize(
WindowSize(
height: size.height,
width: size.width,
maximized: isMaximized,
),
);
_prevSize = size;
}
}
Key takeaways:
-
TitleBarStyle.hidden: We hide the native title bar because we implement a custom one in Dart (more on this later) - Window size persistence: We save and restore window dimensions, including maximized state
- Minimum size constraint: Prevents UI breakdown on very small windows
-
Observer pattern: By extending
WidgetsBindingObserver, we listen to window resize events
Multi-window can be implemented through
desktop_multi_windowpackage but that is not the focus of this article.
Handling Close Behavior
Here's where Spotube excels, we give users control over what happens when they click the close button. In lib/hooks/configurators/use_close_behavior.dart:
void useCloseBehavior(WidgetRef ref) {
useWindowListener(
onWindowClose: () async {
final preferences = ref.read(userPreferencesProvider);
if (preferences.closeBehavior == CloseBehavior.minimizeToTray) {
await windowManager.hide();
closeNotification?.show();
} else {
exit(0);
}
},
);
}
This allows users to either:
- Close completely - Exit the app immediately
- Minimize to system tray - Keep audio playing in the background
This is set in lib/main.dart:
if (kIsDesktop) {
await windowManager.setPreventClose(true);
// ... other initialization
}
By setting setPreventClose(true), we intercept the close event before it propagates to the OS, giving us control over the behavior.
Now if you know, you might be thinking the above code looks like React as it is clearly react-hooks, right?? You got exactly right. Spotube uses flutter_hooks package in combination with riverpod’s hooks_riverpod package. This reduces insane Flutter boilerplate code and helps focus on things that matter more. The
useWindowListeneris also a custom hook that implementwindow_manager’sWindowListenerinterface and converts it into a simple callback based hook that can be used anywhere.
Take a look at: lib\hooks\configurators\use_window_listener.dart for understanding what we’re doing.
Platform-Specific Window Fixes
In lib/hooks/configurators/use_fix_window_stretching.dart handles a quirky Windows issue where the Window content of the initial page stretches and causes weird layout that is not something a user should see.
The bug is still not fixed to this day and is actually caused by the Flutter Window embedder in Windows that does not provide proper size information on the first frame
The GitHub issue: leanflutter/window_manager#464
To fix this, a simple fix was to resize the window by 1 pixel and that fixed the issue, a workaround.
void useFixWindowStretching() {
useEffect(() {
if (!kIsWindows) return;
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 100), () {
windowManager.getSize().then((Size value) {
windowManager.setSize(
Size(value.width + 1, value.height + 1),
);
});
});
});
return null;
}, []);
}
This must be called in all the route pages that can be an initial page. We call it in our root App instance.
2. Custom Title Bar Implementation 🎨
Since we hide the native title bar, we implement a fully custom one. This gives us complete control over appearance while maintaining platform-specific behaviors.
The TitleBar Widget
TitleBar is an AppBar replacement from Flutter Material. This widget integrates window_manager and necessary paddings, gesutres and window buttons when the platform is desktop.
final kTitlebarVisible = kIsWindows || kIsLinux;
/// We're using [HookConsumerWidget] which is a widget from hooks_riverpod package
/// that allows us to use hooks and also consume providers.
///
/// The [PreferredSizeWidget] interface as it is required by the Scaffold's appBar property,
/// which allows us to specify the preferred size of the title bar.
class TitleBar extends HookConsumerWidget implements PreferredSizeWidget {
final bool automaticallyImplyLeading;
final List<Widget> trailing;
final List<Widget> leading;
final Widget? child;
final Widget? title;
final Widget? header; // small widget placed on top of title
final Widget? subtitle; // small widget placed below title
final bool
trailingExpanded; // expand the trailing instead of the main content
final AlignmentGeometry alignment;
final Color? backgroundColor;
final Color? foregroundColor;
final double? leadingGap;
final double? trailingGap;
final EdgeInsetsGeometry? padding;
final double? height;
final bool useSafeArea;
final double? surfaceBlur;
final double? surfaceOpacity;
const TitleBar({
super.key,
this.automaticallyImplyLeading = true,
this.trailing = const [],
this.leading = const [],
this.title,
this.header,
this.subtitle,
this.child,
this.trailingExpanded = false,
this.alignment = Alignment.center,
this.padding,
this.backgroundColor,
this.foregroundColor,
this.leadingGap,
this.trailingGap,
this.height,
this.surfaceBlur,
this.surfaceOpacity,
this.useSafeArea = false,
});
// Enabling dragging the window by dragging the titlebar support
void onDrag(WidgetRef ref) {
// At Spotube, we even allow users to use a customer titlebar or native titlebar
// Not every app needs to do this, but our philosophy is to give users the freedom
// to customize their experience as much as possible
final systemTitleBar =
ref.read(userPreferencesProvider.select((s) => s.systemTitleBar));
if (kIsDesktop && !systemTitleBar) {
windowManager.startDragging(); // a simple API to start dragging the window when the user drags the titlebar
}
}
@override
Widget build(BuildContext context, ref) {
final hasLeadingOrCanPop = leading.isNotEmpty || Navigator.canPop(context);
// We need to use useRef here to store the last clicked
// time for double click detection. Because drag gesture
// disables double click.
final lastClicked = useRef<int>(DateTime.now().millisecondsSinceEpoch);
return SizedBox(
height: height ?? (48 * context.theme.scaling),
child: LayoutBuilder(
builder: (context, constraints) {
final hasFullscreen =
MediaQuery.sizeOf(context).width == constraints.maxWidth;
final canPop = leading.isEmpty &&
automaticallyImplyLeading &&
(Navigator.canPop(context) || context.watchRouter.canPop());
return GestureDetector(
onHorizontalDragStart: (_) => onDrag(ref),
onVerticalDragStart: (_) => onDrag(ref),
onTapDown: (details) async {
// If the user has enabled system title bar, we don't need to handle double click to maximize the window
final systemTitlebar = ref.read(
userPreferencesProvider.select((s) => s.systemTitleBar));
if (!kIsDesktop || systemTitlebar) return;
// Detect double click to maximize/unmaximize the window
int currMills = DateTime.now().millisecondsSinceEpoch;
if ((currMills - lastClicked.value) < 500) {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
} else {
await windowManager.maximize();
}
} else {
lastClicked.value = currMills;
}
},
child: AppBar(
leading: Row(
mainAxisSize: MainAxisSize.min,
children: canPop ? [const BackButton()] : leading
),
actions: [
...trailing,
// These are the custom titlebar buttons
Align(
alignment: Alignment.topRight,
child:
WindowTitleBarButtons(foregroundColor: foregroundColor),
),
],
title: "title,"
header: header,
subtitle: "subtitle,"
trailingExpanded: trailingExpanded,
alignment: alignment,
padding: padding ?? EdgeInsets.zero,
backgroundColor: backgroundColor,
leadingGap: leadingGap,
trailingGap: trailingGap,
height: height ?? (48 * context.theme.scaling),
surfaceBlur: surfaceBlur,
surfaceOpacity: surfaceOpacity,
useSafeArea: useSafeArea,
child: child,
).withPadding(
// This is where we handle macOS's titlebar buttons
// On macOS, we can't have custom titlebar buttons, so we need to make
// sure there is space for the platform buttons
left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0),
);
},
),
);
}
// The preferred size of the title bar is 48 by default, but it can be customized by passing a height parameter.
@override
Size get preferredSize => Size.fromHeight(height ?? 48);
}
Key features:
-
Drag to move:
windowManager.startDragging()lets users drag the title bar to move the window - Double-click to maximize: Just like native title bars, double-clicking toggles maximize state
-
Platform-aware macOS padding: On macOS with fullscreen, we add extra padding for the traffic light buttons:
.withPadding(left: kIsMacOS && hasFullscreen && hasLeadingOrCanPop ? 65 : 0)
TitleBar Buttons
You can see the WindowTitleBarButtons buttons in our repository at: **lib\components\titlebar.** It uses windowManager ’s different methods to show/hide, maximize/restore or minimize the application.
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/hover_builder.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/titlebar/titlebar_icon_buttons.dart';
import 'package:spotube/hooks/configurators/use_window_listener.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:titlebar_buttons/titlebar_buttons.dart';
import 'package:window_manager/window_manager.dart';
class WindowTitleBarButtons extends HookConsumerWidget {
final Color? foregroundColor;
const WindowTitleBarButtons({
super.key,
this.foregroundColor,
});
@override
Widget build(BuildContext context, ref) {
final preferences = ref.watch(userPreferencesProvider);
final isMaximized = useState<bool?>(null);
const type = ThemeType.auto;
Future<void> onClose() async {
await windowManager.close();
}
// The useWindowListener we talked about earlier came in handy
useWindowListener(
onWindowMaximize: () {
isMaximized.value = true;
},
onWindowUnmaximize: () {
isMaximized.value = false;
},
);
useEffect(() {
if (kIsDesktop) {
windowManager.isMaximized().then((value) {
isMaximized.value = value;
});
}
return null;
}, []);
if (!kTitlebarVisible || preferences.systemTitleBar) {
return const SizedBox.shrink();
}
// Windows specific buttons
if (kIsWindows) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ShadcnWindowButton(
icon: MinimizeIcon(color: context.theme.colorScheme.foreground),
onPressed: windowManager.minimize,
),
if (isMaximized.value != true)
ShadcnWindowButton(
icon: MaximizeIcon(color: context.theme.colorScheme.foreground),
onPressed: () {
windowManager.maximize();
isMaximized.value = true;
},
)
else
ShadcnWindowButton(
icon: RestoreIcon(color: context.theme.colorScheme.foreground),
onPressed: () {
windowManager.unmaximize();
isMaximized.value = false;
},
),
HoverBuilder(builder: (context, isHovered) {
return ShadcnWindowButton(
icon: CloseIcon(
color: isHovered
? Colors.white
: context.theme.colorScheme.foreground,
),
onPressed: onClose,
hoverBackgroundColor: const Color(0xFFD32F2F),
);
}),
],
);
}
// These are linux specific buttons
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DecoratedMinimizeButton(
type: type,
onPressed: windowManager.minimize,
),
DecoratedMaximizeButton(
type: type,
onPressed: () async {
if (await windowManager.isMaximized()) {
await windowManager.unmaximize();
isMaximized.value = false;
} else {
await windowManager.maximize();
isMaximized.value = true;
}
},
),
DecoratedCloseButton(
type: type,
onPressed: onClose,
),
],
);
}
}
Windows uses custom in house components that we had to create. And Linux actually uses a package titlebar_buttons which gives accurate titlebar buttons based on dbus config in Linux for different desktop environment, like GTK, Gnome, Cinnamon, KDE etc flavors.
Wondering about shadcn? (Smirk) we don’t use material anymore. Spotube uses
shadcn_flutterwhich is a shadncn implementation in Flutter based on material.
Conclusion: Bringing It All Together
Building professional desktop experiences in Flutter isn't about individual features in isolation, it's about how they work together seamlessly. At Spotube, window management, custom title bars, and platform-specific implementations form the foundation of a polished desktop application.
The key insight we've learned over years of development is this: respect the platform conventions while maintaining a unified codebase. Windows users expect native window buttons and specific click behaviors. macOS users expect traffic light buttons in the right positions. Linux users expect their desktop environment's native styling. By handling these details thoughtfully, we create an application that feels native on each platform, not just a "web app in a window."
What We've Covered
- Window Management: Persisting state, handling lifecycle events, and working around platform-specific quirks
- Custom Title Bars: Implementing native interactions (drag to move, double-click to maximize) with platform-aware positioning
- Platform Integration: Using the right tools and patterns for Windows, macOS, and Linux
Moving Forward
The Flutter community has done remarkable work filling the gaps in Flutter's desktop support. Packages like window_manager, titlebar_buttons, and shadcn_flutter make it possible to build truly professional desktop applications. The patterns we've shared at Spotube, singletons for services, custom hooks for state management, platform-aware configuration, are battle-tested and ready for your own projects.
If you're building a desktop app with Flutter, remember: the small details matter. Users notice when windows restore their previous size, when title bars respond naturally to interactions, and when your app feels like it belongs on their platform.
Happy building! 🚀
Follow the full 'Flutter Desktop Mastery' series on the official blog: https://spotube.krtirtho.dev/blog/flutter-desktop-mastery/part-1-window-management/
Top comments (0)