# Magic Device documentation > Magic Device tells a Flutter app whether the device is on mains power, whether the viewer is away and whether the macOS window is fullscreen. One import. # Installation Add `magic_device` to a Flutter app, check that it answers, and know what it does not need. - [Install](#install) - [Requirements](#requirements) - [What You Do Not Need](#what-you-do-not-need) - [Verify](#verify) - [Platforms](#platforms) - [Related Documentation](#related-documentation) ## Install ```bash flutter pub add magic_device ``` ```dart import 'package:magic_device/magic_device.dart'; ``` The barrel is the only import; everything under `src/` is private. `BatteryState` is re-exported from it, so a custom `batteryState` reader names the type without adding `battery_plus` to the app's own dependencies. ## Requirements | Requirement | Value | |:------------|:------| | Dart | `>=3.6.0` | | Flutter | `>=3.27.0` | | Dependencies | `battery_plus` and `flutter_inactive_timer`, pulled in automatically; `WindowFullscreen` uses the Flutter SDK only | | Flutter binding | Outside macOS and Windows, `DevicePower.start()` attaches a lifecycle observer, so call it from the main isolate once Flutter's binding exists | ## What You Do Not Need - No service provider, config file, facade or CLI command, and no dependency on `magic`. Construct the classes where you need them: a controller, a provider's `boot()` or a background job. - No `plugin:install` step. - No Runner edit. The package's macOS plugin registers the `magic_device/window` channel itself, so the host's `MainFlutterWindow.swift` stays Flutter's stock one. ## Verify ```dart import 'package:flutter/widgets.dart'; import 'package:magic_device/magic_device.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final DevicePower power = DevicePower()..start(); debugPrint('on mains: ${await power.charging}, away: ${power.idle}'); power.stop(); debugPrint('fullscreen available: ${WindowFullscreen.instance.available}'); } ``` On a laptop on battery this prints `on mains: false`. `fullscreen available` is `true` on macOS and `false` everywhere else. ## Platforms | Signal | Source | |:-------|:-------| | `DevicePower.charging` | `battery_plus`. A platform with no implementation answers `false`, and a web browser without `navigator.getBattery` reports `unknown`, which also reads as `false`. | | `DevicePower.idle` on native macOS and Windows | The system-wide last-input clock. | | `DevicePower.idle` everywhere else, web included | The app lifecycle: idle whenever the app is not in front. | | `WindowFullscreen` | macOS only. `available` is `false` elsewhere and `toggle()` sends nothing. | ## Related Documentation - [DevicePower](https://anilcancakir.com/magic_device/docs/basics/device-power.md): mains power and viewer idle. - [WindowFullscreen](https://anilcancakir.com/magic_device/docs/basics/window-fullscreen.md): the macOS window state and its toggle. - [Testing](https://anilcancakir.com/magic_device/docs/guides/testing.md): injecting every platform read. --- # DevicePower Know whether the device is on mains power and whether the viewer is away, so a heavy background job runs only when nobody will notice. - [Basic Usage](#basic-usage) - [Quick Reference](#quick-reference) - [Charging](#charging) - [Idle](#idle) - [Televisions](#televisions) - [Lifecycle](#lifecycle) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Basic Usage ```dart import 'package:magic_device/magic_device.dart'; final DevicePower power = DevicePower(television: () => isTelevision); power.start(); if (await power.charging && power.idle) { // Safe to run the heavy job. } power.stop(); ``` Nothing is collected until `start()`: an instance that never starts holds no channel, no timer and no lifecycle observer. ## Quick Reference | Member | Type | Meaning | |:-------|:-----|:--------| | `DevicePower({batteryState, idleDuration, platform, television})` | constructor | Every platform read is injectable. Omitted readers use `battery_plus`, the OS idle clock and `defaultTargetPlatform`; `television` defaults to `() => false`. | | `DevicePower.instance` | `static final DevicePower` | Shared instance built with every default, so it can never answer for a television. | | `charging` | `Future` | On mains power. Asks the platform on every read. | | `idle` | `bool` | Away. On native macOS and Windows: no input for `idleThreshold`. Everywhere else: any app lifecycle state but `resumed`, immediately, with no threshold. A cached flag: read it without awaiting. | | `start()` | `void` | Begins collecting. Idempotent. | | `stop()` | `void` | Stops collecting and resets `idle` to `false`. | | `idleThreshold` | `static const Duration` | 2 minutes. | | `pollInterval` | `static const Duration` | 30 seconds, the desktop poll. | ## Charging `await power.charging` answers whether the device draws from the mains. | Battery state | Counts as mains | |:--------------|:----------------| | `full`, `charging`, `connectedNotCharging` | Yes. A Mac without a battery reports `connectedNotCharging`. | | `discharging`, `unknown` | No. A batteryless Android reports `unknown`. | | A platform with no `battery_plus` implementation | No. The read answers `false` instead of throwing. | | A web browser without `navigator.getBattery` | No. `battery_plus` reports `unknown`. | Only a missing plugin is turned into `false`. Any other error from `battery_plus` propagates from `charging`, so catch it where you await. ## Idle `power.idle` is `true` while the viewer is away. How that is measured depends on the platform, and only native macOS and Windows have a threshold. | Platform | Source | |:---------|:-------| | Native macOS and Windows | The system-wide last-input clock: idle after `idleThreshold` (2 minutes) without input, read once at `start()` and then every `pollInterval` (30 seconds). Neither OS notifies "the user came back", so a poll is the only shape. | | Every other platform, web included | The app lifecycle: idle whenever the state is not `resumed` (`inactive`, `hidden` and `paused` all count), with no threshold. There is no system-wide input clock to read, so on a phone idle means "the app is not in front", never "no touches for two minutes". | The desktop poll is not guarded: if the OS idle read throws, the error surfaces as an uncaught async error. ## Televisions A batteryless Android TV reports `unknown`, which would read as not charging forever and keep the gate closed on a box that never runs on anything else. Pass a `television` predicate and `charging` answers `true` without asking the platform. ```dart import 'package:magic_device/magic_device.dart'; // isTelevision is your own detection, for example a platform channel or a build flavour. final DevicePower power = DevicePower(television: () => isTelevision); ``` The package cannot detect a television, so `isTelevision` is the host's own signal. `DevicePower.instance` takes no arguments and therefore cannot answer for a television: build and hold your own instance on those builds. ## Lifecycle - `start()` is idempotent, and `stop()` resets `idle` to `false` so a stopped instance claims no stale signal. - Let one component own `start()` and `stop()` of a shared instance. `stop()` has no reference count, so one consumer stopping `DevicePower.instance` closes every other consumer's idle gate. - Outside macOS and Windows, `start()` attaches a lifecycle observer and needs Flutter's binding: call it from the main isolate, not from a background isolate. ## Gotchas | Mistake | Fix | |:--------|:----| | Reading `idle` before `start()` | It stays `false` until `start()`. | | Awaiting `idle` or caching `charging` | `charging` is a `Future` that asks the platform each read; `idle` is a cached `bool`. | | Reaching into `battery_plus` yourself to fake the read | Pass `batteryState:` to the constructor. | | Expecting a grace period on a phone or on the web | `idle` flips as soon as the app leaves `resumed`: a control-centre pull or a window blur (`inactive`) counts. | | Treating `BatteryState.unknown` as mains | Pass `television` instead of widening the rule. | | Using `DevicePower.instance` on a television build | Build your own `DevicePower(television: ...)`. | | Calling `stop()` from a second consumer | Keep one owner for `start()` and `stop()`. | ## Related Documentation - [Installation](https://anilcancakir.com/magic_device/docs/getting-started/installation.md): requirements and platform support. - [WindowFullscreen](https://anilcancakir.com/magic_device/docs/basics/window-fullscreen.md): the macOS window state. - [Testing](https://anilcancakir.com/magic_device/docs/guides/testing.md): driving the readers and the lifecycle in tests. --- # WindowFullscreen Know whether the macOS window is fullscreen, follow it as it changes, and flip it with one call. - [Basic Usage](#basic-usage) - [Quick Reference](#quick-reference) - [Reading the State](#reading-the-state) - [Toggling](#toggling) - [Following the Window](#following-the-window) - [Gotchas](#gotchas) - [Related Documentation](#related-documentation) ## Basic Usage A fullscreen button that follows the window. `ListenableBuilder` adds and removes the listener itself, and the handler catches the one refusal a user can cause. ```dart import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; // PlatformException import 'package:magic_device/magic_device.dart'; Widget fullscreenButton() { if (!WindowFullscreen.instance.available) return const SizedBox.shrink(); return ListenableBuilder( listenable: WindowFullscreen.instance, builder: (BuildContext context, Widget? child) => IconButton( icon: Icon( WindowFullscreen.instance.active ? Icons.fullscreen_exit : Icons.fullscreen, ), onPressed: () async { try { await WindowFullscreen.instance.toggle(); } on PlatformException catch (error) { if (error.code != 'no_window') rethrow; } }, ), ); } ``` ## Quick Reference | Member | Type | Meaning | |:-------|:-----|:--------| | `WindowFullscreen.instance` | `static WindowFullscreen` | The app's one reader, made on first use. On macOS the first use reads the window once, so a window that opens already fullscreen is picked up. | | `available` | `bool` | `true` on macOS only (never on web). Gate the control on it. | | `active` | `bool` | Fullscreen, as the window last confirmed (AppKit's did-notification). | | `target` | `bool` | Fullscreen or on its way there. Moves on the will-notification, ahead of `active`; the did-notification settles both. | | `transitioning` | `bool` | Between the will- and the did-notification. | | `toggle()` | `Future` | Asks the window to enter or leave fullscreen. | | `pressTimeout` | `static const Duration` | 1 second. | | `resetInstance()` | `static void` | Test-only: drops the shared reader. | ## Reading the State One state, two readings. `target` is where the window is going, and `active` is what it confirmed. `transitioning` covers the gap. | Moment | `active` | `target` | `transitioning` | |:-------|:---------|:---------|:----------------| | Windowed | `false` | `false` | `false` | | Entering, animation under way | `false` | `true` | `true` | | Fullscreen | `true` | `true` | `false` | | Leaving, animation under way | `true` | `false` | `true` | Follow `target` to animate a layout with the window, and `active` for what the window really is. During `transitioning` the window's size changes on every frame, so hold off size-sensitive work. ## Toggling `toggle()` is a request, not a guarantee. - It is ignored while a press or a transition is under way, so a double press cannot undo itself. - It throws a `PlatformException` with code `no_window` when the Flutter view is not in a window. That refusal releases the press, so catch it where a user can trigger it. - AppKit silently ignores the call for an app that is not frontmost and posts nothing. One second (`pressTimeout`) after the call returns, the window is read again and that reading ends the press. Treat a missing change as normal, not as an error. - Anywhere but macOS, `toggle()` sends nothing. ## Following the Window The state follows the window, not the calls. The plugin forwards the will- and did-notifications for every change, so the green button, Esc and the Window menu notify listeners exactly as `toggle()` does, and listeners hear only real changes. ## Gotchas | Mistake | Fix | |:--------|:----| | Building a second `WindowFullscreen` | There is no public constructor: the channel takes one handler. Use `instance`. | | Disposing the shared instance from a view | A view adds and removes a listener and never disposes it, or every other reader is left on a disposed notifier. | | Showing a fullscreen control everywhere | Gate it on `available`. | | Animating the layout off `active` | `active` changes only once the window lands. Follow `target`. | | Reaching `instance` in a macOS test without the channel mock | The first use reads the window over the channel. Mock `magic_device/window` and call `resetInstance()` afterwards; see [Testing](https://anilcancakir.com/magic_device/docs/guides/testing.md). | | Editing `MainFlutterWindow.swift` to add a channel | Not needed: the package's macOS plugin registers `magic_device/window` and finds the window on each call. | ## Related Documentation - [Installation](https://anilcancakir.com/magic_device/docs/getting-started/installation.md): requirements and platform support. - [DevicePower](https://anilcancakir.com/magic_device/docs/basics/device-power.md): mains power and viewer idle. - [Testing](https://anilcancakir.com/magic_device/docs/guides/testing.md): a fake window on the channel. --- # Testing Test code that depends on `DevicePower` and `WindowFullscreen` without a device, a plugin or a mocking library. - [DevicePower](#devicepower) - [Lifecycle](#lifecycle) - [WindowFullscreen](#windowfullscreen) - [Related Documentation](#related-documentation) ## DevicePower Every platform read is a constructor parameter, so a test needs no plugin. `idleDuration` returns milliseconds. ```dart import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:magic_device/magic_device.dart'; void main() { test('a discharging device with an away viewer', () async { final DevicePower power = DevicePower( batteryState: () async => BatteryState.discharging, idleDuration: () async => const Duration(minutes: 5).inMilliseconds, platform: TargetPlatform.macOS, ); power.start(); await power.refreshIdle(); // @visibleForTesting: drive the poll without waiting expect(await power.charging, isFalse); expect(power.idle, isTrue); power.stop(); }); } ``` `BatteryState` comes from `package:magic_device/magic_device.dart`, so the test does not need `battery_plus` as a direct dependency. ## Lifecycle The app lifecycle is the one read that cannot be injected. On every platform but macOS and Windows, drive it through the test binding: ```dart import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:magic_device/magic_device.dart'; void main() { final TestWidgetsFlutterBinding binding = TestWidgetsFlutterBinding.ensureInitialized(); test('idle follows the app lifecycle', () { final DevicePower power = DevicePower(platform: TargetPlatform.android) ..start(); binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); expect(power.idle, isTrue); binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); expect(power.idle, isFalse); power.stop(); }); } ``` ## WindowFullscreen On macOS the first use of `WindowFullscreen.instance` asks the window for its state over the `magic_device/window` channel, so any test that reaches it under a macOS override needs a fake window on that channel. Set the override and the fake in `setUp`, and undo both in `tearDown` in this order: ```dart import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:magic_device/magic_device.dart'; const MethodChannel channel = MethodChannel('magic_device/window'); void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUp(() { debugDefaultTargetPlatformOverride = TargetPlatform.macOS; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { if (call.method == 'isFullScreen') return false; return null; // toggleFullScreen }); }); tearDown(() { WindowFullscreen.resetInstance(); TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, null); debugDefaultTargetPlatformOverride = null; }); test('the will-notification moves target ahead of active', () async { final WindowFullscreen window = WindowFullscreen.instance; await Future.delayed(Duration.zero); // let the first read settle // Deliver the notification the plugin would post. await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, const StandardMethodCodec().encodeMethodCall( const MethodCall('willEnterFullScreen'), ), (ByteData? _) {}, ); expect( (window.active, window.target, window.transitioning), (false, true, true), ); }); } ``` Reset the instance first, then clear the mock handler, then the override. Resetting the override first leaves the channel handler installed, because the reader only clears its handler while `available` is true. That `tearDown` order is for plain `test()`. Inside `testWidgets` the binding checks that every foundation debug variable is unset at the end of the test body, before `tearDown` runs, so with the override set in `setUp` end the body with `WindowFullscreen.resetInstance();` and `debugDefaultTargetPlatformOverride = null;`. If you use `variant: TargetPlatformVariant.only(TargetPlatform.macOS)` instead, leave the override out of `setUp` (the variant sets it and clears it before `tearDown`) and still end the body with `WindowFullscreen.resetInstance();`, so the reader drops its channel handler while the platform is macOS. The four notifications the plugin posts are `willEnterFullScreen`, `didEnterFullScreen`, `willExitFullScreen` and `didExitFullScreen`. ## Related Documentation - [DevicePower](https://anilcancakir.com/magic_device/docs/basics/device-power.md): the signals the readers replace. - [WindowFullscreen](https://anilcancakir.com/magic_device/docs/basics/window-fullscreen.md): the state the notifications drive.