device_lab 0.1.1
device_lab: ^0.1.1 copied to clipboard
Preview your Flutter app on any device without a simulator: 149 devices, foldables with real display features, free-form resolutions, and golden-test integration.
device_lab #
Preview a Flutter app on any device without booting a simulator.
device_lab wraps your app, overrides MediaQuery, and renders it inside a
device frame with a tools panel for size, orientation, fold posture, locale,
text scale and accessibility flags.
149 built-in devices across 16 vendors — iPhone (including iPhone Duo, the foldable), Pixel, Galaxy S/A, book-folds, flip phones, iPads, Android tablets, laptops, watches, TV, plus generic resolution presets from 320×568 to 4K. And any size at all via free-form mode.
void main() {
runApp(
DeviceLab(
enabled: isDeviceLabAvailable,
initialDeviceId: 'apple.iphone-17-pro',
builder: (_) => const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) => MaterialApp(
builder: DeviceLab.appBuilder,
locale: DeviceLab.localeOf(context),
home: const HomePage(),
);
}
Two lines matter: DeviceLab at the root, and builder: DeviceLab.appBuilder
inside your MaterialApp so the simulated MediaQuery lands below the app
and reaches every route.
Why not device_preview #
| device_preview | device_lab | |
|---|---|---|
Display features (fold, hinge, cutout) |
not simulated | injected into MediaQuery |
| Foldables | frame art only | per-posture screens, cover + main |
| New hardware | requires a package release | DeviceCatalog.loadJson at runtime |
| Arbitrary resolutions | fixed list | any width × height |
TargetPlatform |
manual | follows the selected device |
| Golden tests | separate setup | same specs via pumpOnDevice |
Foldables and flip phones #
Book-folds (Galaxy Z Fold, Pixel Pro Fold, iPhone Duo, OnePlus Open) fold on a
vertical hinge; flip phones (Z Flip, Motorola Razr) fold on a horizontal one.
Both are modelled the same way — two screens plus a HingeSpec — and the hinge
axis rotates with the device when you flip orientation.
The iPhone Duo's inner display is landscape-natural (its open body is
164.6mm wide by 117.8mm tall), so selecting it opens it in landscape with the
Dynamic Island along the top edge. naturalOrientation on DeviceScreen
carries that, and safeArea / safeAreaRotated are relative to it rather than
assuming portrait.
Postures #
Selecting a foldable exposes cover / half-open / unfolded postures. Each
posture resolves to a different screen and a different DisplayFeature, so
layout code written against the real API just works:
final fold = MediaQuery.of(context).separatingFold;
return fold == null
? const SinglePane()
: const Row(children: [Expanded(child: List()), Expanded(child: Detail())]);
FoldQuery adds hinges, cutouts, separatingFold, hasFold,
isBookPosture and isTabletopPosture to MediaQueryData.
Adding a device #
The catalog is data, not code. Ship a JSON file and register it at startup:
{"devices": [{
"id": "acme.phone-x", "name": "Acme Phone X",
"platform": "android", "category": "phone",
"screens": [{
"label": "Main",
"logicalSize": {"w": 420, "h": 960},
"pixelRatio": 3.0,
"safeArea": {"t": 54, "b": 24},
"cutouts": [{"shape": "punchHole", "size": {"w": 26, "h": 26}, "dy": 14}]
}]
}]}
DeviceCatalog.loadJson(await rootBundle.loadString('assets/devices.json'));
Or register in Dart with DeviceCatalog.register(DeviceSpec(...)). Query with
DeviceCatalog.query(category: DeviceCategory.foldable, releasedAfter: 2024).
Golden tests #
import 'package:device_lab/device_lab_testing.dart';
testWidgets('home renders on current phones', (tester) async {
for (final device in DeviceCatalog.query(
category: DeviceCategory.phone,
releasedAfter: 2024,
)) {
await tester.pumpOnDevice(const MyApp(), device);
await expectLater(
find.byType(MyApp),
matchesGoldenFile(goldenNameFor(device, scenario: 'home')),
);
}
});
Device specs #
Apple entries are derived from Apple's published technical specifications
(pixel resolution ÷ scale factor). Android entries are best-known logical sizes
and are approximations, as are all safe-area insets — good enough for layout
work, and correctable without touching package code. If a value is wrong for
hardware you own, open an issue with the output of MediaQuery.of(context)
from that device and it becomes a one-line data fix.
Hiding the lab #
Three levels, smallest to largest:
| Control | Effect |
|---|---|
| Hide tools panel | device frame stays, panel collapses to a button |
| Show original screen | app fills the window, no frame, no MediaQuery override |
enabled: false |
lab is inert, zero overhead |
"Show original screen" is the runtime toggle. Reach it from the button in the tools panel, with Ctrl/Cmd + Shift + D, or from your own code:
DeviceLab.togglePreview(context);
DeviceLab.setPreviewing(context, false);
DeviceLab.isPreviewing(context);
While hidden, a Device Lab pill floats over the app to bring it back. It docks to a corner like the Flutter Inspector button: drag it and it snaps to whichever corner you release it nearest, so it never sits permanently on top of your FAB or nav bar.
DeviceLab(
restoreButtonAlignment: Alignment.bottomLeft,
showRestoreButton: true,
builder: (_) => const MyApp(),
)
The corner lives on the controller (restoreAlignment /
setRestoreAlignment), so a corner you drag it to survives hiding and showing
the lab again. showRestoreButton: false suppresses the pill entirely if you
would rather drive the toggle from your own debug menu.
Your app keeps its state across the toggle: the widget subtree is held behind a
GlobalKey and reparented rather than rebuilt, so scroll positions, form input
and navigation stack all survive.
controller.active is enabled && previewing — that is what appBuilder
checks, so when hidden your app sees the real window MediaQuery and its real
TargetPlatform.
Disabling in release #
enabled: isDeviceLabAvailable is !kReleaseMode. When disabled, DeviceLab
returns your app untouched and appBuilder is a pass-through, so there is no
release-mode overhead.