flutter_local_device_discovery 0.2.0 copy "flutter_local_device_discovery: ^0.2.0" to clipboard
flutter_local_device_discovery: ^0.2.0 copied to clipboard

Local-network device and service discovery for Flutter using mDNS/DNS-SD, Bonjour, SSDP, and secure UPnP metadata on Android, iOS, macOS, and Windows.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_local_device_discovery/flutter_local_device_discovery.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Local Discovery v0.2 Review',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.teal,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
      ),
      home: const DiscoveryDashboard(),
    );
  }
}

class DiscoveryDashboard extends StatefulWidget {
  const DiscoveryDashboard({super.key});

  @override
  State<DiscoveryDashboard> createState() => _DiscoveryDashboardState();
}

class _DiscoveryDashboardState extends State<DiscoveryDashboard> {
  static const Set<String> _serviceTypes = <String>{
    '_http._tcp',
    '_ipp._tcp',
    '_printer._tcp',
    '_airplay._tcp',
    '_googlecast._tcp',
    '_ssh._tcp',
    '_smb._tcp',
  };

  final FlutterLocalDeviceDiscovery _discovery = FlutterLocalDeviceDiscovery();
  final Map<String, LocalDevice> _devices = <String, LocalDevice>{};
  final Map<String, LocalService> _services = <String, LocalService>{};
  final List<String> _eventLog = <String>[];
  final List<String> _warnings = <String>[];

  LocalDiscoveryCapabilities? _capabilities;
  LocalDiscoveryReadiness? _readiness;
  LocalDiscoveryDiagnostics? _diagnostics;
  LocalDiscoverySession? _session;
  StreamSubscription<LocalDiscoveryEvent>? _eventSubscription;
  bool _isDiscovering = false;
  bool _enableDnsSd = true;
  bool _enableSsdp = true;
  bool _fetchUpnpDescriptions = true;
  int _durationSeconds = 10;
  String? _error;
  DateTime? _lastCompletedAt;

  @override
  void initState() {
    super.initState();
    unawaited(_loadCapabilities());
  }

  @override
  void dispose() {
    unawaited(_eventSubscription?.cancel());
    unawaited(_session?.stop());
    super.dispose();
  }

  Future<void> _loadCapabilities() async {
    try {
      final capabilities = await _discovery.getCapabilities();
      final diagnostics = await _discovery.getDiagnostics();
      if (!mounted) return;
      setState(() {
        _capabilities = capabilities;
        _diagnostics = diagnostics;
      });
    } on Object catch (error) {
      if (mounted) setState(() => _error = 'Capability check failed: $error');
    }
  }

  LocalDiscoveryRequest _buildRequest() {
    final protocols = <LocalDiscoveryProtocol>{};
    if (_enableDnsSd) {
      protocols
        ..add(LocalDiscoveryProtocol.mdns)
        ..add(LocalDiscoveryProtocol.dnsSd)
        ..add(LocalDiscoveryProtocol.bonjour);
    }
    if (_enableSsdp) {
      protocols
        ..add(LocalDiscoveryProtocol.ssdp)
        ..add(LocalDiscoveryProtocol.upnp);
    }
    return LocalDiscoveryRequest(
      mode: LocalDiscoveryMode.servicesAndDevices,
      protocols: protocols,
      serviceTypes: _enableDnsSd ? _serviceTypes : const <String>{},
      ssdpSearchTargets:
          _enableSsdp ? const <String>{'ssdp:all'} : const <String>{},
      duration: Duration(seconds: _durationSeconds),
      resolveServices: true,
      fetchUpnpDescriptions: _enableSsdp && _fetchUpnpDescriptions,
      deduplicateResults: true,
      classifyDevices: true,
      metadataSecurityPolicy: MetadataSecurityPolicy.defaultPolicy,
    );
  }

