flutter_peerlink_plugin 0.0.1 copy "flutter_peerlink_plugin: ^0.0.1" to clipboard
flutter_peerlink_plugin: ^0.0.1 copied to clipboard

A Flutter plugin for peer-to-peer data transfer in android and iOS.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_peerlink_plugin/flutter_peerlink_plugin.dart';

void main() {
  runApp(const PeerLinkApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'PeerLink Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), useMaterial3: true),
      home: const HomePage(),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  final _plugin = FlutterPeerlinkPlugin.instance;
  final _deviceNameController = TextEditingController(text: 'Device_${DateTime.now().millisecondsSinceEpoch % 10000}');
  final _messageController = TextEditingController();
  final _logs = <String>[];

  final _subscriptions = <StreamSubscription>[];
  final _handledConnections = <String>{}; // Track connections we've already handled

  bool _initialized = false;
  bool _advertising = false;
  bool _discovering = false;
  bool _sendingStream = false;
  int? _activeStreamPayloadId;

  List<PeerDevice> _discoveredDevices = [];
  List<LinkedDevice> _connections = [];

  // Track incoming stream data
  final _incomingStreams = <int, List<int>>{};

  @override
  void dispose() {
    for (final sub in _subscriptions) {
      sub.cancel();
    }
    _plugin.dispose();
    _deviceNameController.dispose();
    _messageController.dispose();
    super.dispose();
  }

  void _log(String message) {
    final time = DateTime.now().toIso8601String().substring(11, 19);
    setState(() {
      _logs.insert(0, '[$time] $message');
      if (_logs.length > 50) _logs.removeLast();
    });
  }

  Future<void> _checkAndRequestPermissions() async {
    try {
      final hasPermissions = await _plugin.checkPermissions();
      if (hasPermissions) {
        print('Permissions already granted');
        return;
      }

      final granted = await _plugin.requestPermissions();
      print(granted ? 'Permissions granted' : 'Permissions denied');
    } catch (e) {
      print('Permission error: $e');
    }
  }

  Future<void> _initialize() async {
    final name = _deviceNameController.text.trim();
    if (name.isEmpty) {
      _showSnackBar('Enter a device name');
      return;
    }

    try {
      await _plugin.initialize(serviceId: 'peerlink', deviceName: name, strategy: ConnectionStrategy.cluster);

      _setupListeners();

      setState(() => _initialized = true);
      _log('Initialized as "$name"');
    } catch (e) {
      _log('Init error: $e');
    }
  }

  void _setupListeners() {
    _subscriptions.add(
      _plugin.discoveryStream.listen((devices) {
        setState(() => _discoveredDevices = devices);
      }),
    );

    _subscriptions.add(
      _plugin.connectionStream.listen((connections) {
        setState(() => _connections = connections);

        for (final conn in connections) {
          // Only handle incoming connections - outgoing are auto-accepted by the plugin
          if (conn.state == LinkedDeviceState.connecting && conn.isIncoming && !_handledConnections.contains(conn.id)) {
            _handledConnections.add(conn.id);
            _showConnectionDialog(conn);
          }
        }
      }),
    );

    _subscriptions.add(
      _plugin.onBytePayload.listen((payload) async {
        setState(() {
          final text = utf8.decode(payload.chunk);
          _log('Received bytes from ${payload.deviceId}: $text');
        });
      }),
    );

    _subscriptions.add(
      _plugin.onStreamPayload.listen((payload) {
        setState(() {
          if (payload.isCompleted) {
            final data = _incomingStreams.remove(payload.id);
            if (data != null && data.isNotEmpty) {
              try {
                final text = utf8.decode(data);
                _log('Stream completed from ${payload.deviceId}: $text');
              } catch (_) {
                _log('Stream completed from ${payload.deviceId}: ${data.length} bytes (binary)');
              }
            } else {
              _log('Stream completed from ${payload.deviceId} (empty)');
            }
          } else if (payload.isCanceled) {
            _incomingStreams.remove(payload.id);
            _log('Stream canceled from ${payload.deviceId}');
          } else if (payload.isFailed) {
            _incomingStreams.remove(payload.id);
            _log('Stream failed from ${payload.deviceId}');
          } else if (payload.chunk.isNotEmpty) {
            _incomingStreams[payload.id]?.addAll(payload.chunk);
            _log('Stream chunk received: ${payload.chunk.length} bytes');
          } else {
            // Stream started (idle state)
            _log('Stream started from ${payload.deviceId} (payloadId: ${payload.id})');
            _incomingStreams[payload.id] = [];
          }
        });
      }),
    );
  }

  Future<void> _showConnectionDialog(LinkedDevice device) async {
    final accept = await showDialog<bool>(
      context: context,
      barrierDismissible: false,
      builder: (ctx) => AlertDialog(
        title: const Text('Connection Request'),
        content: Text('${device.name} wants to connect'),
        actions: [
          TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Reject')),
          FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('Accept')),
        ],
      ),
    );

    if (accept == true) {
      await _plugin.acceptConnection(device.id);
      _log('Accepted ${device.name}');
    } else {
      await _plugin.rejectConnection(device.id);
      _log('Rejected ${device.name}');
    }
  }

  Future<void> _toggleAdvertising() async {
    try {
      if (_advertising) {
        await _plugin.stopAdvertising();
        _log('Stopped advertising');
      } else {
        await _plugin.startAdvertising();
        _log('Started advertising');
      }
      setState(() => _advertising = !_advertising);
    } catch (e) {
      _log('Advertising error: $e');
    }
  }

  Future<void> _toggleDiscovery() async {
    try {
      if (_discovering) {
        await _plugin.stopDiscovery();
        _log('Stopped discovery');
      } else {
        await _plugin.startDiscovery();
        _log('Started discovery');
      }
      setState(() => _discovering = !_discovering);
    } catch (e) {
      _log('Discovery error: $e');
    }
  }

  Future<void> _connect(PeerDevice device) async {
    try {
      await _plugin.connect(device.id);
      _log('Connecting to ${device.name}...');
    } catch (e) {
      _log('Connect error: $e');
    }
  }

  Future<void> _disconnect(LinkedDevice device) async {
    try {
      await _plugin.disconnect(device.id);
      _log('Disconnected from ${device.name}');
    } catch (e) {
      _log('Disconnect error: $e');
    }
  }

  Future<void> _sendMessage({bool useStream = false}) async {
    final text = _messageController.text.trim();
    if (text.isEmpty) return;

    final connected = _connections.where((c) => c.state == LinkedDeviceState.connected).toList();
    if (connected.isEmpty) {
      _showSnackBar('No connected devices');
      return;
    }

    final bytes = utf8.encode(text);
    for (final device in connected) {
      try {
        if (useStream) {
          await _sendAsStream(device.id, bytes);
          _log('Sent via stream to ${device.name}: $text');
        } else {
          await _plugin.sendBytes(device.id, bytes);
          _log('Sent bytes to ${device.name}: $text');
        }
      } catch (e) {
        _log('Send error to ${device.name}: $e');
      }
    }

    _messageController.clear();
  }

  Future<void> _sendAsStream(String deviceId, List<int> data) async {
    setState(() => _sendingStream = true);

    try {
      // Start the stream
      final payloadId = await _plugin.startStream(deviceId);
      _activeStreamPayloadId = payloadId;
      _log('Stream started with payloadId: $payloadId');

      // Send data in chunks (e.g., 1KB chunks for demo)
      const chunkSize = 1024;
      for (var i = 0; i < data.length; i += chunkSize) {
        final end = (i + chunkSize < data.length) ? i + chunkSize : data.length;
        final chunk = data.sublist(i, end);
        await _plugin.sendChunk(payloadId, Uint8List.fromList(chunk));
        _log('Sent chunk ${(i ~/ chunkSize) + 1}: ${chunk.length} bytes');
      }

      // Finish the stream
      await _plugin.finishStream(payloadId);
      _log('Stream finished');
    } finally {
      setState(() {
        _sendingStream = false;
        _activeStreamPayloadId = null;
      });
    }
  }

  Future<void> _cancelActiveStream() async {
    if (_activeStreamPayloadId == null) return;

    try {
      await _plugin.finishStream(_activeStreamPayloadId!);
      _log('Stream canceled');
    } catch (e) {
      _log('Cancel stream error: $e');
    } finally {
      setState(() {
        _sendingStream = false;
        _activeStreamPayloadId = null;
      });
    }
  }

  Future<void> _sendFakeLargeStream() async {
    final connected = _connections.where((c) => c.state == LinkedDeviceState.connected).toList();
    if (connected.isEmpty) {
      _showSnackBar('No connected devices');
      return;
    }

    setState(() => _sendingStream = true);

    const totalBytes = 100 * 1024 * 1024; // 10 MB
    const chunkSize = 32 * 1024; // 32 KB chunks
    const totalChunks = totalBytes ~/ chunkSize;

    for (final device in connected) {
      try {
        _log('Starting 10 MB stream to ${device.name}...');

        final payloadId = await _plugin.startStream(device.id);
        _activeStreamPayloadId = payloadId;
        _log('Stream started (payloadId: $payloadId)');

        var bytesSent = 0;
        for (var i = 0; i < totalChunks; i++) {
          // Check if stream was canceled
          if (_activeStreamPayloadId == null) {
            _log('Stream was canceled');
            return;
          }

          // Generate fake data (pattern: chunk index repeated)
          final chunk = List<int>.generate(chunkSize, (j) => (i + j) % 256);
          await _plugin.sendChunk(payloadId, Uint8List.fromList(chunk));

          bytesSent += chunkSize;
          final progress = (bytesSent / totalBytes * 100).toStringAsFixed(1);

          // Log every 10% progress
          if (i % (totalChunks ~/ 10) == 0 || i == totalChunks - 1) {
            _log('Progress: $progress% (${(bytesSent / 1024 / 1024).toStringAsFixed(2)} MB)');
          }
        }

        await _plugin.finishStream(payloadId);
        _log('10 MB stream completed to ${device.name}');
      } catch (e) {
        _log('Large stream error to ${device.name}: $e');
      }
    }

    setState(() {
      _sendingStream = false;
      _activeStreamPayloadId = null;
    });
  }

  void _showSnackBar(String message) {
    ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('PeerLink Demo'),
        actions: [
          if (!_initialized) IconButton(onPressed: _checkAndRequestPermissions, icon: const Icon(Icons.security), tooltip: 'Request Permissions'),
        ],
      ),
      body: _initialized ? _buildMainContent() : _buildSetupContent(),
    );
  }

  Widget _buildSetupContent() {
    return Padding(
      padding: const EdgeInsets.all(24),
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          const Icon(Icons.wifi_tethering, size: 80, color: Colors.indigo),
          const SizedBox(height: 32),
          TextField(
            controller: _deviceNameController,
            decoration: const InputDecoration(labelText: 'Device Name', border: OutlineInputBorder(), prefixIcon: Icon(Icons.smartphone)),
          ),
          const SizedBox(height: 16),
          FilledButton.icon(onPressed: _initialize, icon: const Icon(Icons.play_arrow), label: const Text('Initialize')),
        ],
      ),
    );
  }

  Widget _buildMainContent() {
    return Column(
      children: [
        _buildControlBar(),
        Expanded(
          child: DefaultTabController(
            length: 3,
            child: Column(
              children: [
                const TabBar(
                  tabs: [
                    Tab(icon: Icon(Icons.search), text: 'Discover'),
                    Tab(icon: Icon(Icons.link), text: 'Connected'),
                    Tab(icon: Icon(Icons.list), text: 'Logs'),
                  ],
                ),
                Expanded(child: TabBarView(children: [_buildDiscoveryTab(), _buildConnectionsTab(), _buildLogsTab()])),
              ],
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildControlBar() {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      color: Theme.of(context).colorScheme.surfaceContainerHighest,
      child: Row(
        children: [
          Expanded(
            child: FilledButton.tonalIcon(
              onPressed: _toggleAdvertising,
              icon: Icon(_advertising ? Icons.stop : Icons.broadcast_on_personal),
              label: Text(_advertising ? 'Stop Advertise' : 'Advertise'),
              style: _advertising ? FilledButton.styleFrom(backgroundColor: Colors.orange.shade100) : null,
            ),
          ),
          const SizedBox(width: 12),
          Expanded(
            child: FilledButton.tonalIcon(
              onPressed: _toggleDiscovery,
              icon: Icon(_discovering ? Icons.stop : Icons.radar),
              label: Text(_discovering ? 'Stop Discover' : 'Discover'),
              style: _discovering ? FilledButton.styleFrom(backgroundColor: Colors.green.shade100) : null,
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildDiscoveryTab() {
    if (_discoveredDevices.isEmpty) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.devices, size: 64, color: Colors.grey.shade400),
            const SizedBox(height: 16),
            Text(_discovering ? 'Searching for devices...' : 'Start discovery to find devices', style: TextStyle(color: Colors.grey.shade600)),
            if (_discovering) ...[const SizedBox(height: 16), const CircularProgressIndicator()],
          ],
        ),
      );
    }

    return ListView.builder(
      padding: const EdgeInsets.all(8),
      itemCount: _discoveredDevices.length,
      itemBuilder: (context, index) {
        final device = _discoveredDevices[index];
        final isConnected = _connections.any((c) => c.id == device.id && c.state == LinkedDeviceState.connected);

        return Card(
          child: ListTile(
            leading: CircleAvatar(
              backgroundColor: isConnected ? Colors.green : Colors.grey.shade300,
              child: Icon(Icons.smartphone, color: isConnected ? Colors.white : Colors.grey.shade600),
            ),
            title: Text(device.name),
            subtitle: Text(device.id, style: const TextStyle(fontSize: 11)),
            trailing: isConnected
                ? const Chip(label: Text('Connected'))
                : FilledButton(onPressed: () => _connect(device), child: const Text('Connect')),
          ),
        );
      },
    );
  }

  Widget _buildConnectionsTab() {
    final connected = _connections.where((c) => c.state == LinkedDeviceState.connected).toList();

    return Column(
      children: [
        Expanded(
          child: connected.isEmpty
              ? Center(
                  child: Column(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      Icon(Icons.link_off, size: 64, color: Colors.grey.shade400),
                      const SizedBox(height: 16),
                      Text('No connected devices', style: TextStyle(color: Colors.grey.shade600)),
                    ],
                  ),
                )
              : ListView.builder(
                  padding: const EdgeInsets.all(8),
                  itemCount: connected.length,
                  itemBuilder: (context, index) {
                    final device = connected[index];
                    return Card(
                      child: ListTile(
                        leading: const CircleAvatar(
                          backgroundColor: Colors.green,
                          child: Icon(Icons.link, color: Colors.white),
                        ),
                        title: Text(device.name),
                        subtitle: Text(device.isIncoming ? 'Incoming connection' : 'Outgoing connection', style: const TextStyle(fontSize: 12)),
                        trailing: IconButton(icon: const Icon(Icons.close), onPressed: () => _disconnect(device), tooltip: 'Disconnect'),
                      ),
                    );
                  },
                ),
        ),
        if (connected.isNotEmpty) _buildMessageInput(),
      ],
    );
  }

  Widget _buildMessageInput() {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        border: Border(top: BorderSide(color: Colors.grey.shade300)),
      ),
      child: Row(
        children: [
          Expanded(
            child: TextField(
              controller: _messageController,
              enabled: !_sendingStream,
              decoration: const InputDecoration(
                hintText: 'Type a message...',
                border: OutlineInputBorder(),
                contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              ),
              onSubmitted: (_) => _sendMessage(),
            ),
          ),
          const SizedBox(width: 8),
          if (_sendingStream)
            IconButton.filled(
              onPressed: _cancelActiveStream,
              icon: const Icon(Icons.cancel),
              tooltip: 'Cancel stream',
              style: IconButton.styleFrom(backgroundColor: Colors.red),
            )
          else ...[
            IconButton.filled(
              onPressed: () => _sendMessage(useStream: false),
              icon: const Icon(Icons.send),
              tooltip: 'Send as bytes',
            ),
            const SizedBox(width: 4),
            IconButton.filledTonal(
              onPressed: () => _sendMessage(useStream: true),
              icon: const Icon(Icons.stream),
              tooltip: 'Send as stream',
            ),
            const SizedBox(width: 4),
            IconButton.filledTonal(
              onPressed: _sendFakeLargeStream,
              icon: const Icon(Icons.cloud_upload),
              tooltip: 'Send 10 MB test stream',
            ),
          ],
        ],
      ),
    );
  }

  Widget _buildLogsTab() {
    if (_logs.isEmpty) {
      return Center(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Icon(Icons.article_outlined, size: 64, color: Colors.grey.shade400),
            const SizedBox(height: 16),
            Text('No logs yet', style: TextStyle(color: Colors.grey.shade600)),
          ],
        ),
      );
    }

    return ListView.builder(
      padding: const EdgeInsets.all(8),
      itemCount: _logs.length,
      itemBuilder: (context, index) {
        return Padding(
          padding: const EdgeInsets.symmetric(vertical: 2),
          child: Text(_logs[index], style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
        );
      },
    );
  }
}
4
likes
160
points
122
downloads

Publisher

unverified uploader

Weekly Downloads

A Flutter plugin for peer-to-peer data transfer in android and iOS.

Repository (GitHub)
View/report issues

Documentation

API reference

License

Apache-2.0 (license)

Dependencies

flutter

More

Packages that depend on flutter_peerlink_plugin

Packages that implement flutter_peerlink_plugin