secondary_screen 2.1.1 copy "secondary_screen: ^2.1.1" to clipboard
secondary_screen: ^2.1.1 copied to clipboard

PlatformAndroid

A Flutter package for managing Android dual/secondary screen displays with a state-manager-agnostic service API.

secondary_screen #

pub package pub points platform license: MIT

Drive a dual / secondary screen from Flutter with a state-manager-agnostic service API — show named routes on the second display, push live data to it, and auto-reconnect when a display is plugged or unplugged. Ideal for Point of Sale (POS) setups where the customer sees a live order summary or promotions.

POS demo

The cashier's primary screen (left) drives the customer-facing secondary display (right) in real time.

Platform: Android only. Built on the Android Presentation API via presentation_displays.

🚀 Features #

  • Route control — show any named route on the secondary display.
  • Live data transfer — push structured, event-based payloads (TransferDataModel) to the second screen in real time.
  • Receive widgetSecondaryDisplay delivers incoming data to your secondary UI.
  • Auto-reconnect — restores the last route when a display is plugged/unplugged.
  • One wrapper setupSecondaryScreenScope initializes the service and exposes a controller from context.
  • Convenience controller — call show, send, showEvent, sendEvent, hide, and reconnect without manual JSON encoding.
  • State-manager agnostic — use the singleton SecondaryScreenService, its ValueListenable, or bridge the service into Provider, Riverpod, BLoC, GetX, or your own state layer.
  • UI listener widgetsSecondaryScreenBuilder and SecondaryScreenListener rebuild or react to service state without requiring BLoC.
  • Low-level accessDisplayManager to enumerate displays directly when you need it.

📦 Installation #

Add the package to your pubspec.yaml:

dependencies:
  secondary_screen: ^2.1.0

Then import it:

import 'package:secondary_screen/secondary_screen.dart';

⚙️ Setup (Android) #

A secondary display runs in its own Flutter entry point and engine. No extra <activity> registration is required — the default flutterEmbedding v2 meta-data Flutter generates is enough.

1. Declare the secondary entry point in your main.dart. It must be annotated with @pragma('vm:entry-point') so it survives tree-shaking:

@pragma('vm:entry-point')
void secondaryDisplayMain() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MySecondApp());
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      onGenerateRoute: generateRoute, // same route table as the primary app
      initialRoute: 'presentation',
    );
  }
}

2. Share one onGenerateRoute between both entry points so route names line up:

Route<dynamic> generateRoute(RouteSettings settings) {
  switch (settings.name) {
    case '/':
    case 'sales':
      return MaterialPageRoute(builder: (_) => const SalesScreen());
    case 'presentation':
      return MaterialPageRoute(builder: (_) => const PromotionScreen());
    case 'order_display':
      return MaterialPageRoute(builder: (_) => const OrderDisplayScreen());
    default:
      return MaterialPageRoute(
        builder: (_) => const Scaffold(body: Center(child: Text('No route'))),
      );
  }
}

🛠️ Usage #

Wrap your primary appSecondaryScreenScope initializes the service, optionally shows your default secondary route, and exposes a controller to every descendant:

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

  @override
  Widget build(BuildContext context) {
    return const SecondaryScreenScope(
      autoShow: true,
      defaultRouteName: 'presentation',
      child: MaterialApp(
        onGenerateRoute: generateRoute,
        initialRoute: 'sales',
      ),
    );
  }
}

Use the controller anywhere below the wrapper — no jsonEncode needed:

final screen = SecondaryScreenScope.of(context);

await screen.showEvent(
  'order_display',
  eventName: 'update_order',
  data: {'items': items, 'total': 42000},
);

await screen.sendEvent(
  eventName: 'update_order',
  data: {'items': updatedItems, 'total': 50000},
);

For custom payload models, pass TransferDataModel or a JSON object Map directly:

final payload = TransferDataModel(
  eventName: 'update_order',
  data: {'items': items, 'total': 42000},
);

await screen.show('order_display', data: payload);
await screen.send(payload);

Read state from the same wrapper:

SecondaryScreenScope(
  builder: (context, screen, state, child) {
    final isConnected = state.status == SecondaryScreenServiceState.connected;
    return Column(
      children: [
        Text(isConnected ? 'Connected' : 'Disconnected'),
        Expanded(child: child!),
      ],
    );
  },
  child: const SalesScreen(),
)

Use onError or onStateChanged when the wrapper should handle side effects for you:

SecondaryScreenScope(
  onError: (context, error, state) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text(error)),
    );
  },
  child: const MyPrimaryScreen(),
)

You can still use the lower-level singleton service when you do not want a widget wrapper:

await SecondaryScreenService.instance.init(
  autoShow: true,
  defaultRouterName: 'presentation',
);
await SecondaryScreenService.instance.showOnSecondary(
  'order_display',
  json: jsonEncode({
    'event_name': 'update_order',
    'data': {'items': items, 'total': 42000},
  }),
);

Listen from Flutter UI — use SecondaryScreenBuilder for a focused rebuild when you do not need the full scope wrapper:

SecondaryScreenBuilder(
  builder: (context, state, child) {
    final isConnected = state.status == SecondaryScreenServiceState.connected;
    return Text(isConnected ? 'Connected' : 'Disconnected');
  },
)

Use SecondaryScreenListener for focused side effects:

SecondaryScreenListener(
  listenWhen: (previous, current) => previous.error != current.error,
  listener: (context, state) {
    if (state.error != null) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.error!)),
      );
    }
  },
  child: const MyPrimaryScreen(),
)

Receive data on the secondary screen — wrap the route's UI in SecondaryDisplay and handle payloads in its callback:

SecondaryDisplay(
  callback: (args) {
    if (args is! Map) return;
    final map = Map<String, dynamic>.from(args);
    if (map['event_name'] == 'update_order') {
      final data = Map<String, dynamic>.from(map['data']);
      // ...update your UI from data...
    }
  },
  child: yourCustomerFacingUi,
)

Hide & reconnect:

final screen = SecondaryScreenScope.of(context);
await screen.hide(clearData: true);
await screen.reconnect(); // restores the last route

show only re-navigates when the screen isn't already showing or the route changes — otherwise it just transfers data, so it's safe to call repeatedly.

💡 For a full, runnable Point-of-Sale demo, see the example/ directory: SalesScreen runs on the primary display, while PromotionScreen and OrderDisplayScreen run on the secondary display.

📖 API reference #

SecondaryScreenScope #

One-widget primary-side setup. It can auto-initialize the secondary display, auto-show a default route, provide state to a builder, and expose SecondaryScreenController through SecondaryScreenScope.of(context).

Option Type Description
autoInit bool Calls init automatically when the widget mounts
autoShow bool Shows defaultRouteName after a display is detected
defaultRouteName String Initial secondary route, defaults to presentation
builder SecondaryScreenScopeBuilder? Rebuild from controller + current state
onStateChanged SecondaryScreenStateListener? Side effect for state changes
onError SecondaryScreenErrorListener? Side effect for new error messages
listenWhen SecondaryScreenListenWhen? Filters onStateChanged calls

SecondaryScreenController #

High-level controller exposed by SecondaryScreenScope.of(context) and SecondaryScreenController.instance.

Method Returns Description
init({autoShow, defaultRouteName}) Future<void> Detect displays and optionally show a default route
show(routeName, {data}) Future<bool> Navigate and optionally send String, Map, or TransferDataModel
send(data) Future<bool> Send String, Map, or TransferDataModel without route changes
showEvent(routeName, {eventName, data}) Future<bool> Build and send TransferDataModel while navigating
sendEvent({eventName, data}) Future<bool> Build and send TransferDataModel without navigation
hide({clearData = false}) Future<bool> Hide the secondary display
reconnect() Future<bool> Restore the last route

SecondaryScreenService #

A lower-level singleton service that implements ValueListenable<SecondaryScreenState> and exposes a broadcast stateChanges stream. Use it directly when you want to bridge into another state-management library.

Method Returns Description
init({autoShow = true, defaultRouterName}) Future<void> Detect displays, connect, optionally show defaultRouterName
showOnSecondary(routeName, {json}) Future<bool> Navigate to a route (deduped) and optionally send a JSON payload
updateDataOnSecondary(data) Future<bool> Push a new JSON payload without changing the route
hideOnSecondary({clearData = false}) Future<bool> Hide the secondary display; keeps the last route for reconnecting
reConnectCurrentRoute() Future<bool> Re-show the last active route after a reconnect
State access Type Description
state SecondaryScreenState Current immutable state snapshot
listenable ValueListenable<SecondaryScreenState> Rebuild Flutter UI with ValueListenableBuilder
stateChanges Stream<SecondaryScreenState> Bridge state updates into another state layer

SecondaryScreenBuilder #

SecondaryScreenBuilder({service, builder, child}) — rebuilds when SecondaryScreenService emits a new state. The service argument is optional and defaults to SecondaryScreenService.instance.

SecondaryScreenListener #

SecondaryScreenListener({service, listenWhen, listener, child}) — runs side effects when service state changes. Use listenWhen to filter transitions, such as only reacting when error changes.

SecondaryScreenState #

Field Type Description
status SecondaryScreenServiceState initial, connected, disconnected
currentSecondaryDisplay Display? The active secondary display
availableDisplays List<Display>? All detected displays
currentRoute String? Currently shown route name
currentData String? Last transferred JSON payload
isShowing bool Whether a route is currently shown
isLoading bool Whether an operation is in progress
error String? Last error message, if any

Getter: defaultSecondaryDisplayIdcurrentSecondaryDisplay?.displayId.

TransferDataModel #

TransferDataModel(eventName, data) — serializes to/from JSON with snake_case keys (event_name, data) via toJson() / fromJson().

SecondaryDisplay #

SecondaryDisplay({callback, child}) — widget placed on the secondary screen; callback(dynamic args) receives each decoded payload.

DisplayManager #

Low-level platform access: getDisplays(), getNameByDisplayId(), getNameByIndex(), showSecondaryDisplay(), hideSecondaryDisplay(), transferDataToPresentation(), and the connectedDisplaysChangedStream (Stream<int?> of the connected-display count).

🤝 Contributions & Issues #

Contributions are welcome!

📄 License #

Released under the MIT License. See LICENSE for details.

2
likes
145
points
269
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter package for managing Android dual/secondary screen displays with a state-manager-agnostic service API.

Repository (GitHub)
View/report issues

Topics

#dual-screen #multi-screen #android #display #presentation

License

MIT (license)

Dependencies

flutter

More

Packages that depend on secondary_screen

Packages that implement secondary_screen