drago_blue_printer 1.0.0 copy "drago_blue_printer: ^1.0.0" to clipboard
drago_blue_printer: ^1.0.0 copied to clipboard

A Flutter plugin for connecting to thermal printer via bluetooth

example/lib/main.dart

import 'dart:async';
import 'package:example/testprint.dart';
import 'package:flutter/material.dart';
import 'package:drago_blue_printer/drago_blue_printer.dart';

void main() => runApp(const MyApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Drago Blue Printer',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(0xFF1565C0),
        brightness: Brightness.light,
      ),
      darkTheme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(0xFF1565C0),
        brightness: Brightness.dark,
      ),
      home: const BluetoothPrinterPage(),
    );
  }
}

// ---------------------------------------------------------------------------
// Main Page
// ---------------------------------------------------------------------------
class BluetoothPrinterPage extends StatefulWidget {
  const BluetoothPrinterPage({super.key});

  @override
  State<BluetoothPrinterPage> createState() => _BluetoothPrinterPageState();
}

class _BluetoothPrinterPageState extends State<BluetoothPrinterPage>
    with SingleTickerProviderStateMixin {
  final _bluetooth = DragoBluePrinter.instance;
  final _testPrint = TestPrint();

  List<BluetoothDevice> _pairedDevices = [];
  final List<BluetoothDevice> _scannedDevices = [];
  BluetoothDevice? _selectedDevice;
  bool _connected = false;
  bool _isLoading = false;
  bool _isConnecting = false;
  bool _isPrinting = false;
  bool _isScanning = false;
  StreamSubscription<BluetoothDevice>? _scanSub;
  StreamSubscription<int?>? _stateSub;

  late final AnimationController _scanAnimCtrl;

  @override
  void initState() {
    super.initState();
    _scanAnimCtrl = AnimationController(
      vsync: this,
      duration: const Duration(seconds: 2),
    );
    _loadDevices();
    _listenState();
  }

  @override
  void dispose() {
    _scanSub?.cancel();
    _stateSub?.cancel();
    _scanAnimCtrl.dispose();
    super.dispose();
  }

  // -- Bluetooth state listener ---------------------------------------------
  void _listenState() {
    _stateSub = _bluetooth.onStateChanged().listen((state) {
      if (!mounted) return;
      switch (state) {
        case DragoBluePrinter.CONNECTED:
          setState(() {
            _connected = true;
            _isConnecting = false;
          });
          _showSnack('Connected', icon: Icons.check_circle, isError: false);
          break;
        case DragoBluePrinter.DISCONNECTED:
        case DragoBluePrinter.DISCONNECT_REQUESTED:
          setState(() {
            _connected = false;
            _isConnecting = false;
          });
          break;
        case DragoBluePrinter.STATE_OFF:
        case DragoBluePrinter.STATE_TURNING_OFF:
          setState(() {
            _connected = false;
            _isConnecting = false;
          });
          _showSnack('Bluetooth turned off', icon: Icons.bluetooth_disabled);
          break;
        default:
          break;
      }
    });
  }

  // -- Load bonded devices --------------------------------------------------
  Future<void> _loadDevices() async {
    setState(() => _isLoading = true);
    try {
      _pairedDevices = await _bluetooth.getBondedDevices();
    } catch (e) {
      debugPrint('getBondedDevices error: $e');
    }
    if (mounted) setState(() => _isLoading = false);
  }

  // -- Scan -----------------------------------------------------------------
  void _toggleScan() {
    _isScanning ? _stopScan() : _startScan();
  }

  void _startScan() {
    setState(() {
      _isScanning = true;
      _scannedDevices.clear();
    });
    _scanAnimCtrl.repeat();
    _scanSub?.cancel();
    _scanSub = _bluetooth.scan().listen((device) {
      final isDuplicate =
          _pairedDevices.any((d) => d.address == device.address) ||
              _scannedDevices.any((d) => d.address == device.address);
      if (!isDuplicate && mounted) {
        setState(() => _scannedDevices.add(device));
      }
    });
    Future.delayed(const Duration(seconds: 20), _stopScan);
  }

  void _stopScan() {
    _scanSub?.cancel();
    if (mounted) {
      _scanAnimCtrl.stop();
      _scanAnimCtrl.reset();
      setState(() => _isScanning = false);
    }
  }

  // -- Connect / Disconnect -------------------------------------------------
  Future<void> _connect(BluetoothDevice device) async {
    if (_isConnecting) return;
    setState(() {
      _selectedDevice = device;
      _isConnecting = true;
    });
    try {
      final alreadyConnected = await _bluetooth.isConnected ?? false;
      if (!alreadyConnected) {
        await _bluetooth.connect(device);
      }
    } catch (e) {
      if (mounted) {
        setState(() => _isConnecting = false);
        _showSnack('Connection failed: $e');
      }
    }
  }

  Future<void> _disconnect() async {
    try {
      await _bluetooth.disconnect();
    } catch (_) {}
    if (mounted) setState(() => _connected = false);
  }

  // -- Pair -----------------------------------------------------------------
  Future<void> _pairDevice(BluetoothDevice device) async {
    try {
      await _bluetooth.pairDevice(device);
      await Future.delayed(const Duration(seconds: 2));
      _loadDevices();
      _showSnack('Pairing requested', icon: Icons.link, isError: false);
    } catch (e) {
      _showSnack('Pairing failed: $e');
    }
  }

  // -- Print ----------------------------------------------------------------
  Future<void> _printReceipt() async {
    if (_isPrinting) return;
    setState(() => _isPrinting = true);
    try {
      await _testPrint.sampleBatch();
      if (mounted) {
        _showSnack('Print sent!', icon: Icons.print, isError: false);
      }
    } catch (e) {
      if (mounted) _showSnack('Print error: $e');
    }
    if (mounted) setState(() => _isPrinting = false);
  }

  Future<void> _printLegacy() async {
    if (_isPrinting) return;
    setState(() => _isPrinting = true);
    try {
      await _testPrint.sampleLegacy();
      if (mounted) {
        _showSnack('Print sent (legacy)!', icon: Icons.print, isError: false);
      }
    } catch (e) {
      if (mounted) _showSnack('Print error: $e');
    }
    if (mounted) setState(() => _isPrinting = false);
  }

  // -- Snackbar helper ------------------------------------------------------
  void _showSnack(String msg,
      {IconData icon = Icons.error_outline, bool isError = true}) {
    if (!mounted) return;
    final cs = Theme.of(context).colorScheme;
    ScaffoldMessenger.of(context).removeCurrentSnackBar();
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(
      behavior: SnackBarBehavior.floating,
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
      backgroundColor: isError ? cs.errorContainer : cs.primaryContainer,
      content: Row(children: [
        Icon(icon,
            color: isError ? cs.onErrorContainer : cs.onPrimaryContainer,
            size: 20),
        const SizedBox(width: 10),
        Expanded(
          child: Text(msg,
              style: TextStyle(
                  color: isError
                      ? cs.onErrorContainer
                      : cs.onPrimaryContainer)),
        ),
      ]),
      duration: const Duration(seconds: 3),
    ));
  }

  // =========================================================================
  // BUILD
  // =========================================================================
  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    final isConnected =
        _selectedDevice != null && _connected && !_isConnecting;

    return Scaffold(
      // ── App Bar ──────────────────────────────────────────────────────────
      appBar: AppBar(
        title: const Text('Drago Blue Printer'),
        centerTitle: true,
        actions: [
          IconButton(
            tooltip: 'Refresh paired devices',
            icon: const Icon(Icons.refresh_rounded),
            onPressed: _loadDevices,
          ),
          const SizedBox(width: 4),
        ],
      ),

      // ── FAB ──────────────────────────────────────────────────────────────
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _toggleScan,
        icon: _isScanning
            ? RotationTransition(
                turns: _scanAnimCtrl,
                child: const Icon(Icons.bluetooth_searching))
            : const Icon(Icons.search_rounded),
        label: Text(_isScanning ? 'Stop Scan' : 'Scan Nearby'),
      ),

      // ── Body ─────────────────────────────────────────────────────────────
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : RefreshIndicator(
              onRefresh: _loadDevices,
              child: ListView(
                padding: const EdgeInsets.fromLTRB(16, 8, 16, 100),
                children: [
                  // ── Status banner ────────────────────────────────────────
                  _StatusBanner(
                    connected: isConnected,
                    deviceName: _selectedDevice?.name,
                  ),
                  const SizedBox(height: 16),

                  // ── Action buttons when connected ────────────────────────
                  if (isConnected) ...[
                    _SectionHeader(
                        icon: Icons.receipt_long_rounded, label: 'Actions'),
                    const SizedBox(height: 8),
                    _ActionBar(
                      isPrinting: _isPrinting,
                      onPrintBatch: _printReceipt,
                      onPrintLegacy: _printLegacy,
                      onDisconnect: _disconnect,
                    ),
                    const SizedBox(height: 24),
                  ],

                  // ── Paired devices ───────────────────────────────────────
                  _SectionHeader(
                      icon: Icons.devices_rounded, label: 'Paired Devices'),
                  const SizedBox(height: 8),
                  if (_pairedDevices.isEmpty)
                    _EmptyHint(
                        label: 'No paired printers found.',
                        icon: Icons.print_disabled_rounded),
                  ..._pairedDevices.map((d) => _DeviceTile(
                        device: d,
                        isSelected: _selectedDevice?.address == d.address,
                        isConnected:
                            _selectedDevice?.address == d.address && _connected,
                        isConnecting:
                            _selectedDevice?.address == d.address &&
                                _isConnecting,
                        onTap: () => _connect(d),
                      )),

                  // ── Scanned devices ──────────────────────────────────────
                  if (_isScanning || _scannedDevices.isNotEmpty) ...[
                    const SizedBox(height: 24),
                    _SectionHeader(
                        icon: Icons.bluetooth_searching_rounded,
                        label: 'Nearby Devices',
                        trailing: _isScanning
                            ? const SizedBox(
                                width: 14,
                                height: 14,
                                child: CircularProgressIndicator(
                                    strokeWidth: 2))
                            : null),
                    const SizedBox(height: 8),
                    if (_scannedDevices.isEmpty && _isScanning)
                      _EmptyHint(
                          label: 'Searching…',
                          icon: Icons.radar_rounded),
                    ..._scannedDevices.map((d) => _ScannedDeviceTile(
                          device: d,
                          onPair: () => _pairDevice(d),
                        )),
                  ],
                ],
              ),
            ),
    );
  }
}

