zebra_emdk_plugin 0.4.0
zebra_emdk_plugin: ^0.4.0 copied to clipboard
A Flutter plugin for Zebra Android devices using the EMDK SDK.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:zebra_emdk_plugin/zebra_emdk_plugin.dart';
import 'package:zebra_emdk_plugin_example/pairing_code_dialog.dart';
void main() => runApp(const MyApp());
const _accent = Color(0xFF00C8FF);
const _danger = Color(0xFFFF4D4D);
const _ok = Color(0xFF3DDC84);
const _surface = Color(0xFF1E2128);
const _card = Color(0xFF252930);
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'EMDK Test Harness',
debugShowCheckedModeBanner: false,
theme: ThemeData(
brightness: Brightness.dark,
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: _accent, brightness: Brightness.dark),
scaffoldBackgroundColor: _surface,
cardTheme: const CardThemeData(color: _card),
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final EmdkManager _emdk = EmdkManager();
final List<StreamSubscription<dynamic>> _subs = [];
bool _supported = false;
bool _ready = false;
String _status = 'Starting…';
// Scanners
List<ScannerInfo> _scanners = [];
String? _activeScanner;
ScannerStates? _scannerState;
bool _readEnabled = false;
final List<ScanData> _scans = [];
// Notification devices
List<NotificationDeviceInfo> _notifDevices = [];
String? _activeNotif;
// Device / profile feedback
Map<String, String?> _deviceInfo = {};
String? _lastProfile;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
try {
_supported = await _emdk.isSupported();
} catch (_) {
_supported = false;
}
if (!_supported) {
setState(() => _status = 'Not a Zebra device (isSupported = false)');
return;
}
_subs.add(_emdk.onOpened.listen((_) => _onOpened()));
_subs.add(_emdk.onClosed.listen((_) {
if (mounted) {
setState(() {
_ready = false;
_status = 'EMDK closed';
});
}
}));
setState(() => _status = 'Initializing EMDK…');
await _emdk.initialize();
}
void _onOpened() {
if (!mounted) return;
setState(() {
_ready = true;
_status = 'EMDK ready';
});
_subs.add(_emdk.barcodeManager.onData.listen(_onScan));
_subs.add(_emdk.barcodeManager.onStatus.listen((s) {
if (mounted) setState(() => _scannerState = s.state);
// EMDK arms ONE read per read() call; after each decode the scanner returns to IDLE.
// Re-arm while the user has reading enabled so the hardware trigger keeps working.
// (This is app/Dart policy — see docs/ZEBRA_EMDK_PLUGIN_CONTEXT.md §4.)
if (s.state == ScannerStates.idle && _readEnabled) {
_emdk.barcodeManager.enableRead().ignore();
}
}));
_subs.add(_emdk.barcodeManager.onConnectionChange.listen((_) => _refreshScanners()));
_subs.add(_emdk.profileManager.onData.listen((r) {
if (mounted) {
setState(() =>
_lastProfile = '${r.profileName ?? '?'}: ${r.result?.statusCode?.value ?? '?'}');
}
}));
_subs.add(_emdk.keyEventManager.onKeyDown.listen((k) => _snack('Key: ${k?.value ?? '?'}')));
_emdk.profileManager.getBluetoothMac();
_emdk.profileManager.getBuildSerial();
_emdk.profileManager.getProductModel();
_emdk.profileManager.getWifiMac();
_emdk.profileManager.getPeripheralBatteryInfo();
_refreshScanners();
_refreshNotifDevices();
}
void _onScan(ScanDataCollection c) {
final data = c.scanData;
if (data == null || data.isEmpty || !mounted) return;
setState(() => _scans.insertAll(0, data));
}
// ─── Scanners ───────────────────────────────────────────────────────────────
Future<void> _refreshScanners() async {
try {
// Only the scanners currently available to activate (connected). Supported-but-not-
// connected types can't be initialised, so they're not shown.
final list = await _emdk.barcodeManager.getConnectedScanners();
final active = await _emdk.barcodeManager.getScannerInfo();
if (!mounted) return;
setState(() {
_scanners = list;
_activeScanner = active?.friendlyName;
});
} catch (e) {
_snack('Refresh scanners failed: $e');
}
}
Future<void> _connectScanner(ScannerInfo s) async {
final name = s.friendlyName;
if (name == null) return;
try {
final ok = await _emdk.barcodeManager.initScanner(name);
if (ok) {
await _emdk.barcodeManager.enableRead();
if (mounted) setState(() => _readEnabled = true);
_snack('Connected: $name');
} else {
_snack('Could not connect $name');
}
} on ScannerException catch (e) {
_snack('Scanner error: ${e.result.value}');
} catch (e) {
_snack('$e');
}
await _refreshScanners();
}
Future<void> _disconnectScanner() async {
try {
await _emdk.barcodeManager.deinitScanner();
} catch (e) {
_snack('$e');
}
if (mounted) {
setState(() {
_readEnabled = false;
_scannerState = null;
});
}
await _refreshScanners();
}
Future<void> _toggleRead() async {
try {
if (_readEnabled) {
await _emdk.barcodeManager.disableRead();
} else {
await _emdk.barcodeManager.enableRead();
}
if (mounted) setState(() => _readEnabled = !_readEnabled);
} catch (e) {
_snack('$e');
}
}
Future<void> _showPairing() async {
try {
final mac = await _emdk.profileManager.getBluetoothMac();
if (mac == null || mac.isEmpty) {
_snack('No Bluetooth MAC yet (Access permission may still be pending).');
return;
}
final code = '{3}B${mac.replaceAll(":", "").toUpperCase()}';
if (!mounted) return;
showDialog(context: context, builder: (_) => PairingCodeDialog(code: code));
} catch (e) {
_snack('$e');
}
}
// ─── Notification devices ─────────────────────────────────────────────────────
Future<void> _refreshNotifDevices() async {
try {
final list = await _emdk.notificationManager.getSupportedDevices();
final active = await _emdk.notificationManager.getDeviceInfo();
if (!mounted) return;
setState(() {
_notifDevices = list;
_activeNotif = active?.friendlyName;
});
} catch (_) {/* feature may be unavailable; ignore */}
}
Future<void> _connectNotif(NotificationDeviceInfo d) async {
final name = d.friendlyName;
if (name == null) return;
try {
final ok = await _emdk.notificationManager.initDevice(name);
_snack(ok ? 'Notification device: $name' : 'Could not enable $name');
} on NotificationException catch (e) {
_snack('Notification error: ${e.result.value}');
} catch (e) {
_snack('$e');
}
await _refreshNotifDevices();
}
Future<void> _testNotify(NotificationCommand cmd, String label) async {
try {
await _emdk.notificationManager.notify(cmd);
_snack('Sent: $label');
} on NotificationException catch (e) {
_snack('Notification error: ${e.result.value}');
} catch (e) {
_snack('$e');
}
}
// ─── MX profiles / OEM info ────────────────────────────────────────────────────
Future<void> _batteryInfo() async {
try {
final info = await _emdk.profileManager.getPeripheralBatteryInfo();
_snack(info != null && info.isNotEmpty ? info : 'No battery data');
} catch (e) {
_snack('$e');
}
}
Future<void> _refreshDeviceInfo() async {
var result = {
'Bluetooth MAC': await _emdk.profileManager.getBluetoothMac(),
'Serial': await _emdk.profileManager.getBuildSerial(),
'Model': await _emdk.profileManager.getProductModel(),
'Wi-Fi MAC': await _emdk.profileManager.getWifiMac(),
};
if (mounted) setState(() => _deviceInfo = result);
}
Future<void> _confirm(String title, String message, VoidCallback onConfirm,
{bool danger = false}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: _card,
title: Text(title),
content: Text(message),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text('Confirm', style: TextStyle(color: danger ? _danger : _accent)),
),
],
),
);
if (ok == true) onConfirm();
}
void _snack(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(msg), duration: const Duration(seconds: 2)),
);
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_emdk.dispose().ignore();
super.dispose();
}
// ─── UI ───────────────────────────────────────────────────────────────────────
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
backgroundColor: _card,
title: const Text('EMDK Test Harness'),
bottom: const TabBar(
tabs: [Tab(text: 'Scanner'), Tab(text: 'Notify'), Tab(text: 'Device')],
),
),
body: Column(
children: [
_statusBar(),
Expanded(
child: TabBarView(children: [_scannerTab(), _notifyTab(), _deviceTab()]),
),
],
),
),
);
}
Widget _statusBar() {
final color = !_supported ? _danger : (_ready ? _ok : _accent);
return Container(
width: double.infinity,
color: _card,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
child: Row(
children: [
Icon(Icons.circle, size: 12, color: color),
const SizedBox(width: 8),
Expanded(child: Text(_status)),
if (_supported && !_ready) TextButton(onPressed: _init, child: const Text('Retry')),
],
),
);
}
Widget _sectionTitle(String text, {Widget? trailing}) => Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 8, 4),
child: Row(
children: [
Expanded(
child: Text(text,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13))),
?trailing,
],
),
);
// ── Scanner tab ──
Widget _scannerTab() {
if (!_ready) return const Center(child: Text('Waiting for EMDK…'));
return ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _showPairing,
icon: const Icon(Icons.qr_code_2),
label: const Text('Pair scanner'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _refreshScanners,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
),
],
),
),
_sectionTitle('AVAILABLE SCANNERS (${_scanners.length})'),
if (_scanners.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Text('No scanners available. Pair one, then Refresh.'),
)
else
..._scanners.map(_scannerTile),
if (_activeScanner != null) _activeScannerControls(),
_sectionTitle(
'SCANS (${_scans.length})',
trailing: TextButton(
onPressed: _scans.isEmpty ? null : () => setState(_scans.clear),
child: const Text('Clear'),
),
),
if (_scans.isEmpty)
const Padding(padding: EdgeInsets.all(16), child: Text('Trigger the scanner…'))
else
..._scans.take(50).map(_scanTile),
],
);
}
Widget _scannerTile(ScannerInfo s) {
final isActive = s.friendlyName == _activeScanner;
final isBt = s.connectionType == ScannerConnectionType.bluetoothSsi;
return Card(
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
shape: isActive
? RoundedRectangleBorder(
side: const BorderSide(color: _accent, width: 1.5),
borderRadius: BorderRadius.circular(12))
: null,
child: ListTile(
leading: Icon(isBt ? Icons.bluetooth : Icons.qr_code_scanner, color: _accent),
title: Text(s.friendlyName ?? '(unnamed)'),
subtitle: Text([
?s.modelNumber,
s.connectionType?.value ?? '?',
if (s.isDefaultScanner ?? false) 'DEFAULT',
].join(' • ')),
trailing: isActive
? const Chip(label: Text('ACTIVE'))
: const Text('tap to activate', style: TextStyle(fontSize: 11)),
onTap: isActive ? null : () => _connectScanner(s),
),
);
}
Widget _activeScannerControls() {
return Card(
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Active: $_activeScanner', style: const TextStyle(fontWeight: FontWeight.w600)),
Text('Status: ${_scannerState?.value ?? '—'}',
style: TextStyle(color: Colors.white.withAlpha(160), fontSize: 12)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: _toggleRead,
icon: Icon(_readEnabled ? Icons.pause : Icons.play_arrow),
label: Text(_readEnabled ? 'Disable read' : 'Enable read'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _disconnectScanner,
style: OutlinedButton.styleFrom(foregroundColor: _danger),
icon: const Icon(Icons.link_off),
label: const Text('Disconnect'),
),
),
],
),
],
),
),
);
}
Widget _scanTile(ScanData d) => ListTile(
dense: true,
leading: const Icon(Icons.barcode_reader, size: 20),
title: Text(d.data ?? '', maxLines: 2, overflow: TextOverflow.ellipsis),
subtitle: Text('${d.labelType?.value ?? '?'} • ${d.timeStamp ?? ''}',
style: const TextStyle(fontSize: 11)),
);
// ── Notify tab ──
Widget _notifyTab() {
if (!_ready) return const Center(child: Text('Waiting for EMDK…'));
final active = _activeNotif != null;
return ListView(
children: [
_sectionTitle('NOTIFICATION DEVICES (${_notifDevices.length})',
trailing:
TextButton(onPressed: _refreshNotifDevices, child: const Text('Refresh'))),
if (_notifDevices.isEmpty)
const Padding(padding: EdgeInsets.all(16), child: Text('No notification devices.'))
else
..._notifDevices.map((d) {
final isActive = d.friendlyName == _activeNotif;
return Card(
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: ListTile(
leading: Icon(Icons.notifications_active,
color: (d.isConnected ?? false) ? _accent : Colors.grey),
title: Text(d.friendlyName ?? '(unnamed)'),
subtitle: Text([
if (d.isLEDSupported ?? false) 'LED',
if (d.isBeepSupported ?? false) 'Beep',
if (d.isVibrateSupported ?? false) 'Vibrate',
].join(' • ')),
trailing:
isActive ? const Chip(label: Text('ACTIVE')) : const Icon(Icons.chevron_right),
onTap: () => _connectNotif(d),
),
);
}),
_sectionTitle('TEST'),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(
spacing: 8,
children: [
OutlinedButton.icon(
onPressed: active
? () => _testNotify(
const NotificationCommand(
beep: NotificationBeepParams(
pattern: [NotificationBeep(time: 120, frequency: 2500)]),
),
'beep')
: null,
icon: const Icon(Icons.volume_up),
label: const Text('Beep'),
),
OutlinedButton.icon(
onPressed: active
? () => _testNotify(
const NotificationCommand(
led: NotificationLEDParams(
color: 0xFF00FF00, onTime: 300, offTime: 100, repeatCount: 2),
),
'LED')
: null,
icon: const Icon(Icons.lightbulb),
label: const Text('LED'),
),
OutlinedButton.icon(
onPressed: active
? () => _testNotify(
const NotificationCommand(vibrate: NotificationVibrateParams(time: 400)),
'vibrate')
: null,
icon: const Icon(Icons.vibration),
label: const Text('Vibrate'),
),
],
),
),
if (!active)
const Padding(
padding: EdgeInsets.all(16),
child: Text('Select a notification device above to enable the tests.',
style: TextStyle(fontSize: 12)),
),
],
);
}
// ── Device tab (OEM info + MX device control) ──
Widget _deviceTab() {
if (!_ready) return const Center(child: Text('Waiting for EMDK…'));
final pm = _emdk.profileManager;
return ListView(
children: [
_sectionTitle('OEM DEVICE INFO',
trailing: TextButton(onPressed: _refreshDeviceInfo, child: const Text('Read'))),
if (_deviceInfo.isEmpty)
const Padding(
padding: EdgeInsets.all(16),
child: Text('Tap "Read" to query the Zebra OEM content provider.'),
)
else
..._deviceInfo.entries.map((e) => ListTile(
dense: true,
title: Text(e.key),
subtitle: Text(e.value ?? '(null)',
style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
)),
_sectionTitle('SCANNER ACTIONS (MX — acts on the active scanner)'),
_mxWrap([
_mx('Reset', Icons.restart_alt, pm.resetScanner),
_mx('Disconnect', Icons.link_off, pm.disconnectScanner),
_mx('Unpair', Icons.bluetooth_disabled, pm.unpairScanner),
_mx('Locate', Icons.lightbulb, pm.locateScanner),
OutlinedButton.icon(
onPressed: _batteryInfo,
icon: const Icon(Icons.battery_full),
label: const Text('Battery')),
]),
_sectionTitle('DEVICE ACTIONS (MX)'),
_mxWrap([
_mx('Clear paired', Icons.cleaning_services, pm.clearPairedBluetoothDevices),
_mx('BT on', Icons.bluetooth, () => pm.setBluetoothState(true)),
_mx('BT off', Icons.bluetooth_disabled, () => pm.setBluetoothState(false)),
_mx('NFC on', Icons.nfc, () => pm.setNfcState(true)),
_mx('NFC off', Icons.nfc, () => pm.setNfcState(false)),
_mx('Open Settings', Icons.settings,
() => pm.startActivity(actionName: 'android.settings.SETTINGS')),
_mx('Zebra KB', Icons.keyboard,
() => pm.enableEnterpriseKeyboard(currentLocale: 'pt_PT')),
]),
_sectionTitle('UI / DISPLAY (MX — individual options)'),
_mxWrap([
_mx('Status bar off', Icons.fullscreen, () => pm.setStatusBarState(false)),
_mx('Status bar on', Icons.fullscreen_exit, () => pm.setStatusBarState(true)),
_mx('Nav bar off', Icons.navigation, () => pm.setNavigationBarState(false)),
_mx('Pull-down off', Icons.swipe_down, () => pm.setNotificationPullDownState(false)),
_mx('Bright 100%', Icons.brightness_high, () => pm.setScreenBrightness(100)),
_mx('Rotate off', Icons.screen_lock_rotation, () => pm.setAutoRotateState(false)),
]),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(foregroundColor: Colors.orange),
onPressed: () => _confirm('Reboot device?', 'The device will reboot now.', () {
pm.reboot();
_snack('Sent: reboot');
}),
icon: const Icon(Icons.power_settings_new),
label: const Text('Reboot'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(foregroundColor: _danger),
onPressed: () => _confirm('Full device wipe?', 'ALL data will be erased.', () {
pm.fullDeviceWipe();
_snack('Sent: wipe');
}, danger: true),
icon: const Icon(Icons.delete_forever),
label: const Text('Wipe'),
),
),
],
),
),
if (_lastProfile != null)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Last MX result: $_lastProfile',
style: TextStyle(color: Colors.white.withAlpha(160), fontSize: 12)),
),
const SizedBox(height: 24),
],
);
}
Widget _mxWrap(List<Widget> children) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Wrap(spacing: 8, runSpacing: 8, children: children),
);
Widget _mx(String label, IconData icon, VoidCallback action) => OutlinedButton.icon(
onPressed: () {
action();
_snack('Sent: $label');
},
icon: Icon(icon, size: 16),
label: Text(label),
);
}