desktop_tray 2026.4.19 copy "desktop_tray: ^2026.4.19" to clipboard
desktop_tray: ^2026.4.19 copied to clipboard

A Flutter plugin for managing system tray icons and menus on desktop platforms (Windows, macOS, Linux).

example/lib/main.dart

// A complete demonstration of the `desktop_tray` plugin.
//
// Place your own icon files under `example/assets/icons/` before running:
//   - assets/icons/logo.png   (used on macOS / Linux / Windows via GDI+)
//   - assets/icons/logo.ico   (optional, best quality on Windows)
//   - assets/icons/alt.png    (a second icon to demo live switching)
//
// Then run:  flutter run -d windows   (or macos / linux)

import 'dart:io';

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

void main() {
  runApp(const DemoApp());
}

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'desktop_tray demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
      home: const DemoHome(),
    );
  }
}

// Menu item keys (shared between building the menu and handling clicks).
const String kShow = 'show';
const String kHide = 'hide';
const String kMute = 'mute';
const String kNotifications = 'notifications';
const String kSubA = 'sub_a';
const String kSubB = 'sub_b';
const String kSubNested = 'sub_nested';
const String kDisabled = 'disabled';
const String kAbout = 'about';
const String kExit = 'exit';

class DemoHome extends StatefulWidget {
  const DemoHome({super.key});

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> with DesktopTrayListener {
  // ───────────── Tray state that the UI edits ─────────────
  final List<String> _icons = const [
    'assets/icons/logo.png',
    'assets/icons/logo.ico',
    'assets/icons/alt.png',
  ];
  String _currentIcon = 'assets/icons/logo.png';
  final TextEditingController _tooltipCtrl = TextEditingController(
    text: 'desktop_tray demo',
  );

  bool _muted = false;
  bool _notifications = true;

  bool _trayAlive = false;
  bool _available = true;

  // Scrolling event log (newest on top).
  final List<_LogEntry> _log = [];
  final ScrollController _logCtrl = ScrollController();

  @override
  void initState() {
    super.initState();
    desktopTray.addListener(this);
    WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrapTray());
  }

  @override
  void dispose() {
    desktopTray.removeListener(this);
    desktopTray.destroy();
    _tooltipCtrl.dispose();
    _logCtrl.dispose();
    super.dispose();
  }

  // ───────────── Tray lifecycle ─────────────

  Future<void> _bootstrapTray() async {
    final available = await desktopTray.checkAvailable();
    setState(() => _available = available);
    if (!available) {
      _append('tray unavailable (no StatusNotifierWatcher on D-Bus)');
      return;
    }

    await _applyIcon(_currentIcon);
    await desktopTray.setToolTip(_tooltipCtrl.text);
    await _rebuildMenu();
    setState(() => _trayAlive = true);
    _append('tray initialised');
  }

  Future<void> _applyIcon(String path) async {
    try {
      await desktopTray.setIcon(path);
      setState(() => _currentIcon = path);
      _append('setIcon → $path');
    } on PlatformException catch (e) {
      _append('setIcon failed: ${e.code} ${e.message}');
    }
  }

  Future<void> _applyTooltip() async {
    await desktopTray.setToolTip(_tooltipCtrl.text);
    _append('setToolTip → "${_tooltipCtrl.text}"');
  }

  Future<void> _rebuildMenu() async {
    final menu = TrayMenu(
      items: [
        TrayMenuItem(key: kShow, label: 'Show Window'),
        TrayMenuItem(key: kHide, label: 'Hide Window'),
        TrayMenuItem.separator(),
        TrayMenuItem.checkbox(key: kMute, label: 'Mute', checked: _muted),
        TrayMenuItem.checkbox(
          key: kNotifications,
          label: 'Enable Notifications',
          checked: _notifications,
        ),
        TrayMenuItem.separator(),
        TrayMenuItem.submenu(
          key: 'preferences',
          label: 'Preferences',
          children: [
            TrayMenuItem(key: kSubA, label: 'Option A'),
            TrayMenuItem(key: kSubB, label: 'Option B'),
            TrayMenuItem.submenu(
              key: 'nested',
              label: 'More',
              children: [TrayMenuItem(key: kSubNested, label: 'Nested item')],
            ),
          ],
        ),
        TrayMenuItem(key: kDisabled, label: 'Disabled item', disabled: true),
        TrayMenuItem.separator(),
        TrayMenuItem(key: kAbout, label: 'About'),
        TrayMenuItem(key: kExit, label: 'Exit'),
      ],
    );

    await desktopTray.setContextMenu(menu);

    // Demonstrate findByKey: log the id of the mute item.
    final found = menu.findByKey(kMute);
    _append('findByKey("$kMute") → id=${found?.id}');
  }

  Future<void> _popUp() async {
    await desktopTray.popUpContextMenu();
    _append('popUpContextMenu()');
  }

  Future<void> _destroyTray() async {
    await desktopTray.destroy();
    setState(() => _trayAlive = false);
    _append('destroy()');
  }

  Future<void> _recreateTray() => _bootstrapTray();

  // ───────────── DesktopTrayListener ─────────────

  @override
  void onTrayIconMouseDown() => _append('onTrayIconMouseDown');

  @override
  void onTrayIconMouseUp() {
    _append('onTrayIconMouseUp');
    // Common pattern on Windows / macOS: open menu on left-click-up.
    if (!Platform.isLinux) desktopTray.popUpContextMenu();
  }

  @override
  void onTrayIconRightMouseDown() => _append('onTrayIconRightMouseDown');