// ===========================================================================
// Extracted widgets
// ===========================================================================

/// Bluetooth connection status banner at the top.
class _StatusBanner extends StatelessWidget {
  const _StatusBanner({required this.connected, this.deviceName});
  final bool connected;
  final String? deviceName;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return AnimatedContainer(
      duration: const Duration(milliseconds: 350),
      curve: Curves.easeInOut,
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(16),
        gradient: LinearGradient(
          colors: connected
              ? [cs.primaryContainer, cs.primaryContainer.withAlpha(180)]
              : [cs.surfaceContainerHighest, cs.surfaceContainerHigh],
        ),
      ),
      child: Row(children: [
        Icon(
          connected
              ? Icons.bluetooth_connected_rounded
              : Icons.bluetooth_disabled_rounded,
          color: connected ? cs.primary : cs.outline,
          size: 28,
        ),
        const SizedBox(width: 14),
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                connected ? 'Connected' : 'Not Connected',
                style: Theme.of(context).textTheme.titleSmall?.copyWith(
                    fontWeight: FontWeight.w700,
                    color: connected ? cs.primary : cs.onSurfaceVariant),
              ),
              if (connected && deviceName != null)
                Text(deviceName!,
                    style: Theme.of(context).textTheme.bodySmall?.copyWith(
                        color: cs.onPrimaryContainer)),
              if (!connected)
                Text('Select a device below to connect',
                    style: Theme.of(context)
                        .textTheme
                        .bodySmall
                        ?.copyWith(color: cs.outline)),
            ],
          ),
        ),
        if (connected)
          Container(
            width: 10,
            height: 10,
            decoration: BoxDecoration(
              color: Colors.green,
              shape: BoxShape.circle,
              boxShadow: [
                BoxShadow(
                    color: Colors.green.withAlpha(120),
                    blurRadius: 6,
                    spreadRadius: 2)
              ],
            ),
          ),
      ]),
    );
  }
}

