惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Jina AI
Jina AI
博客园 - 司徒正美
大猫的无限游戏
大猫的无限游戏
博客园 - 三生石上(FineUI控件)
J
Java Code Geeks
博客园 - 聂微东
酷 壳 – CoolShell
酷 壳 – CoolShell
爱范儿
爱范儿
美团技术团队
腾讯CDC
博客园 - Franky
MyScale Blog
MyScale Blog
人人都是产品经理
人人都是产品经理
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
月光博客
月光博客
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
aimingoo的专栏
aimingoo的专栏
博客园_首页
V
V2EX
Martin Fowler
Martin Fowler
T
The Blog of Author Tim Ferriss

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Flutter Desktop Mastery 1: Window Management
Kingkor Roy · 2026-05-15 · via DEV Community

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/