local_websocket 0.0.4
local_websocket: ^0.0.4 copied to clipboard
A pure Dart library for local network WebSocket communication with automatic server discovery and scanning capabilities.
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:local_websocket/local_websocket.dart';
import 'dart:async';
void main() {
runApp(const MyApp());
}
// ===== CUSTOM DELEGATES =====
/// Example implementation of ClientConnectionDelegate
/// Logs when clients connect and disconnect
class LoggingConnectionDelegate implements ClientConnectionDelegate {
final void Function(String) onLog;
const LoggingConnectionDelegate(this.onLog);
@override
Future<void> onClientConnected(Client client) async {
onLog('Client connected: ${client.uid}');
if (client.details.isNotEmpty) {
onLog(' Details: ${client.details}');
}
}
@override
Future<void> onClientDisconnected(Client client) async {
onLog('Client disconnected: ${client.uid}');
}
}
/// Example implementation of RequestAuthenticationDelegate
/// Requires a token in the query parameters
class SimpleTokenAuthenticator implements RequestAuthenticationDelegate {
final String requiredToken;
final void Function(String)? onLog;
const SimpleTokenAuthenticator({required this.requiredToken, this.onLog});
@override
Future<RequestAuthenticationResult> authenticateRequest(
HttpRequest request,
) async {
final token = request.uri.queryParameters['token'];
if (token == null || token.isEmpty) {
onLog?.call('Authentication failed: Missing token');
return RequestAuthenticationResult.failure(
reason: 'Missing token parameter',
statusCode: 401,
);
}
if (token != requiredToken) {
onLog?.call('Authentication failed: Invalid token');
return RequestAuthenticationResult.failure(
reason: 'Invalid token',
statusCode: 403,
);
}
onLog?.call('Authentication successful for token: $token');
return RequestAuthenticationResult.success(metadata: {'token': token});
}
}
/// Example implementation of ClientValidationDelegate
/// Validates that client has a username in details
class UsernameValidator implements ClientValidationDelegate {
final void Function(String)? onLog;
const UsernameValidator({this.onLog});
@override
Future<bool> validateClient(Client client, HttpRequest request) async {
final username = client.details['username'];
if (username == null || username.isEmpty) {
onLog?.call('Client validation failed: Missing username');
return false;
}
if (username.length < 3) {
onLog?.call('Client validation failed: Username too short');
return false;
}
onLog?.call('Client validated: $username');
return true;
}
}
/// Example implementation of MessageValidationDelegate
/// Validates that messages are not empty and not too long
class MessageValidator implements MessageValidationDelegate {
final int maxLength;
final void Function(String)? onLog;
const MessageValidator({this.maxLength = 1000, this.onLog});
@override
Future<bool> validateMessage(Client client, dynamic message) async {
if (message == null) {
onLog?.call('Message validation failed: null message');
return false;
}
final messageStr = message.toString();
if (messageStr.isEmpty) {
onLog?.call('Message validation failed: empty message');
return false;
}
if (messageStr.length > maxLength) {
onLog?.call(
'Message validation failed: message too long (${messageStr.length} > $maxLength)',
);
return false;
}
return true;
}
}
// ===== APP =====
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Local WebSocket Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Local WebSocket Example'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: IndexedStack(
index: _selectedIndex,
children: [const ServerPage(), const ClientPage(), const ScannerPage()],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (int index) {
setState(() {
_selectedIndex = index;
});
},
destinations: const [
NavigationDestination(icon: Icon(Icons.dns), label: 'Server'),
NavigationDestination(
icon: Icon(Icons.phone_android),
label: 'Client',
),
NavigationDestination(icon: Icon(Icons.search), label: 'Scanner'),
],
),
);
}
}
// ===== SERVER PAGE =====
class ServerPage extends StatefulWidget {
const ServerPage({super.key});
@override
State<ServerPage> createState() => _ServerPageState();
}
class _ServerPageState extends State<ServerPage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
Server? _server;
final List<String> _logs = [];
final _nameController = TextEditingController(text: 'My Local Server');
final _portController = TextEditingController(text: '8080');
final _hostController = TextEditingController(text: '127.0.0.1');
final _tokenController = TextEditingController(text: 'secret123');
List<Client> _connectedClients = [];
StreamSubscription? _clientsSubscription;
// Delegate toggles
bool _useTokenAuth = false;
bool _useConnectionLogging = true;
bool _useUsernameValidation = false;
bool _useMessageValidation = false;
@override
void dispose() {
_clientsSubscription?.cancel();
_server?.stop();
_nameController.dispose();
_portController.dispose();
_hostController.dispose();
_tokenController.dispose();
super.dispose();
}
void _addLog(String message) {
setState(() {
_logs.insert(
0,
'${DateTime.now().toString().substring(11, 19)} - $message',
);
if (_logs.length > 50) _logs.removeLast();
});
}
Future<void> _startServer() async {
try {
final host = _hostController.text;
final port = int.parse(_portController.text);
_server = Server(
echo: false, // Broadcast mode
details: {
'name': _nameController.text,
'description': 'Flutter WebSocket Server Example',
'version': '1.0.0',
'platform': 'Flutter',
'authEnabled': _useTokenAuth,
'validationEnabled': _useUsernameValidation || _useMessageValidation,
},
requestAuthenticationDelegate: _useTokenAuth
? SimpleTokenAuthenticator(
requiredToken: _tokenController.text,
onLog: _addLog,
)
: null,
clientConnectionDelegate: _useConnectionLogging
? LoggingConnectionDelegate(_addLog)
: null,
clientValidationDelegate: _useUsernameValidation
? UsernameValidator(onLog: _addLog)
: null,
messageValidationDelegate: _useMessageValidation
? MessageValidator(maxLength: 500, onLog: _addLog)
: null,
);
await _server!.start(host, port: port);
_addLog('Server started at ${_server!.address}');
if (_useTokenAuth) {
_addLog(
'Token authentication enabled (token: ${_tokenController.text})',
);
}
if (_useUsernameValidation) {
_addLog('Username validation enabled');
}
if (_useMessageValidation) {
_addLog('Message validation enabled (max 500 chars)');
}
// Listen for client connections
_clientsSubscription = _server!.clientsStream.listen((clients) {
setState(() {
_connectedClients = clients.toList();
});
_addLog('Connected clients: ${clients.length}');
});
// Listen for messages from clients
_server!.messageStream.listen((message) {
_addLog(
'Message received: ${message.toString().substring(0, message.toString().length > 50 ? 50 : message.toString().length)}...',
);
});
setState(() {});
} catch (e) {
_addLog('Error starting server: $e');
}
}
Future<void> _stopServer() async {
try {
await _clientsSubscription?.cancel();
await _server?.stop();
_addLog('Server stopped');
setState(() {
_server = null;
_connectedClients = [];
});
} catch (e) {
_addLog('Error stopping server: $e');
}
}
void _broadcastMessage(String message) {
if (_server != null) {
_server!.send(message);
_addLog('Broadcasted: $message');
}
}
@override
Widget build(BuildContext context) {
super.build(context);
final isRunning = _server != null;
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Server Configuration',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Server Name',
border: OutlineInputBorder(),
),
enabled: !isRunning,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host',
border: OutlineInputBorder(),
),
enabled: !isRunning,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _portController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
enabled: !isRunning,
),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
const Text(
'Security & Validation',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
CheckboxListTile(
value: _useTokenAuth,
onChanged: isRunning
? null
: (value) =>
setState(() => _useTokenAuth = value ?? false),
title: const Text('Token Authentication'),
subtitle: const Text('Require token in query params'),
dense: true,
contentPadding: EdgeInsets.zero,
),
if (_useTokenAuth) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8),
child: TextField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Required Token',
border: OutlineInputBorder(),
isDense: true,
helperText: 'Clients must include ?token=VALUE',
),
enabled: !isRunning,
),
),
],
CheckboxListTile(
value: _useConnectionLogging,
onChanged: isRunning
? null
: (value) => setState(
() => _useConnectionLogging = value ?? false,
),
title: const Text('Connection Logging'),
subtitle: const Text(
'Log client connect/disconnect events',
),
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
value: _useUsernameValidation,
onChanged: isRunning
? null
: (value) => setState(
() => _useUsernameValidation = value ?? false,
),
title: const Text('Username Validation'),
subtitle: const Text('Require username (min 3 chars)'),
dense: true,
contentPadding: EdgeInsets.zero,
),
CheckboxListTile(
value: _useMessageValidation,
onChanged: isRunning
? null
: (value) => setState(
() => _useMessageValidation = value ?? false,
),
title: const Text('Message Validation'),
subtitle: const Text('Limit message length (500 chars)'),
dense: true,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
const Divider(),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: isRunning ? null : _startServer,
icon: const Icon(Icons.play_arrow),
label: const Text('Start Server'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: isRunning ? _stopServer : null,
icon: const Icon(Icons.stop),
label: const Text('Stop Server'),
),
),
],
),
if (isRunning) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.green),
),
child: Row(
children: [
const Icon(Icons.check_circle, color: Colors.green),
const SizedBox(width: 8),
Expanded(
child: Text(
'Running at ${_server!.address}',
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
],
),
),
],
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Connected Clients (${_connectedClients.length})',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120),
child: _connectedClients.isEmpty
? const SizedBox(
height: 60,
child: Center(child: Text('No clients connected')),
)
: ListView.builder(
shrinkWrap: true,
itemCount: _connectedClients.length,
itemBuilder: (context, index) {
final client = _connectedClients[index];
return ListTile(
dense: true,
leading: const Icon(Icons.person),
title: Text(client.uid),
subtitle: Text(
client.details.isNotEmpty
? client.details.toString()
: 'No details',
),
);
},
),
),
if (isRunning) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
decoration: const InputDecoration(
labelText: 'Broadcast Message',
border: OutlineInputBorder(),
isDense: true,
),
onSubmitted: (value) {
if (value.isNotEmpty) {
_broadcastMessage(value);
}
},
),
),
],
),
],
],
),
),
),
const SizedBox(height: 16),
const Text(
'Server Logs',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 300,
child: Card(
child: _logs.isEmpty
? const Center(child: Text('No logs yet'))
: ListView.builder(
itemCount: _logs.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Text(
_logs[index],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
);
},
),
),
),
],
),
);
}
}
// ===== CLIENT PAGE =====
class ClientPage extends StatefulWidget {
const ClientPage({super.key});
@override
State<ClientPage> createState() => _ClientPageState();
}
class _ClientPageState extends State<ClientPage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
Client? _client;
final List<String> _messages = [];
final _urlController = TextEditingController(text: 'ws://127.0.0.1:8080/ws');
final _usernameController = TextEditingController(
text: 'User_${DateTime.now().millisecondsSinceEpoch % 1000}',
);
final _tokenController = TextEditingController(text: 'secret123');
final _messageController = TextEditingController();
bool _isConnected = false;
bool _useToken = false;
bool _useAutoReconnect = false;
ClientConnectionStatus _connectionStatus =
ClientConnectionStatus.disconnected;
StreamSubscription? _connectionSubscription;
StreamSubscription? _messageSubscription;
@override
void dispose() {
_connectionSubscription?.cancel();
_messageSubscription?.cancel();
_client?.disconnect();
_urlController.dispose();
_usernameController.dispose();
_tokenController.dispose();
_messageController.dispose();
super.dispose();
}
void _addMessage(String message) {
setState(() {
_messages.insert(
0,
'${DateTime.now().toString().substring(11, 19)} - $message',
);
if (_messages.length > 50) _messages.removeLast();
});
}
Future<void> _connect() async {
try {
// Build client details - include token if enabled
final details = <String, String>{
'username': _usernameController.text,
'device': 'Flutter App',
'platform': 'Mobile',
};
// Add token to details if enabled (this will be added as query parameter)
if (_useToken) {
details['token'] = _tokenController.text;
}
// Create client with optional auto-reconnect
_client = Client(
details: details,
clientReconnectionDelegate: _useAutoReconnect
? ExponentialBackoffReconnect(
maxAttempts: 5,
initialDelay: Duration(seconds: 1),
maxDelay: Duration(seconds: 30),
)
: null,
);
await _client!.connect(_urlController.text);
_addMessage('Connected! Client ID: ${_client!.uid}');
if (_useToken) {
_addMessage('Using token: ${_tokenController.text}');
}
if (_useAutoReconnect) {
_addMessage('Auto-reconnect enabled (max 5 attempts)');
}
_connectionSubscription = _client!.connectionStream.listen((status) {
setState(() {
_connectionStatus = status;
_isConnected = status.isConnected;
});
switch (status) {
case ClientConnectionStatus.connected:
_addMessage('Status: Connected');
break;
case ClientConnectionStatus.connecting:
_addMessage('Status: Connecting...');
break;
case ClientConnectionStatus.disconnected:
_addMessage('Status: Disconnected');
break;
}
});
_messageSubscription = _client!.messageStream.listen((message) {
_addMessage('Received: $message');
});
setState(() {
_connectionStatus = ClientConnectionStatus.connected;
_isConnected = true;
});
} catch (e) {
_addMessage('Error connecting: $e');
setState(() {
_connectionStatus = ClientConnectionStatus.disconnected;
_isConnected = false;
});
}
}
Future<void> _disconnect() async {
try {
await _connectionSubscription?.cancel();
await _messageSubscription?.cancel();
await _client?.disconnect();
_addMessage('Disconnected');
setState(() {
_connectionStatus = ClientConnectionStatus.disconnected;
_isConnected = false;
_client = null;
});
} catch (e) {
_addMessage('Error disconnecting: $e');
}
}
void _sendMessage(String message) {
if (_client != null && message.isNotEmpty) {
_client!.send(message);
_addMessage('Sent: $message');
_messageController.clear();
}
}
void _sendJsonMessage() {
if (_client != null) {
final jsonMessage = jsonEncode({
'type': 'chat',
'user': _usernameController.text,
'message': _messageController.text,
'timestamp': DateTime.now().toIso8601String(),
});
_client!.send(jsonMessage);
_addMessage('Sent JSON: ${jsonMessage.toString()}');
_messageController.clear();
}
}
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Client Configuration',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
border: OutlineInputBorder(),
),
enabled: !_isConnected,
),
const SizedBox(height: 12),
TextField(
controller: _urlController,
decoration: const InputDecoration(
labelText: 'WebSocket URL',
border: OutlineInputBorder(),
helperText:
'Token will be added automatically if enabled',
),
enabled: !_isConnected,
),
const SizedBox(height: 12),
CheckboxListTile(
value: _useToken,
onChanged: _isConnected
? null
: (value) => setState(() => _useToken = value ?? false),
title: const Text('Use Token Authentication'),
subtitle: const Text('Add token to query parameters'),
dense: true,
contentPadding: EdgeInsets.zero,
),
if (_useToken) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 12),
child: TextField(
controller: _tokenController,
decoration: const InputDecoration(
labelText: 'Token',
border: OutlineInputBorder(),
isDense: true,
helperText: 'Must match server token',
),
enabled: !_isConnected,
),
),
],
CheckboxListTile(
value: _useAutoReconnect,
onChanged: _isConnected
? null
: (value) => setState(
() => _useAutoReconnect = value ?? false,
),
title: const Text('Enable Auto-Reconnect'),
subtitle: const Text(
'Automatically reconnect if connection is lost',
),
dense: true,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _isConnected ? null : _connect,
icon: const Icon(Icons.link),
label: const Text('Connect'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _isConnected ? _disconnect : null,
icon: const Icon(Icons.link_off),
label: const Text('Disconnect'),
),
),
],
),
const SizedBox(height: 12),
// Connection status indicator
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color:
_connectionStatus == ClientConnectionStatus.connected
? Colors.green.withAlpha(25)
: _connectionStatus ==
ClientConnectionStatus.connecting
? Colors.orange.withAlpha(25)
: Colors.red.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color:
_connectionStatus ==
ClientConnectionStatus.connected
? Colors.green
: _connectionStatus ==
ClientConnectionStatus.connecting
? Colors.orange
: Colors.red,
),
),
child: Row(
children: [
Icon(
_connectionStatus == ClientConnectionStatus.connected
? Icons.cloud_done
: _connectionStatus ==
ClientConnectionStatus.connecting
? Icons.cloud_sync
: Icons.cloud_off,
color:
_connectionStatus ==
ClientConnectionStatus.connected
? Colors.green
: _connectionStatus ==
ClientConnectionStatus.connecting
? Colors.orange
: Colors.red,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_connectionStatus ==
ClientConnectionStatus.connected
? 'Connected'
: _connectionStatus ==
ClientConnectionStatus.connecting
? 'Connecting...'
: 'Disconnected',
style: TextStyle(
fontWeight: FontWeight.bold,
color:
_connectionStatus ==
ClientConnectionStatus.connected
? Colors.green.shade700
: _connectionStatus ==
ClientConnectionStatus.connecting
? Colors.orange.shade700
: Colors.red.shade700,
),
),
if (_isConnected && _client != null)
Text(
'Client ID: ${_client!.uid}',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade600,
),
),
],
),
),
],
),
),
],
),
),
),
const SizedBox(height: 16),
if (_isConnected) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Send Message',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: const InputDecoration(
labelText: 'Message',
border: OutlineInputBorder(),
isDense: true,
),
onSubmitted: _sendMessage,
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: () =>
_sendMessage(_messageController.text),
icon: const Icon(Icons.send),
tooltip: 'Send Text',
),
IconButton.filledTonal(
onPressed: _sendJsonMessage,
icon: const Icon(Icons.code),
tooltip: 'Send as JSON',
),
],
),
],
),
),
),
const SizedBox(height: 16),
],
const Text(
'Messages',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 300,
child: Card(
child: _messages.isEmpty
? const Center(child: Text('No messages yet'))
: ListView.builder(
itemCount: _messages.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
child: Text(
_messages[index],
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
);
},
),
),
),
],
),
);
}
}
// ===== SCANNER PAGE =====
class ScannerPage extends StatefulWidget {
const ScannerPage({super.key});
@override
State<ScannerPage> createState() => _ScannerPageState();
}
class _ScannerPageState extends State<ScannerPage>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => true;
final List<DiscoveredServer> _discoveredServers = [];
final _hostController = TextEditingController(text: 'localhost');
final _portController = TextEditingController(text: '8080');
bool _isScanning = false;
StreamSubscription? _scanSubscription;
@override
void dispose() {
_scanSubscription?.cancel();
_hostController.dispose();
_portController.dispose();
super.dispose();
}
Future<void> _startScanning() async {
setState(() {
_isScanning = true;
_discoveredServers.clear();
});
try {
final host = _hostController.text;
final port = int.parse(_portController.text);
_scanSubscription = Scanner.scan(host, port: port).listen(
(servers) {
setState(() {
_discoveredServers.clear();
_discoveredServers.addAll(servers);
});
},
onError: (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Scan error: $error')));
}
},
);
} catch (e) {
setState(() {
_isScanning = false;
});
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $e')));
}
}
}
void _stopScanning() {
_scanSubscription?.cancel();
setState(() {
_isScanning = false;
});
}
@override
Widget build(BuildContext context) {
super.build(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Scanner Configuration',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _hostController,
decoration: const InputDecoration(
labelText: 'Host / Subnet',
border: OutlineInputBorder(),
helperText: 'e.g., localhost or 192.168.1',
),
enabled: !_isScanning,
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _portController,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
enabled: !_isScanning,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _isScanning ? null : _startScanning,
icon: const Icon(Icons.search),
label: const Text('Start Scanning'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton.tonalIcon(
onPressed: _isScanning ? _stopScanning : null,
icon: const Icon(Icons.stop),
label: const Text('Stop Scanning'),
),
),
],
),
if (_isScanning) ...[
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 8),
const Text(
'Scanning for servers...',
style: TextStyle(fontStyle: FontStyle.italic),
textAlign: TextAlign.center,
),
],
],
),
),
),
const SizedBox(height: 16),
Text(
'Discovered Servers (${_discoveredServers.length})',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
height: 400,
child: _discoveredServers.isEmpty
? Card(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
_isScanning
? 'Scanning for servers...'
: 'No servers found',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
),
),
],
),
),
)
: ListView.builder(
itemCount: _discoveredServers.length,
itemBuilder: (context, index) {
final server = _discoveredServers[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: const Icon(Icons.dns, color: Colors.green),
title: Text(
server.details['name']?.toString() ??
'Unknown Server',
style: const TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text('URL: ${server.path}'),
if (server.details.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Details: ${server.details.entries.map((e) => '${e.key}: ${e.value}').join(', ')}',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade700,
),
),
],
],
),
trailing: IconButton(
icon: const Icon(Icons.copy),
tooltip: 'Copy URL',
onPressed: () {
// In a real app, you'd copy to clipboard
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('URL copied: ${server.path}'),
duration: const Duration(seconds: 2),
),
);
}
},
),
),
);
},
),
),
],
),
);
}
}