  @override
  void onTrayIconRightMouseUp() {
    _append('onTrayIconRightMouseUp');
    if (Platform.isMacOS) desktopTray.popUpContextMenu();
  }

  @override
  void onTrayMenuItemClick(TrayMenuItem item) {
    _append('onTrayMenuItemClick key="${item.key}" id=${item.id}');
    switch (item.key) {
      case kShow:
        // In a real app: call window_manager.show()
        break;
      case kHide:
        break;
      case kMute:
        setState(() => _muted = !_muted);
        _rebuildMenu();
      case kNotifications:
        setState(() => _notifications = !_notifications);
        _rebuildMenu();
      case kAbout:
        _append('About: desktop_tray example');
      case kExit:
        desktopTray.destroy().then((_) => exit(0));
    }
  }

  // ───────────── Logging helper ─────────────

  void _append(String msg) {
    setState(() {
      _log.insert(0, _LogEntry(DateTime.now(), msg));
      if (_log.length > 200) _log.removeLast();
    });
  }

  // ───────────── UI ─────────────

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Scaffold(
      appBar: AppBar(
        title: const Text('desktop_tray demo'),
        actions: [
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 12),
            child: Chip(
              label: Text(_trayAlive ? 'tray: alive' : 'tray: dead'),
              backgroundColor:
                  _trayAlive ? Colors.green.shade100 : Colors.red.shade100,
            ),
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Expanded(flex: 3, child: _buildControlPanel(theme)),
            const SizedBox(width: 16),
            Expanded(flex: 2, child: _buildLogPanel(theme)),
          ],
        ),
      ),
    );
  }

  Widget _buildControlPanel(ThemeData theme) {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          _section(theme, 'Availability'),
          Row(
            children: [
              FilledButton.icon(
                onPressed: () async {
                  final ok = await desktopTray.checkAvailable();
                  setState(() => _available = ok);
                  _append('checkAvailable → $ok');
                },
                icon: const Icon(Icons.check_circle_outline),
                label: const Text('checkAvailable'),
              ),
              const SizedBox(width: 12),
              Text('available: $_available'),
            ],
          ),
          const SizedBox(height: 24),
          _section(theme, 'Icon'),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              for (final p in _icons)
                ChoiceChip(
                  label: Text(p.split('/').last),
                  selected: _currentIcon == p,
                  onSelected: (_) => _applyIcon(p),
                ),
            ],
          ),
          const SizedBox(height: 24),
          _section(theme, 'Tooltip'),
          Row(
            children: [
              Expanded(
                child: TextField(
                  controller: _tooltipCtrl,
                  decoration: const InputDecoration(
                    labelText: 'Tooltip text',
                    border: OutlineInputBorder(),
                    isDense: true,
                  ),
                ),
              ),
              const SizedBox(width: 8),
              FilledButton(
                onPressed: _applyTooltip,
                child: const Text('Apply'),
              ),
            ],
          ),
          const SizedBox(height: 24),
          _section(theme, 'Menu state (checkbox demo)'),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text('Muted'),
            value: _muted,
            onChanged: (v) {
              setState(() => _muted = v);
              _rebuildMenu();
            },
          ),
          SwitchListTile(
            contentPadding: EdgeInsets.zero,
            title: const Text('Notifications enabled'),
            value: _notifications,
            onChanged: (v) {
              setState(() => _notifications = v);
              _rebuildMenu();
            },
          ),
          const SizedBox(height: 24),
          _section(theme, 'Actions'),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              OutlinedButton.icon(
                onPressed: _rebuildMenu,
                icon: const Icon(Icons.refresh),
                label: const Text('setContextMenu'),
              ),
              OutlinedButton.icon(
                onPressed: _popUp,
                icon: const Icon(Icons.open_in_new),
                label: const Text('popUpContextMenu'),
              ),
              OutlinedButton.icon(
                onPressed: _trayAlive ? _destroyTray : null,
                icon: const Icon(Icons.delete_outline),
                label: const Text('destroy'),
              ),
              FilledButton.tonalIcon(
                onPressed: _trayAlive ? null : _recreateTray,
                icon: const Icon(Icons.play_arrow),
                label: const Text('recreate'),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildLogPanel(ThemeData theme) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            Row(
              children: [
                Text('Event log', style: theme.textTheme.titleMedium),
                const Spacer(),
                IconButton(
                  tooltip: 'Clear',
                  onPressed: () => setState(_log.clear),
                  icon: const Icon(Icons.clear_all),
                ),
              ],
            ),
            const Divider(),
            Expanded(
              child: ListView.builder(
                controller: _logCtrl,
                itemCount: _log.length,
                itemBuilder: (_, i) {
                  final e = _log[i];
                  return Padding(
                    padding: const EdgeInsets.symmetric(vertical: 2),
                    child: Text(
                      '[${_fmt(e.time)}] ${e.message}',
                      style: const TextStyle(
                        fontFamily: 'monospace',
                        fontSize: 12,
                      ),
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _section(ThemeData theme, String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Text(title, style: theme.textTheme.titleMedium),
    );
  }

  static String _fmt(DateTime t) =>
      '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}';
}

class _LogEntry {
  _LogEntry(this.time, this.message);
  final DateTime time;
  final String message;
}
1
likes
150
points
322
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin for managing system tray icons and menus on desktop platforms (Windows, macOS, Linux).

Homepage
Repository (GitHub)
View/report issues

Topics

#tray

License

Apache-2.0 (license)

Dependencies

flutter, path

More

Packages that depend on desktop_tray

Packages that implement desktop_tray