781 lines
30 KiB
Dart
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'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|