  Future<void> _startDiscovery() async {
    if (_isDiscovering) return;
    if (kIsWeb) {
      setState(() {
        _error = 'Browsers cannot access mDNS or SSDP sockets. Run the example '
            'on Android, iOS, macOS, or Windows.';
      });
      return;
    }
    if (!_enableDnsSd && !_enableSsdp) {
      setState(() => _error = 'Enable at least one discovery protocol.');
      return;
    }

    final request = _buildRequest();
    setState(() {
      _isDiscovering = true;
      _error = null;
      _readiness = null;
      _devices.clear();
      _services.clear();
      _eventLog.clear();
      _warnings.clear();
    });

    LocalDiscoverySession? session;
    try {
      final readiness = await _discovery.checkReadiness(request);
      if (!mounted) return;
      setState(() {
        _readiness = readiness;
        _warnings.addAll(readiness.warnings);
      });
      if (!readiness.canStart) {
        throw StateError(
          'Discovery requirements are not met: '
          '${readiness.requirements.join(', ')}',
        );
      }

      session = await _discovery.start(request);
      _session = session;
      _eventSubscription = session.events.listen(_handleEvent);
      final snapshot = await session.snapshot();
      await _eventSubscription?.cancel();
      await session.stop();
      final diagnostics = await _discovery.getDiagnostics();

      if (!mounted) return;
      setState(() {
        _devices
          ..clear()
          ..addEntries(
            snapshot.devices.map((device) => MapEntry(device.id, device)),
          );
        _services
          ..clear()
          ..addEntries(
            snapshot.services.map((service) => MapEntry(service.id, service)),
          );
        _diagnostics = diagnostics;
        _lastCompletedAt = snapshot.completedAt;
      });
    } on Object catch (error) {
      if (mounted) setState(() => _error = 'Discovery failed: $error');
    } finally {
      await _eventSubscription?.cancel();
      if (session != null &&
          session.state != LocalDiscoverySessionState.stopped) {
        await session.stop();
      }
      if (mounted) {
        setState(() {
          _isDiscovering = false;
          _session = null;
          _eventSubscription = null;
        });
      }
    }
  }

  Future<void> _stopDiscovery() async {
    await _session?.stop();
  }

  void _handleEvent(LocalDiscoveryEvent event) {
    if (!mounted) return;
    setState(() {
      switch (event) {
        case LocalDiscoveryStarted():
          _log('Discovery engines started');
        case LocalDeviceAdded(:final device):
          _devices[device.id] = device;
          _log('Device added: ${device.displayName}');
        case LocalDeviceUpdated(:final device):
          _devices[device.id] = device;
          _log('Device enriched: ${device.displayName}');
        case LocalDeviceRemoved(:final device):
          _devices.remove(device.id);
          _log('Device removed: ${device.displayName}');
        case LocalServiceAdded(:final service):
          _services[service.id] = service;
          _log('Service added: ${service.serviceType}');
        case LocalServiceUpdated(:final service):
          _services[service.id] = service;
          _log('Service resolved: ${service.instanceName}');
        case LocalServiceRemoved(:final service):
          _services.remove(service.id);
          _log('Service removed: ${service.instanceName}');
        case LocalNetworkChanged():
          _log('Network configuration changed');
        case LocalDiscoveryWarning(:final message):
          if (!_warnings.contains(message)) _warnings.add(message);
          _log('Warning: $message');
        case LocalDiscoveryFailure(:final error):
          if (!_warnings.contains('$error')) _warnings.add('$error');
          _log('Engine failure: $error');
        case LocalDiscoveryStopped():
          _log('Discovery engines stopped');
      }
    });
  }

  void _log(String message) {
    _eventLog.insert(0, '${_clock(DateTime.now())}  $message');
    if (_eventLog.length > 12) _eventLog.removeLast();
  }

