multicast/broadcast support

This commit is contained in:
2025-10-15 15:22:51 +02:00
parent eb154802b4
commit c5264186f3
12 changed files with 712 additions and 116 deletions

View File

@ -7,34 +7,54 @@ part 'project.g.dart';
class Project {
final String id;
final String name;
final String ipAddress;
// Support for multiple IP addresses at project level
final List<String> ipAddresses;
// Broadcast mode flag for project
@JsonKey(defaultValue: false)
final bool isBroadcast;
final int port;
final List<UdpPackage> packages;
// Legacy field for backward compatibility
@JsonKey(includeFromJson: true, includeToJson: false)
final String? ipAddress;
Project({
required this.id,
required this.name,
required this.ipAddress,
List<String>? ipAddresses,
this.isBroadcast = false,
required this.port,
required this.packages,
});
this.ipAddress,
}) : ipAddresses = ipAddresses ?? (ipAddress != null ? [ipAddress] : ['127.0.0.1']);
factory Project.fromJson(Map<String, dynamic> json) =>
_$ProjectFromJson(json);
factory Project.fromJson(Map<String, dynamic> json) {
// Handle migration from old format
if (json.containsKey('ipAddress') && !json.containsKey('ipAddresses')) {
json['ipAddresses'] = [json['ipAddress']];
}
return _$ProjectFromJson(json);
}
Map<String, dynamic> toJson() => _$ProjectToJson(this);
Project copyWith({
String? id,
String? name,
String? ipAddress,
List<String>? ipAddresses,
bool? isBroadcast,
int? port,
List<UdpPackage>? packages,
}) {
return Project(
id: id ?? this.id,
name: name ?? this.name,
ipAddress: ipAddress ?? this.ipAddress,
ipAddresses: ipAddresses ?? this.ipAddresses,
isBroadcast: isBroadcast ?? this.isBroadcast,
port: port ?? this.port,
packages: packages ?? this.packages,
);

View File

@ -9,17 +9,22 @@ part of 'project.dart';
Project _$ProjectFromJson(Map<String, dynamic> json) => Project(
id: json['id'] as String,
name: json['name'] as String,
ipAddress: json['ipAddress'] as String,
ipAddresses: (json['ipAddresses'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
isBroadcast: json['isBroadcast'] as bool? ?? false,
port: (json['port'] as num).toInt(),
packages: (json['packages'] as List<dynamic>)
.map((e) => UdpPackage.fromJson(e as Map<String, dynamic>))
.toList(),
ipAddress: json['ipAddress'] as String?,
);
Map<String, dynamic> _$ProjectToJson(Project instance) => <String, dynamic>{
'id': instance.id,
'name': instance.name,
'ipAddress': instance.ipAddress,
'ipAddresses': instance.ipAddresses,
'isBroadcast': instance.isBroadcast,
'port': instance.port,
'packages': instance.packages,
};

View File

@ -7,17 +7,34 @@ class UdpPackage {
final String id;
final String name;
final String data;
final String ipAddress;
// Support for multiple IP addresses
final List<String> ipAddresses;
// Broadcast mode flag
@JsonKey(defaultValue: false)
final bool isBroadcast;
// Legacy field for backward compatibility - kept for migration
@JsonKey(includeFromJson: true, includeToJson: false)
final String? ipAddress;
UdpPackage({
required this.id,
required this.name,
required this.data,
required this.ipAddress,
});
List<String>? ipAddresses,
this.isBroadcast = false,
this.ipAddress,
}) : ipAddresses = ipAddresses ?? (ipAddress != null ? [ipAddress] : ['127.0.0.1']);
factory UdpPackage.fromJson(Map<String, dynamic> json) =>
_$UdpPackageFromJson(json);
factory UdpPackage.fromJson(Map<String, dynamic> json) {
// Handle migration from old format
if (json.containsKey('ipAddress') && !json.containsKey('ipAddresses')) {
json['ipAddresses'] = [json['ipAddress']];
}
return _$UdpPackageFromJson(json);
}
Map<String, dynamic> toJson() => _$UdpPackageToJson(this);
@ -25,13 +42,15 @@ class UdpPackage {
String? id,
String? name,
String? data,
String? ipAddress,
List<String>? ipAddresses,
bool? isBroadcast,
}) {
return UdpPackage(
id: id ?? this.id,
name: name ?? this.name,
data: data ?? this.data,
ipAddress: ipAddress ?? this.ipAddress,
ipAddresses: ipAddresses ?? this.ipAddresses,
isBroadcast: isBroadcast ?? this.isBroadcast,
);
}
}

View File

