DEV Community

Cover image for Flutter Desktop Mastery 1: Window Management
Kingkor Roy Tirtho
Kingkor Roy Tirtho

Posted on • Originally published at spotube.krtirtho.dev

Flutter Desktop Mastery 1: Window Management

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);
}
Enter fullscreen mode Exit fullscreen mode

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(),
        ),
      );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

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_window package 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);
      }
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

This allows users to either:

  1. Close completely - Exit the app immediately
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

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 useWindowListener is also a custom hook that implement window_manager’s WindowListener interface 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;
  }, []);
}
Enter fullscreen mode Exit fullscreen mode

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);
}

Enter fullscreen mode Exit fullscreen mode

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,
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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_flutter which 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)