  @override
  Widget build(BuildContext context) {
    final devices = _devices.values.toList()
      ..sort((a, b) => a.displayName.compareTo(b.displayName));
    final services = _services.values.toList()
      ..sort((a, b) => a.instanceName.compareTo(b.instanceName));
    return Scaffold(
      appBar: AppBar(
        title: const Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text('Local Discovery'),
            Text('v0.2 review console', style: TextStyle(fontSize: 12)),
          ],
        ),
        actions: <Widget>[
          if (_isDiscovering)
            IconButton(
              onPressed: _stopDiscovery,
              tooltip: 'Stop discovery',
              icon: const Icon(Icons.stop_circle_outlined),
            )
          else
            IconButton(
              onPressed: _startDiscovery,
              tooltip: 'Start discovery',
              icon: const Icon(Icons.radar),
            ),
        ],
      ),
      body: RefreshIndicator(
        onRefresh: _isDiscovering ? () async {} : _startDiscovery,
        child: ListView(
          padding: const EdgeInsets.all(16),
          children: <Widget>[
            _buildControlPanel(),
            if (_isDiscovering) ...<Widget>[
              const SizedBox(height: 12),
              const LinearProgressIndicator(),
            ],
            if (_error != null) ...<Widget>[
              const SizedBox(height: 12),
              _MessageCard(
                icon: Icons.error_outline,
                message: _error!,
                color: Theme.of(context).colorScheme.errorContainer,
              ),
            ],
            if (_warnings.isNotEmpty) ...<Widget>[
              const SizedBox(height: 12),
              _WarningsCard(warnings: _warnings),
            ],
            const SizedBox(height: 16),
            _buildSummary(devices),
            const SizedBox(height: 20),
            _SectionHeader(
              title: 'Devices',
              count: devices.length,
              subtitle: 'Deduplicated across DNS-SD, SSDP, and UPnP',
            ),
            const SizedBox(height: 8),
            if (devices.isEmpty)
              const _EmptyCard(
                icon: Icons.devices_other,
                message: 'Start discovery to inspect normalized devices.',
              )
            else
              ...devices.map((device) => _DeviceCard(device: device)),
            const SizedBox(height: 20),
            _SectionHeader(
              title: 'DNS-SD services',
              count: services.length,
              subtitle: 'Resolved ports, addresses, and TXT records',
            ),
            const SizedBox(height: 8),
            if (services.isEmpty)
              const _EmptyCard(
                icon: Icons.dns_outlined,
                message: 'No DNS-SD services observed yet.',
              )
            else
              ...services.map((service) => _ServiceCard(service: service)),
            const SizedBox(height: 20),
            _DiagnosticsCard(diagnostics: _diagnostics, eventLog: _eventLog),
            const SizedBox(height: 24),
          ],
        ),
      ),
    );
  }

  Widget _buildControlPanel() {
    final capabilities = _capabilities;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Row(
              children: <Widget>[
                Icon(
                  _isDiscovering ? Icons.radar : Icons.tune,
                  color: Theme.of(context).colorScheme.primary,
                ),
                const SizedBox(width: 8),
                Expanded(
                  child: Text(
                    _isDiscovering ? 'Discovery in progress' : 'Review setup',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                ),
                FilledButton.icon(
                  onPressed: _isDiscovering ? _stopDiscovery : _startDiscovery,
                  icon: Icon(_isDiscovering ? Icons.stop : Icons.play_arrow),
                  label: Text(_isDiscovering ? 'Stop' : 'Run'),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: <Widget>[
                FilterChip(
                  selected: _enableDnsSd,
                  onSelected: _isDiscovering
                      ? null
                      : (value) => setState(() => _enableDnsSd = value),
                  avatar: const Icon(Icons.dns_outlined, size: 18),
                  label: const Text('mDNS / DNS-SD'),
                ),
                FilterChip(
                  selected: _enableSsdp,
                  onSelected: _isDiscovering
                      ? null
                      : (value) {
                          setState(() {
                            _enableSsdp = value;
                            if (!value) _fetchUpnpDescriptions = false;
                          });
                        },
                  avatar: const Icon(Icons.cast_connected, size: 18),
                  label: const Text('SSDP'),
                ),
                FilterChip(
                  selected: _fetchUpnpDescriptions,
                  onSelected: !_enableSsdp || _isDiscovering
                      ? null
                      : (value) =>
                          setState(() => _fetchUpnpDescriptions = value),
                  avatar: const Icon(Icons.description_outlined, size: 18),
                  label: const Text('UPnP metadata'),
                ),
              ],
            ),
            const SizedBox(height: 12),
            Row(
              children: <Widget>[
                const Text('Duration'),
                const SizedBox(width: 12),
                SegmentedButton<int>(
                  segments: const <ButtonSegment<int>>[
                    ButtonSegment<int>(value: 5, label: Text('5s')),
                    ButtonSegment<int>(value: 10, label: Text('10s')),
                    ButtonSegment<int>(value: 15, label: Text('15s')),
                  ],
                  selected: <int>{_durationSeconds},
                  onSelectionChanged: _isDiscovering
                      ? null
                      : (values) =>
                          setState(() => _durationSeconds = values.single),
                ),
              ],
            ),
            if (capabilities != null) ...<Widget>[
              const Divider(height: 24),
              Text(
                'Available: ${capabilities.supportedProtocols.map((item) => item.name).join(', ')}',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
            if (_readiness != null) ...<Widget>[
              const SizedBox(height: 6),
              Text(
                _readiness!.canStart
                    ? 'Readiness check passed'
                    : 'Readiness requirements: '
                        '${_readiness!.requirements.join(', ')}',
                style: Theme.of(context).textTheme.bodySmall?.copyWith(
                      color: _readiness!.canStart
                          ? Colors.green
                          : Theme.of(context).colorScheme.error,
                    ),
              ),
            ],
            if (_lastCompletedAt != null) ...<Widget>[
              const SizedBox(height: 6),
              Text(
                'Last completed at ${_clock(_lastCompletedAt!)}',
                style: Theme.of(context).textTheme.bodySmall,
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildSummary(List<LocalDevice> devices) {
    final ssdpCount = devices
        .where(
          (device) => device.discoveredBy.contains(LocalDiscoveryProtocol.ssdp),
        )
        .length;
    final enrichedCount = devices
        .where(
          (device) => device.discoveredBy.contains(LocalDiscoveryProtocol.upnp),
        )
        .length;
    return Card(
      color: Theme.of(context).colorScheme.secondaryContainer,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
        child: Row(
          children: <Widget>[
            _SummaryValue(label: 'Devices', value: '${devices.length}'),
            _SummaryValue(label: 'Services', value: '${_services.length}'),
            _SummaryValue(label: 'SSDP', value: '$ssdpCount'),
            _SummaryValue(label: 'Enriched', value: '$enrichedCount'),
          ],
        ),
      ),
    );
  }

  String _clock(DateTime time) => '${time.hour.toString().padLeft(2, '0')}:'
      '${time.minute.toString().padLeft(2, '0')}:'
      '${time.second.toString().padLeft(2, '0')}';
}

class _SummaryValue extends StatelessWidget {
  const _SummaryValue({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Column(
        children: <Widget>[
          Text(value, style: Theme.of(context).textTheme.headlineSmall),
          Text(label, style: Theme.of(context).textTheme.bodySmall),
        ],
      ),
    );
  }
}

class _SectionHeader extends StatelessWidget {
  const _SectionHeader({
    required this.title,
    required this.count,
    required this.subtitle,
  });

  final String title;
  final int count;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text(title, style: Theme.of(context).textTheme.titleLarge),
              Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
            ],
          ),
        ),
        Badge(label: Text('$count')),
      ],
    );
  }
}