/// Section header with icon.
class _SectionHeader extends StatelessWidget {
  const _SectionHeader(
      {required this.icon, required this.label, this.trailing});
  final IconData icon;
  final String label;
  final Widget? trailing;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Row(children: [
      Icon(icon, size: 18, color: cs.primary),
      const SizedBox(width: 8),
      Text(label,
          style: Theme.of(context)
              .textTheme
              .titleSmall
              ?.copyWith(fontWeight: FontWeight.w600, color: cs.primary)),
      if (trailing != null) ...[const SizedBox(width: 8), trailing!],
    ]);
  }
}

/// Empty-state hint row.
class _EmptyHint extends StatelessWidget {
  const _EmptyHint({required this.label, required this.icon});
  final String label;
  final IconData icon;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 24),
      child: Center(
        child: Column(mainAxisSize: MainAxisSize.min, children: [
          Icon(icon, size: 36, color: Theme.of(context).colorScheme.outline),
          const SizedBox(height: 8),
          Text(label,
              style: Theme.of(context)
                  .textTheme
                  .bodyMedium
                  ?.copyWith(color: Theme.of(context).colorScheme.outline)),
        ]),
      ),
    );
  }
}

/// Tile for a paired device.
class _DeviceTile extends StatelessWidget {
  const _DeviceTile({
    required this.device,
    required this.isSelected,
    required this.isConnected,
    required this.isConnecting,
    required this.onTap,
  });
  final BluetoothDevice device;
  final bool isSelected;
  final bool isConnected;
  final bool isConnecting;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Material(
        borderRadius: BorderRadius.circular(14),
        color: isConnected
            ? cs.primaryContainer.withAlpha(80)
            : cs.surfaceContainerLow,
        child: InkWell(
          borderRadius: BorderRadius.circular(14),
          onTap: isConnected ? null : onTap,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
            child: Row(children: [
              Container(
                width: 44,
                height: 44,
                decoration: BoxDecoration(
                  color: isConnected
                      ? cs.primary.withAlpha(30)
                      : cs.surfaceContainerHighest,
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Icon(
                  Icons.print_rounded,
                  color: isConnected ? cs.primary : cs.onSurfaceVariant,
                  size: 22,
                ),
              ),
              const SizedBox(width: 14),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(device.name ?? 'Unknown Device',
                        style: Theme.of(context)
                            .textTheme
                            .bodyLarge
                            ?.copyWith(fontWeight: FontWeight.w600)),
                    const SizedBox(height: 2),
                    Text(device.address ?? '',
                        style: Theme.of(context)
                            .textTheme
                            .bodySmall
                            ?.copyWith(color: cs.outline)),
                  ],
                ),
              ),
              if (isConnecting)
                const SizedBox(
                    width: 20,
                    height: 20,
                    child: CircularProgressIndicator(strokeWidth: 2))
              else if (isConnected)
                Chip(
                  label: const Text('Connected'),
                  labelStyle: TextStyle(
                      fontSize: 11,
                      fontWeight: FontWeight.w600,
                      color: cs.onPrimary),
                  backgroundColor: cs.primary,
                  side: BorderSide.none,
                  visualDensity: VisualDensity.compact,
                  padding: EdgeInsets.zero,
                )
              else
                Icon(Icons.chevron_right_rounded, color: cs.outline),
            ]),
          ),
        ),
      ),
    );
  }
}

