flutter_peerlink_plugin 0.0.1
flutter_peerlink_plugin: ^0.0.1 copied to clipboard
A Flutter plugin for peer-to-peer data transfer in android and iOS.
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)),
);
},
);
}
}