class _DeviceCard extends StatelessWidget {
  const _DeviceCard({required this.device});

  final LocalDevice device;

  @override
  Widget build(BuildContext context) {
    final identity = device.identity;
    return Card(
      child: ExpansionTile(
        leading: CircleAvatar(child: Icon(_iconFor(device.type))),
        title: Text(device.displayName),
        subtitle: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            if (device.hostname != null) Text(device.hostname!),
            Wrap(
              spacing: 4,
              runSpacing: 2,
              children: <Widget>[
                _MiniChip(device.type.name),
                ...device.discoveredBy.map((item) => _MiniChip(item.name)),
              ],
            ),
          ],
        ),
        childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
        children: <Widget>[
          _DetailRow(label: 'ID', value: device.id),
          if (device.addresses.isNotEmpty)
            _DetailRow(
              label: 'Addresses',
              value: device.addresses.map((item) => item.address).join(', '),
            ),
          if (identity.manufacturer != null)
            _DetailRow(label: 'Manufacturer', value: identity.manufacturer!),
          if (identity.model != null)
            _DetailRow(label: 'Model', value: identity.model!),
          if (identity.serialNumber != null)
            _DetailRow(label: 'Serial', value: identity.serialNumber!),
          if (identity.upnpUdn != null)
            _DetailRow(label: 'UPnP UDN', value: identity.upnpUdn!),
          if (device.metadata['ssdpLocation'] case final String location)
            _DetailRow(label: 'Description', value: location),
          if (device.metadata['ssdpSearchTarget'] case final String target)
            _DetailRow(label: 'SSDP target', value: target),
          if (device.metadata['ssdpServer'] case final String server)
            _DetailRow(label: 'SSDP server', value: server),
          if (device.capabilities.isNotEmpty) ...<Widget>[
            const Divider(),
            Align(
              alignment: Alignment.centerLeft,
              child: Text(
                'Inferred capabilities',
                style: Theme.of(context).textTheme.titleSmall,
              ),
            ),
            Align(
              alignment: Alignment.centerLeft,
              child: Wrap(
                spacing: 6,
                runSpacing: 4,
                children: device.capabilities
                    .map((item) => Chip(label: Text(item.name)))
                    .toList(),
              ),
            ),
          ],
          if (device.capabilityEvidence.isNotEmpty) ...<Widget>[
            const Divider(),
            Align(
              alignment: Alignment.centerLeft,
              child: Text(
                'Capability evidence',
                style: Theme.of(context).textTheme.titleSmall,
              ),
            ),
            ...device.capabilityEvidence.map(
              (item) => _DetailRow(
                label: item.capability,
                value: '${(item.confidence * 100).round()}% · ${item.source}',
              ),
            ),
          ],
          if (device.services.isNotEmpty) ...<Widget>[
            const Divider(),
            _DetailRow(
              label: 'Services',
              value: device.services.map((item) => item.serviceType).join(', '),
            ),
          ],
        ],
      ),
    );
  }

  IconData _iconFor(LocalDeviceType type) => switch (type) {
        LocalDeviceType.printer => Icons.print_outlined,
        LocalDeviceType.scanner => Icons.document_scanner_outlined,
        LocalDeviceType.camera => Icons.videocam_outlined,
        LocalDeviceType.smartTv ||
        LocalDeviceType.mediaRenderer =>
          Icons.tv_outlined,
        LocalDeviceType.mediaServer ||
        LocalDeviceType.nas =>
          Icons.storage_outlined,
        LocalDeviceType.router || LocalDeviceType.gateway => Icons.router,
        LocalDeviceType.computer ||
        LocalDeviceType.server =>
          Icons.computer_outlined,
        _ => Icons.devices_other,
      };
}