/// Tile for a scanned (not-yet-paired) device.
class _ScannedDeviceTile extends StatelessWidget {
  const _ScannedDeviceTile({required this.device, required this.onPair});
  final BluetoothDevice device;
  final VoidCallback onPair;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Material(
        borderRadius: BorderRadius.circular(14),
        color: cs.surfaceContainerLow,
        child: InkWell(
          borderRadius: BorderRadius.circular(14),
          onTap: onPair,
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
            child: Row(children: [
              Container(
                width: 44,
                height: 44,
                decoration: BoxDecoration(
                  color: cs.tertiaryContainer.withAlpha(120),
                  borderRadius: BorderRadius.circular(12),
                ),
                child: Icon(Icons.bluetooth_rounded,
                    color: cs.tertiary, size: 22),
              ),
              const SizedBox(width: 14),
              Expanded(
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(device.name ?? 'Unknown',
                        style: Theme.of(context)
                            .textTheme
                            .bodyLarge
                            ?.copyWith(fontWeight: FontWeight.w600)),
                    const SizedBox(height: 2),
                    Text(device.address ?? '',
                        style: Theme.of(context)
                            .textTheme
                            .bodySmall
                            ?.copyWith(color: cs.outline)),
                  ],
                ),
              ),
              FilledButton.tonal(
                onPressed: onPair,
                style: FilledButton.styleFrom(
                    visualDensity: VisualDensity.compact),
                child: const Text('Pair'),
              ),
            ]),
          ),
        ),
      ),
    );
  }
}

/// Row of action buttons shown when a printer is connected.
class _ActionBar extends StatelessWidget {
  const _ActionBar({
    required this.isPrinting,
    required this.onPrintBatch,
    required this.onPrintLegacy,
    required this.onDisconnect,
  });
  final bool isPrinting;
  final VoidCallback onPrintBatch;
  final VoidCallback onPrintLegacy;
  final VoidCallback onDisconnect;

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return Row(children: [
      Expanded(
        child: FilledButton.icon(
          onPressed: isPrinting ? null : onPrintBatch,
          icon: isPrinting
              ? const SizedBox(
                  width: 16,
                  height: 16,
                  child: CircularProgressIndicator(
                      strokeWidth: 2, color: Colors.white))
              : const Icon(Icons.bolt_rounded, size: 20),
          label: const Text('Batch Print'),
        ),
      ),
      const SizedBox(width: 8),
      Expanded(
        child: FilledButton.tonalIcon(
          onPressed: isPrinting ? null : onPrintLegacy,
          icon: const Icon(Icons.receipt_long_rounded, size: 20),
          label: const Text('Legacy Print'),
        ),
      ),
      const SizedBox(width: 8),
      IconButton.filled(
        onPressed: onDisconnect,
        icon: const Icon(Icons.link_off_rounded, size: 20),
        tooltip: 'Disconnect',
        style: IconButton.styleFrom(
          backgroundColor: cs.errorContainer,
          foregroundColor: cs.onErrorContainer,
        ),
      ),
    ]);
  }
}
9
likes
150
points
234
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin for connecting to thermal printer via bluetooth

Repository (GitHub)
View/report issues

License

CPL-1.0 (license)

Dependencies

flutter, permission_handler

More

Packages that depend on drago_blue_printer

Packages that implement drago_blue_printer