Files
UnityUDP/lib/screens/project_screen.dart
2025-10-15 15:22:51 +02:00

781 lines
30 KiB
Dart

import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../models/project.dart';
import '../models/udp_package.dart';
import '../services/udp_service.dart';
import '../widgets/package_dialog.dart';
class ProjectScreen extends StatefulWidget {
final Project project;
const ProjectScreen({super.key, required this.project});
@override
State<ProjectScreen> createState() => _ProjectScreenState();
}
class _ProjectScreenState extends State<ProjectScreen> {
late Project _project;
final UdpService _udpService = UdpService();
String? _lastSentPackageId;
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();
_portController = TextEditingController(text: _project.port.toString());
_projectIpAddresses = _project.ipAddresses.toList();
_projectIsBroadcast = _project.isBroadcast;
}
@override
void dispose() {
_ipController.dispose();
_portController.dispose();
super.dispose();
}
void _addPackage(UdpPackage package) {
setState(() {
_project = _project.copyWith(
packages: [..._project.packages, package],
);
});
}
void _updatePackage(UdpPackage updatedPackage) {
setState(() {
final packages = _project.packages.map((p) {
return p.id == updatedPackage.id ? updatedPackage : p;
}).toList();
_project = _project.copyWith(packages: packages);
});
}
void _deletePackage(String packageId) {
setState(() {
final packages = _project.packages.where((p) => p.id != packageId).toList();
_project = _project.copyWith(packages: packages);
});
}
void _copyPackage(UdpPackage package) {
final copiedPackage = UdpPackage(
id: DateTime.now().millisecondsSinceEpoch.toString(),
name: '${package.name} (Copy)',
data: package.data,
ipAddresses: package.ipAddresses.toList(),
isBroadcast: package.isBroadcast,
);
_addPackage(copiedPackage);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Package copied successfully!'),
behavior: SnackBarBehavior.floating,
),
);
}
bool _isValidIp(String ip) {
final ipPattern = RegExp(r'^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$');
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;
}
}
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('Invalid IP address'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
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,
builder: (context) => PackageDialog(
package: package,
onSave: (newPackage) {
if (package == null) {
_addPackage(newPackage);
} else {
_updatePackage(newPackage);
}
},
),
);
}
Future<void> _sendPackage(UdpPackage package) async {
if (kIsWeb) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('UDP is not supported in web browsers. Please run on desktop or mobile.'),
backgroundColor: Colors.orange,
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 4),
),
);
return;
}
setState(() {
_isSending = true;
});
// 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,
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 (allSuccess) {
_lastSentPackageId = package.id;
// Reset the indicator after 2 seconds
Future.delayed(const Duration(seconds: 2), () {
if (mounted && _lastSentPackageId == package.id) {
setState(() {
_lastSentPackageId = null;
});
}
});
}
});
if (!mounted) return;
// 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(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}"'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
@override
Widget build(BuildContext context) {
return PopScope<Project>(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (!didPop) {
Navigator.of(context).pop(_project);
}
},
child: Scaffold(
appBar: AppBar(
title: Text(_project.name),
),
body: Column(
children: [
// Settings Card
Container(
width: double.infinity,
margin: const EdgeInsets.all(16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.settings,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Project Settings',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
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: _portController,
decoration: const InputDecoration(
labelText: 'UDP Port',
prefixIcon: Icon(Icons.router),
isDense: true,
),
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
onSubmitted: (_) => _updateSettings(),
),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _updateSettings,
icon: const Icon(Icons.check, size: 18),
label: const Text('Apply'),
),
],
),
],
),
),
),
),
// Packages List
Expanded(
child: _project.packages.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Theme.of(context).colorScheme.primary.withAlpha(128),
),
const SizedBox(height: 16),
Text(
'No packages yet',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Create your first UDP package',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
)
: LayoutBuilder(
builder: (context, constraints) {
// Use 2 columns when width is >= 800px, otherwise 1 column
final crossAxisCount = constraints.maxWidth >= 800 ? 2 : 1;
final aspectRatio = constraints.maxWidth >= 800 ? 2.5 : 2.0;
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: aspectRatio,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: _project.packages.length,
itemBuilder: (context, index) {
final package = _project.packages[index];
final wasSent = _lastSentPackageId == package.id;
return Card(
color: wasSent
? Theme.of(context).colorScheme.primaryContainer
: null,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
package.name,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
if (wasSent)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.green,
borderRadius:
BorderRadius.circular(12),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check,
size: 14,
color: Colors.white,
),
SizedBox(width: 4),
Text(
'Sent',
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 4),
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,
),
],
),
),
PopupMenuButton(
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Edit'),
],
),
),
const PopupMenuItem(
value: 'copy',
child: Row(
children: [
Icon(Icons.copy),
SizedBox(width: 8),
Text('Copy'),
],
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete',
style:
TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
_showPackageDialog(package: package);
} else if (value == 'copy') {
_copyPackage(package);
} else if (value == 'delete') {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Package'),
content: Text(
'Are you sure you want to delete "${package.name}"?'),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
_deletePackage(package.id);
Navigator.pop(context);
},
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
child: const Text('Delete'),
),
],
),
);
}
},
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
package.data,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isSending
? null
: () => _sendPackage(package),
icon: _isSending
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.send),
label: Text(_isSending ? 'Sending...' : 'Send'),
),
),
],
),
),
);
},
);
},
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _showPackageDialog(),
icon: const Icon(Icons.add),
label: const Text('New Package'),
),
),
);
}
}