class _ServiceCard extends StatelessWidget {
  const _ServiceCard({required this.service});

  final LocalService service;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ExpansionTile(
        leading: Icon(service.resolved ? Icons.dns : Icons.dns_outlined),
        title: Text(service.instanceName),
        subtitle: Text(service.serviceType),
        childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
        children: <Widget>[
          _DetailRow(label: 'Domain', value: service.domain),
          if (service.hostname != null)
            _DetailRow(label: 'Host', value: service.hostname!),
          if (service.port != null)
            _DetailRow(label: 'Port', value: '${service.port}'),
          _DetailRow(label: 'Resolved', value: service.resolved ? 'Yes' : 'No'),
          _DetailRow(
            label: 'Protocols',
            value: service.discoveredBy.map((item) => item.name).join(', '),
          ),
          if (service.addresses.isNotEmpty)
            _DetailRow(
              label: 'Addresses',
              value: service.addresses.map((item) => item.address).join(', '),
            ),
          if (service.textTxtRecords.isNotEmpty) ...<Widget>[
            const Divider(),
            ...service.textTxtRecords.entries.map(
              (item) => _DetailRow(label: item.key, value: item.value),
            ),
          ],
        ],
      ),
    );
  }
}

class _DiagnosticsCard extends StatelessWidget {
  const _DiagnosticsCard({required this.diagnostics, required this.eventLog});