@ -10,7 +10,11 @@ UdpPackage _$UdpPackageFromJson(Map<String, dynamic> json) => UdpPackage(
id: json['id'] as String,
name: json['name'] as String,
data: json['data'] as String,
ipAddress: json['ipAddress'] as String,
ipAddresses: (json['ipAddresses'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
isBroadcast: json['isBroadcast'] as bool? ?? false,
ipAddress: json['ipAddress'] as String?,
);
Map<String, dynamic> _$UdpPackageToJson(UdpPackage instance) =>
@ -18,5 +22,6 @@ Map<String, dynamic> _$UdpPackageToJson(UdpPackage instance) =>
'id': instance.id,
'name': instance.name,
'data': instance.data,
'ipAddress': instance.ipAddress,
'ipAddresses': instance.ipAddresses,
'isBroadcast': instance.isBroadcast,
};

View File

@ -322,9 +322,14 @@ class _HomeScreenState extends State<HomeScreen> {
),
const SizedBox(height: 4),
Text(
'${project.ipAddress}:${project.port}${project.packages.length} package(s)',
style:
Theme.of(context).textTheme.bodySmall,
project.isBroadcast
? 'Broadcast • Port: ${project.port}${project.packages.length} package(s)'
: project.ipAddresses.length > 1
? '${project.ipAddresses.length} IPs • Port: ${project.port}${project.packages.length} package(s)'
: '${project.ipAddresses.first}:${project.port}${project.packages.length} package(s)',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),

View File

@ -22,13 +22,17 @@ class _ProjectScreenState extends State<ProjectScreen> {
bool _isSending = false;
late TextEditingController _ipController;
late TextEditingController _portController;
late List<String> _projectIpAddresses;
late bool _projectIsBroadcast;
@override
void initState() {
super.initState();
_project = widget.project;
_ipController = TextEditingController(text: _project.ipAddress);
_ipController = TextEditingController();
_portController = TextEditingController(text: _project.port.toString());
_projectIpAddresses = _project.ipAddresses.toList();
_projectIsBroadcast = _project.isBroadcast;
}
@override
@ -67,7 +71,8 @@ class _ProjectScreenState extends State<ProjectScreen> {
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: '${package.name} (Copy)',
data: package.data,
ipAddress: package.ipAddress,
ipAddresses: package.ipAddresses.toList(),
isBroadcast: package.isBroadcast,
);
_addPackage(copiedPackage);
ScaffoldMessenger.of(context).showSnackBar(
@ -78,58 +83,40 @@ class _ProjectScreenState extends State<ProjectScreen> {
);
}
void _updateSettings() {
final ip = _ipController.text.trim();
final port = int.tryParse(_portController.text);
// Validate IP address
bool _isValidIp(String ip) {
final ipPattern = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$');
bool isValidIp = ipPattern.hasMatch(ip);
if (isValidIp) {
final parts = ip.split('.');
for (final part in parts) {
final num = int.tryParse(part);
if (num == null || num < 0 || num > 255) {
isValidIp = false;
break;
}
if (!ipPattern.hasMatch(ip)) return false;
final parts = ip.split('.');
for (final part in parts) {
final num = int.tryParse(part);
if (num == null || num < 0 || num > 255) {
return false;
}
}
// Validate port
final isValidPort = port != null && port >= 1 && port <= 65535;
if (isValidIp && isValidPort) {
setState(() {
_project = _project.copyWith(
ipAddress: ip,
port: port,
return true;
}
void _addProjectIp() {
final ip = _ipController.text.trim();
if (ip.isNotEmpty && _isValidIp(ip)) {
if (!_projectIpAddresses.contains(ip)) {
setState(() {
_projectIpAddresses.add(ip);
_ipController.clear();
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('IP address already added'),
behavior: SnackBarBehavior.floating,
),
);
});
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Settings updated successfully!'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
} else {
// Revert to current project values
_ipController.text = _project.ipAddress;
_portController.text = _project.port.toString();
String errorMessage = 'Invalid ';
if (!isValidIp && !isValidPort) {
errorMessage += 'IP address and port number';
} else if (!isValidIp) {
errorMessage += 'IP address';
} else {
errorMessage += 'port number (1-65535)';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMessage),
content: Text('Invalid IP address'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
@ -137,6 +124,117 @@ class _ProjectScreenState extends State<ProjectScreen> {
}
}
void _removeProjectIp(String ip) {
setState(() {
_projectIpAddresses.remove(ip);
// Ensure at least one IP if not in broadcast mode
if (_projectIpAddresses.isEmpty && !_projectIsBroadcast) {
_projectIpAddresses.add('127.0.0.1');
}
});
}
void _updateSettings() {
final port = int.tryParse(_portController.text);
// Validate port
final isValidPort = port != null && port >= 1 && port <= 65535;
// Validate we have IPs if not in broadcast mode
if (!_projectIsBroadcast && _projectIpAddresses.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please add at least one IP address'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
return;
}
if (isValidPort) {
// Show dialog asking if user wants to apply settings to all packages
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Apply Settings'),
content: const Text(
'Do you want to apply these settings to all packages?\n\n'
'• Yes - All packages will use these settings\n'
'• No - Only project defaults will be updated (packages keep their individual settings)',
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context);
_applySettings(port!, applyToPackages: false);
},
child: const Text('No'),
),
FilledButton(
onPressed: () {
Navigator.pop(context);
_applySettings(port!, applyToPackages: true);
},
child: const Text('Yes'),
),
],
),
);
} else {
// Revert to current project values
_portController.text = _project.port.toString();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid port number (1-65535)'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
void _applySettings(int port, {required bool applyToPackages}) {
setState(() {
if (applyToPackages) {
// Update all packages to use the new project settings
final updatedPackages = _project.packages.map((package) {
return package.copyWith(
ipAddresses: _projectIsBroadcast ? [] : _projectIpAddresses,
isBroadcast: _projectIsBroadcast,
);
}).toList();
_project = _project.copyWith(
ipAddresses: _projectIsBroadcast ? [] : _projectIpAddresses,
isBroadcast: _projectIsBroadcast,
port: port,
packages: updatedPackages,
);
} else {
// Only update project-level settings
_project = _project.copyWith(
ipAddresses: _projectIsBroadcast ? [] : _projectIpAddresses,
isBroadcast: _projectIsBroadcast,
port: port,
);
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
applyToPackages
? 'Settings applied to project and all packages!'
: 'Project settings updated!',
),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
}
void _showPackageDialog({UdpPackage? package}) {
showDialog(
context: context,
@ -170,15 +268,28 @@ class _ProjectScreenState extends State<ProjectScreen> {
_isSending = true;
});
final success = await _udpService.sendPackage(
// Determine if we should use project-level or package-level settings
// Packages can override project settings, or use project defaults if empty
final isBroadcast = package.isBroadcast || (_project.isBroadcast && package.ipAddresses.isEmpty);
final ipAddresses = package.ipAddresses.isNotEmpty
? package.ipAddresses
: _project.ipAddresses;
// Use the new advanced send method that supports broadcast and multiple IPs
final results = await _udpService.sendPackageAdvanced(
data: package.data,
ipAddress: _project.ipAddress, // Use project IP to override package IP
port: _project.port,
isBroadcast: isBroadcast,
ipAddresses: isBroadcast ? null : ipAddresses,
);
final successCount = results.values.where((v) => v).length;
final totalCount = results.length;
final allSuccess = successCount == totalCount;
setState(() {
_isSending = false;
if (success) {
if (allSuccess) {
_lastSentPackageId = package.id;
// Reset the indicator after 2 seconds
Future.delayed(const Duration(seconds: 2), () {
@ -193,15 +304,35 @@ class _ProjectScreenState extends State<ProjectScreen> {
if (!mounted) return;
if (success) {
// Show appropriate feedback based on results
if (allSuccess) {
String message;
if (isBroadcast) {
message = 'Package "${package.name}" broadcast successfully!';
} else if (totalCount > 1) {
message = 'Package "${package.name}" sent to $totalCount addresses!';
} else {
message = 'Package "${package.name}" sent successfully!';
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Package "${package.name}" sent successfully!'),
content: Text(message),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
),
);
} else if (successCount > 0) {
// Partial success
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Package "${package.name}" sent to $successCount of $totalCount addresses'),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
),
);
} else {
// Complete failure
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to send package "${package.name}"'),
@ -254,20 +385,106 @@ class _ProjectScreenState extends State<ProjectScreen> {
],
),
const SizedBox(height: 16),
// Broadcast Toggle
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Broadcast Mode'),
subtitle: const Text('Send to all devices on network'),
value: _projectIsBroadcast,
onChanged: (value) {
setState(() {
_projectIsBroadcast = value;
});
},
secondary: const Icon(Icons.sensors),
),
const SizedBox(height: 8),
// IP Addresses Section (only shown when not in broadcast mode)
if (!_projectIsBroadcast) ...[
Text(
'Target IP Addresses',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
// IP Address Input
Row(
children: [
Expanded(
child: TextField(
controller: _ipController,
decoration: const InputDecoration(
labelText: 'IP Address',
prefixIcon: Icon(Icons.computer),
hintText: '192.168.1.100',
isDense: true,
),
keyboardType: TextInputType.number,
onSubmitted: (_) => _addProjectIp(),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _addProjectIp,
icon: const Icon(Icons.add),
tooltip: 'Add IP',
),
],
),
const SizedBox(height: 12),
// IP Address Chips
if (_projectIpAddresses.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _projectIpAddresses.map((ip) {
return Chip(
label: Text(ip),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: _projectIpAddresses.length > 1
? () => _removeProjectIp(ip)
: null,
);
}).toList(),
),
const SizedBox(height: 16),
] else ...[
// Broadcast Info
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Packages will be broadcast to all devices on your local network',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
),
),
const SizedBox(height: 16),
],
// Port Configuration
Row(
children: [
Expanded(
child: TextField(
controller: _ipController,
decoration: const InputDecoration(
labelText: 'IP Address',
prefixIcon: Icon(Icons.computer),
isDense: true,
),
onSubmitted: (_) => _updateSettings(),
),
),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _portController,
@ -401,11 +618,33 @@ class _ProjectScreenState extends State<ProjectScreen> {
],
),
const SizedBox(height: 4),
Text(
'To: ${_project.ipAddress}:${_project.port}',
style:
Theme.of(context).textTheme.bodySmall,
),
if (package.isBroadcast)
Row(
children: [
Icon(
Icons.sensors,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 4),
Text(
'Broadcast • Port: ${_project.port}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
],
)
else
Text(
package.ipAddresses.length > 1
? 'To: ${package.ipAddresses.join(", ")} • Port: ${_project.port}'
: 'To: ${package.ipAddresses.first}:${_project.port}',
style: Theme.of(context).textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),

View File

@ -3,6 +3,7 @@ import 'dart:io';
import 'package:udp/udp.dart';
class UdpService {
/// Send a package to a single IP address
Future<bool> sendPackage({
required String data,
required String ipAddress,
@ -37,6 +38,98 @@ class UdpService {
sender?.close();
}
}
/// Send a broadcast package to all devices on the network
Future<bool> sendBroadcast({
required String data,
required int port,
String broadcastAddress = '255.255.255.255',
}) async {
UDP? sender;
try {
// Create UDP instance
sender = await UDP.bind(Endpoint.any());
// Convert data to bytes
final dataBytes = utf8.encode(data);
// Parse broadcast address
final address = InternetAddress(broadcastAddress);
// Create broadcast endpoint - Endpoint.broadcast takes only port
final destination = Endpoint.broadcast(port: Port(port));
// Send the broadcast packet
final bytesSent = await sender.send(dataBytes, destination);
// Give a small delay to ensure packet is sent
await Future.delayed(const Duration(milliseconds: 100));
return bytesSent > 0;
} catch (e) {
// Error occurred while sending broadcast package
return false;
} finally {
// Always close the socket
sender?.close();
}
}
/// Send a package to multiple IP addresses
/// Returns a map of IP address to success status
Future<Map<String, bool>> sendToMultipleIps({
required String data,
required List<String> ipAddresses,
required int port,
}) async {
final results = <String, bool>{};
for (final ip in ipAddresses) {
final success = await sendPackage(
data: data,
ipAddress: ip,
port: port,
);
results[ip] = success;
}
return results;
}
/// Send a package with support for broadcast mode and multiple IPs
/// Returns a map of targets (IP addresses or "broadcast") to success status
Future<Map<String, bool>> sendPackageAdvanced({
required String data,
required int port,
bool isBroadcast = false,
List<String>? ipAddresses,
String broadcastAddress = '255.255.255.255',
}) async {
if (isBroadcast) {
// Send broadcast
final success = await sendBroadcast(
data: data,
port: port,
broadcastAddress: broadcastAddress,
);
return {'broadcast': success};
} else if (ipAddresses != null && ipAddresses.isNotEmpty) {
// Send to multiple IPs
return await sendToMultipleIps(
data: data,
ipAddresses: ipAddresses,
port: port,
);
} else {
// Fallback to localhost
final success = await sendPackage(
data: data,
ipAddress: '127.0.0.1',
port: port,
);
return {'127.0.0.1': success};
}
}
}

View File

@ -20,15 +20,18 @@ class _PackageDialogState extends State<PackageDialog> {
late TextEditingController _ipController;
late TextEditingController _dataController;
final _formKey = GlobalKey<FormState>();
late bool _isBroadcast;
late List<String> _ipAddresses;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.package?.name ?? '');
_ipController = TextEditingController(
text: widget.package?.ipAddress ?? '127.0.0.1',
);
_ipController = TextEditingController();
_dataController = TextEditingController(text: widget.package?.data ?? '');
_isBroadcast = widget.package?.isBroadcast ?? false;
_ipAddresses = widget.package?.ipAddresses.toList() ?? ['127.0.0.1'];
}
@override
@ -49,12 +52,62 @@ class _PackageDialogState extends State<PackageDialog> {
return true;
}
void _addIpAddress() {
final ip = _ipController.text.trim();
if (ip.isNotEmpty && _isValidIp(ip)) {
if (!_ipAddresses.contains(ip)) {
setState(() {
_ipAddresses.add(ip);
_ipController.clear();
});
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('IP address already added'),
behavior: SnackBarBehavior.floating,
),
);
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid IP address'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
void _removeIpAddress(String ip) {
setState(() {
_ipAddresses.remove(ip);
// Ensure at least one IP if not in broadcast mode
if (_ipAddresses.isEmpty && !_isBroadcast) {
_ipAddresses.add('127.0.0.1');
}
});
}
void _save() {
if (_formKey.currentState!.validate()) {
// Validate we have IPs if not in broadcast mode
if (!_isBroadcast && _ipAddresses.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please add at least one IP address'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
return;
}
final package = UdpPackage(
id: widget.package?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
name: _nameController.text.trim(),
ipAddress: _ipController.text.trim(),
ipAddresses: _isBroadcast ? [] : _ipAddresses,
isBroadcast: _isBroadcast,
data: _dataController.text,
);
widget.onSave(package);
@ -71,7 +124,9 @@ class _PackageDialogState extends State<PackageDialog> {
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Package Name
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
@ -87,25 +142,103 @@ class _PackageDialogState extends State<PackageDialog> {
autofocus: true,
),
const SizedBox(height: 16),
TextFormField(
controller: _ipController,
decoration: const InputDecoration(
labelText: 'IP Address',
prefixIcon: Icon(Icons.computer),
hintText: '192.168.1.100',
),
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter an IP address';
}
if (!_isValidIp(value.trim())) {
return 'Invalid IP address';
}
return null;
// Broadcast Mode Toggle
SwitchListTile(
title: const Text('Broadcast Mode'),
subtitle: const Text('Send to all devices on network'),
value: _isBroadcast,
onChanged: (value) {
setState(() {
_isBroadcast = value;
});
},
secondary: const Icon(Icons.sensors),
),
const SizedBox(height: 16),
const SizedBox(height: 8),
// IP Addresses Section (only shown when not in broadcast mode)
if (!_isBroadcast) ...[
Text(
'Target IP Addresses',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
// IP Address Input
Row(
children: [
Expanded(
child: TextField(
controller: _ipController,
decoration: const InputDecoration(
labelText: 'IP Address',
prefixIcon: Icon(Icons.computer),
hintText: '192.168.1.100',
isDense: true,
),
keyboardType: TextInputType.number,
onSubmitted: (_) => _addIpAddress(),
),
),
const SizedBox(width: 8),
IconButton.filled(
onPressed: _addIpAddress,
icon: const Icon(Icons.add),
tooltip: 'Add IP',
),
],
),
const SizedBox(height: 12),
// IP Address Chips
if (_ipAddresses.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: _ipAddresses.map((ip) {
return Chip(
label: Text(ip),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: _ipAddresses.length > 1
? () => _removeIpAddress(ip)
: null,
);
}).toList(),
),
const SizedBox(height: 8),
] else ...[
// Broadcast Info
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
Icons.info_outline,
size: 20,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'This package will be broadcast to all devices on your local network',
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
),
],
),
),
const SizedBox(height: 16),
],
// Data Field
TextFormField(
controller: _dataController,
decoration: const InputDecoration(

View File

@ -27,7 +27,7 @@ class _ProjectDialogState extends State<ProjectDialog> {
super.initState();
_nameController = TextEditingController(text: widget.project?.name ?? '');
_ipController = TextEditingController(
text: widget.project?.ipAddress ?? '127.0.0.1',
text: widget.project?.ipAddresses.firstOrNull ?? '127.0.0.1',
);
_portController = TextEditingController(
text: widget.project?.port.toString() ?? '8888',
@ -47,7 +47,8 @@ class _ProjectDialogState extends State<ProjectDialog> {
final project = Project(
id: widget.project?.id ?? DateTime.now().millisecondsSinceEpoch.toString(),
name: _nameController.text.trim(),
ipAddress: _ipController.text.trim(),
ipAddresses: [_ipController.text.trim()],
isBroadcast: false,
port: int.parse(_portController.text),
packages: widget.project?.packages ?? [],
);