  final LocalDiscoveryDiagnostics? diagnostics;
  final List<String> eventLog;

  @override
  Widget build(BuildContext context) {
    final value = diagnostics;
    return Card(
      child: ExpansionTile(
        initiallyExpanded: eventLog.isNotEmpty,
        leading: const Icon(Icons.monitor_heart_outlined),
        title: const Text('Diagnostics and event log'),
        subtitle: Text(
          value == null
              ? 'No diagnostic snapshot yet'
              : '${value.rawObservationCount} observations · '
                  '${value.metadataFetchCount} metadata fetches',
        ),
        childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
        children: <Widget>[
          if (value != null) ...<Widget>[
            _DetailRow(
              label: 'Plugin',
              value: value.pluginVersion ?? 'unknown',
            ),
            _DetailRow(
              label: 'Raw observations',
              value: '${value.rawObservationCount}',
            ),
            _DetailRow(
              label: 'Deduplicated',
              value: '${value.deduplicatedDeviceCount}',
            ),
            _DetailRow(
              label: 'UPnP fetches',
              value: '${value.metadataFetchCount}',
            ),
            _DetailRow(
              label: 'Resolved services',
              value: '${value.resolutionSuccessCount}',
            ),
            _DetailRow(
              label: 'Dropped events',
              value: '${value.droppedEventCount}',
            ),
          ],
          if (eventLog.isNotEmpty) ...<Widget>[
            const Divider(),
            Align(
              alignment: Alignment.centerLeft,
              child: Text(
                'Latest events',
                style: Theme.of(context).textTheme.titleSmall,
              ),
            ),
            const SizedBox(height: 6),
            ...eventLog.map(
              (item) => Align(
                alignment: Alignment.centerLeft,
                child: Padding(
                  padding: const EdgeInsets.symmetric(vertical: 2),
                  child: Text(
                    item,
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ),
              ),
            ),
          ],
        ],
      ),
    );
  }
}

class _WarningsCard extends StatelessWidget {
  const _WarningsCard({required this.warnings});

  final List<String> warnings;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: Theme.of(context).colorScheme.tertiaryContainer,
      child: ExpansionTile(
        leading: const Icon(Icons.warning_amber_outlined),
        title: Text(
          '${warnings.length} warning${warnings.length == 1 ? '' : 's'}',
        ),
        childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
        children: warnings
            .map(
              (item) => Align(
                alignment: Alignment.centerLeft,
                child: Padding(
                  padding: const EdgeInsets.symmetric(vertical: 3),
                  child: Text('• $item'),
                ),
              ),
            )
            .toList(),
      ),
    );
  }
}

class _MessageCard extends StatelessWidget {
  const _MessageCard({
    required this.icon,
    required this.message,
    required this.color,
  });

  final IconData icon;
  final String message;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Card(
      color: color,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: <Widget>[
            Icon(icon),
            const SizedBox(width: 12),
            Expanded(child: Text(message)),
          ],
        ),
      ),
    );
  }
}

class _EmptyCard extends StatelessWidget {
  const _EmptyCard({required this.icon, required this.message});

  final IconData icon;
  final String message;

  @override
  Widget build(BuildContext context) {
    return Card.outlined(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: <Widget>[
            Icon(icon, size: 42, color: Theme.of(context).colorScheme.outline),
            const SizedBox(height: 8),
            Text(message, textAlign: TextAlign.center),
          ],
        ),
      ),
    );
  }
}

class _MiniChip extends StatelessWidget {
  const _MiniChip(this.label);
  final String label;

  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.only(top: 3),
      padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        borderRadius: BorderRadius.circular(10),
      ),
      child: Text(label, style: Theme.of(context).textTheme.labelSmall),
    );
  }
}

class _DetailRow extends StatelessWidget {
  const _DetailRow({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          SizedBox(
            width: 120,
            child: Text(
              label,
              style: Theme.of(
                context,
              ).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
            ),
          ),
          Expanded(
            child: SelectableText(
              value,
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ),
        ],
      ),
    );
  }
}