diff --git a/lib/constants.dart b/lib/constants.dart index 927f9a8..d398f59 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -17,6 +17,10 @@ class AppConstants { static const add = Icon(Icons.add); static const sort = Icon(Icons.sort); + // Home Ping Timeouts and Intervals for scanning + static const homePingTimeout = 1; + static const homePingInterval = 12; + // Wake Up Dialog Elements static const errorMessageColor = Colors.red; static const successMessageColor = Colors.green; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5ab8c9d..0d05008 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -21,6 +21,8 @@ "homeDeviceListTitle": "Devices", "homeDeviceCardWakeButton": "Wake Up", "homeDeviceCardEditButton": "Edit", + "homeDeviceCardOnline": "Device is online", + "homeDeviceCardOffline": "Device is offline", "homeWolCardTitle": "Waking up...", "@DISCOVER": {}, "discoverTitle": "Discover Devices", diff --git a/lib/screens/home/bottom_sheet_form.dart b/lib/screens/home/bottom_sheet_form.dart index 975174a..e69c9cf 100644 --- a/lib/screens/home/bottom_sheet_form.dart +++ b/lib/screens/home/bottom_sheet_form.dart @@ -15,12 +15,14 @@ import '../../widgets/universal_ui_components.dart'; abstract class ModularBottomFormPage extends StatefulWidget { final String title; final Device device; - final Function(List) onSubmitDeviceCallback; + final List devices; + final Function(List, String?) onSubmitDeviceCallback; final bool deleteButton; ModularBottomFormPage( {Key? key, required this.device, + required this.devices, required this.title, required this.onSubmitDeviceCallback, this.deleteButton = false}) @@ -57,6 +59,7 @@ abstract class ModularBottomFormPage extends StatefulWidget { macAddress: controllerMac.text, modified: DateTime.now(), wolPort: wolPort, + isOnline: storageDevice.isOnline, deviceType: deviceType); } else { return NetworkDevice( @@ -74,14 +77,13 @@ abstract class ModularBottomFormPage extends StatefulWidget { /// dataOperationOnSave() is an abstract method that is implemented in the child classes and is called when the submitButton is pressed /// it saves the device to the json file and returns the updated [StorageDevice] list - Future> dataOperationOnSave(); + Future<(List, StorageDevice)> dataOperationOnSave(); /// dataOperationOnDelete() is triggered when the delete button is pressed and delete a device from the json file and returns the updated [StorageDevice] list Future> dataOperationOnDelete() async { StorageDevice device = getDevice as StorageDevice; - List devices = await deviceStorage.deleteDevice( - device.id, - ); + List devices = + await deviceStorage.deleteDevice(device.id, this.devices); return devices; } @@ -213,10 +215,11 @@ class _ModularBottomFormPageState extends State { onPressed: () => { validateFormFields(onSubmitDeviceCallback: () async { Navigator.popUntil(context, (route) => route.isFirst); - List device = + (List, StorageDevice) updatedDevices = await widget.dataOperationOnSave(); // sent device to callback function in order to update the UI - widget.onSubmitDeviceCallback(device); + widget.onSubmitDeviceCallback( + updatedDevices.$1, updatedDevices.$2.id); }) }, text: AppLocalizations.of(context)!.formApplyButtonText, @@ -529,9 +532,9 @@ class _ModularBottomFormPageState extends State { rightColor: Theme.of(context).colorScheme.error, rightOnPressed: () async { Navigator.popUntil(context, (route) => route.isFirst); - List device = await widget.dataOperationOnDelete(); + List devices = await widget.dataOperationOnDelete(); // sent device to callback function in order to update the UI - widget.onSubmitDeviceCallback(device); + widget.onSubmitDeviceCallback(devices, null); }, ); }, @@ -558,15 +561,15 @@ class NetworkDeviceFormPage extends ModularBottomFormPage { NetworkDeviceFormPage( {super.key, required super.device, + required super.devices, required super.title, required super.onSubmitDeviceCallback}); @override - Future> dataOperationOnSave() async { - List devices = await deviceStorage.addDevice( - getDevice as NetworkDevice, - ); - return devices; + Future<(List, StorageDevice)> dataOperationOnSave() async { + (List, StorageDevice) updatedDevices = + await deviceStorage.addDevice(getDevice as NetworkDevice, devices); + return updatedDevices; } } @@ -576,15 +579,15 @@ class EditDeviceFormPage extends ModularBottomFormPage { {super.key, required super.device, required super.title, + required super.devices, required super.onSubmitDeviceCallback}) : super(deleteButton: true); @override - Future> dataOperationOnSave() async { - List devices = await deviceStorage.updateDevice( - getDevice as StorageDevice, - ); - return devices; + Future<(List, StorageDevice)> dataOperationOnSave() async { + (List, StorageDevice) updatedDevices = + await deviceStorage.updateDevice(getDevice as StorageDevice, devices); + return updatedDevices; } } diff --git a/lib/screens/home/discover.dart b/lib/screens/home/discover.dart index f0c7a34..7341039 100644 --- a/lib/screens/home/discover.dart +++ b/lib/screens/home/discover.dart @@ -10,9 +10,11 @@ import '../../widgets/layout_elements.dart'; import '../../widgets/universal_ui_components.dart'; class DiscoverPage extends StatefulWidget { - final Function(List) updateDevicesList; + final Function(List, String?) updateDevicesList; + final List devices; - const DiscoverPage({Key? key, required this.updateDevicesList}) + const DiscoverPage( + {Key? key, required this.updateDevicesList, required this.devices}) : super(key: key); @override @@ -105,6 +107,7 @@ class _DiscoverPageState extends State { title: AppLocalizations.of(context)!.discoverAddDeviceAlertTitle, device: NetworkDevice(), + devices: widget.devices, onSubmitDeviceCallback: widget.updateDevicesList)), text: AppLocalizations.of(context)!.discoverAddCustomDeviceButton, icon: const Icon(Icons.add)), @@ -167,6 +170,7 @@ class _DiscoverPageState extends State { .discoverAddDeviceAlertTitle, device: _devices[index] .copyWith(wolPort: 9), + devices: widget.devices, onSubmitDeviceCallback: widget.updateDevicesList)), ); @@ -221,6 +225,7 @@ class _DiscoverPageState extends State { builder: (context) => NetworkDeviceFormPage( title: title, device: device.copyWith(wolPort: port), + devices: widget.devices, onSubmitDeviceCallback: widget.updateDevicesList), ); } diff --git a/lib/screens/home/home.dart b/lib/screens/home/home.dart index 67c3ecd..b6ef092 100644 --- a/lib/screens/home/home.dart +++ b/lib/screens/home/home.dart @@ -48,15 +48,44 @@ class _HomePageState extends State { late SortingOrder selectedMenu = widget.selectedMenu; + Timer? _pingDevicesTimer; + @override void initState() { super.initState(); _loadDevices().then((value) => { filterDevicesByType(), sortDevices(), + _pingDevices(), }); } + @override + void dispose() { + _pingDevicesTimer?.cancel(); + super.dispose(); + } + + /// loads a list of devices from the device storage + Future _loadDevices() async { + setState(() { + _isLoading = true; + }); + + try { + final devices = await _deviceStorage.loadDevices(); + setState(() { + _devicesRaw = devices; + }); + } on PlatformException catch (e) { + debugPrint('Failed to load devices: $e'); + } finally { + setState(() { + _isLoading = false; + }); + } + } + /// sort Devices by chipsDeviceTypes selection void filterDevicesByType() { List sortedDevices = []; @@ -102,22 +131,31 @@ class _HomePageState extends State { } } - /// loads a list of devices from the device storage - Future _loadDevices() async { - setState(() { - _isLoading = true; + /// ping devices periodically in the background to get the current status + /// of the devices and update the ui accordingly + void _pingDevices() { + checkAllDevicesStatus(); + _pingDevicesTimer = Timer.periodic( + const Duration(seconds: AppConstants.homePingInterval), (timer) { + checkAllDevicesStatus(); }); + } - try { - final devices = await _deviceStorage.loadDevices(); - setState(() { - _devicesRaw = devices; - }); - } on PlatformException catch (e) { - debugPrint('Failed to load devices: $e'); - } finally { + /// updates the status of all devices in [_devices] + Future checkAllDevicesStatus() async { + for (StorageDevice device in _devices) { + checkDeviceStatus(device); + } + } + + /// ping a device and update the ui accordingly + /// [device] is the device to ping + /// if the widget is not mounted anymore, the function will stop + Future checkDeviceStatus(StorageDevice device) async { + bool isOnline = await pingDevice(ipAddress: device.ipAddress); + if (mounted) { setState(() { - _isLoading = false; + device.isOnline = isOnline; }); } } @@ -170,6 +208,7 @@ class _HomePageState extends State { MaterialPageRoute( builder: (context) => DiscoverPage( updateDevicesList: updateDevicesList, + devices: _devices, )), ); if (newDevice != null) { @@ -184,33 +223,53 @@ class _HomePageState extends State { ); } - updateDevicesList(devices) { + /// callback function for updating the list of devices + /// [devices] is the list of devices + /// [deviceId] is the changed device id. This devices gets pinged additionally to the background timer to get the current status. + /// If it is set to null, no device gets pinged (e.g. if device gets deleted, this devices doesn't need to get pinged) + updateDevicesList(List devices, String? deviceId) { setState(() { - //_devices.add(device); _devicesRaw = devices; filterDevicesByType(); sortDevices(); + if (deviceId != null) { + StorageDevice device = + devices.firstWhere((element) => element.id == deviceId); + // set online state to null because online state is not known yet + device.isOnline = null; + checkDeviceStatus(device); + } }); } Widget buildListview() { - return ListView( - padding: AppConstants.screenPaddingScrollView, - children: [ - TextTitle( - title: AppLocalizations.of(context)!.homeFilterDevicesTitle, - children: [ - SizedBox( - height: 50, - child: filterDevicesChipsV2(), - ), - ], - ), - TextTitle( - title: AppLocalizations.of(context)!.homeDeviceListTitle, - children: [buildDeviceList()], - ), - ], + return RefreshIndicator( + onRefresh: () async { + _pingDevicesTimer?.cancel(); + // set online state for all devices to null because online state is not known yet + for (StorageDevice device in _devices) { + device.isOnline = null; + } + _pingDevices(); + }, + child: ListView( + padding: AppConstants.screenPaddingScrollView, + children: [ + TextTitle( + title: AppLocalizations.of(context)!.homeFilterDevicesTitle, + children: [ + SizedBox( + height: 50, + child: filterDevicesChipsV2(), + ), + ], + ), + TextTitle( + title: AppLocalizations.of(context)!.homeDeviceListTitle, + children: [buildDeviceList()], + ), + ], + ), ); } @@ -325,6 +384,7 @@ class _HomePageState extends State { title = device.ipAddress; } return DeviceCard( + isOnline: device.isOnline, title: title, subtitle: subtitle, deviceType: device.deviceType, @@ -364,15 +424,30 @@ class _HomePageState extends State { required String subtitle2}) { return customDualChoiceAlertdialog( title: title != "" ? title : null, - child: (subtitle1 != "" || subtitle2 != "") + child: (subtitle1 != "" || subtitle2 != "" || device.isOnline != null) ? Column( children: [ + if (device.isOnline != null) + Text( + device.isOnline! + ? AppLocalizations.of(context)!.homeDeviceCardOnline + : AppLocalizations.of(context)! + .homeDeviceCardOffline, + style: TextStyle( + color: device.isOnline! + ? AppConstants.successMessageColor + : Theme.of(context).colorScheme.error)), if (subtitle1 != "") Text(subtitle1), if (subtitle2 != "") Text(subtitle2), ], ) : null, icon: getIcon(device.deviceType), + iconColor: device.isOnline != null + ? device.isOnline! + ? AppConstants.successMessageColor + : Theme.of(context).colorScheme.error + : null, leftText: AppLocalizations.of(context)!.homeDeviceCardWakeButton, rightText: AppLocalizations.of(context)!.homeDeviceCardEditButton, leftIcon: AppConstants.wakeUp, @@ -385,6 +460,7 @@ class _HomePageState extends State { formPage: EditDeviceFormPage( title: "Edit Device", device: device, + devices: _devices, onSubmitDeviceCallback: updateDevicesList)) }); } diff --git a/lib/services/data.dart b/lib/services/data.dart index 834da66..da93e01 100644 --- a/lib/services/data.dart +++ b/lib/services/data.dart @@ -30,6 +30,7 @@ abstract class Device implements Comparable { class StorageDevice extends Device { final String id; final DateTime modified; + bool? isOnline; StorageDevice( {required this.id, @@ -37,6 +38,7 @@ class StorageDevice extends Device { required ipAddress, required macAddress, wolPort, + this.isOnline, required this.modified, deviceType}) : super( @@ -60,6 +62,7 @@ class StorageDevice extends Device { int? wolPort, DateTime? modified, String? deviceType, + bool? isOnline, }) { return StorageDevice( id: id ?? this.id, @@ -69,6 +72,7 @@ class StorageDevice extends Device { wolPort: wolPort ?? this.wolPort, modified: modified ?? this.modified, deviceType: deviceType ?? this.deviceType, + isOnline: isOnline ?? this.isOnline, ); } diff --git a/lib/services/database.dart b/lib/services/database.dart index e2b5b5d..2640720 100644 --- a/lib/services/database.dart +++ b/lib/services/database.dart @@ -39,19 +39,20 @@ class DeviceStorage { /// Adds a new device to the list of devices /// [device] the device to add - Future> addDevice(NetworkDevice device) async { - final devices = await loadDevices(); + Future<(List, StorageDevice)> addDevice( + NetworkDevice device, List devices) async { final storageDevice = device.toStorageDevice(id: const Uuid().v1(), modified: DateTime.now()); final updatedDevices = [...devices, storageDevice]; await saveDevices(updatedDevices); - return updatedDevices; + return (updatedDevices, storageDevice); } /// Updates a device in the list of devices /// [updatedDevice] the device to update - Future> updateDevice(StorageDevice updatedDevice) async { - final devices = await loadDevices(); + /// [devices] the list of all devices + Future<(List, StorageDevice)> updateDevice( + StorageDevice updatedDevice, List devices) async { final updatedDevices = devices.map((device) { if (device.id == updatedDevice.id) { return updatedDevice.copyWith(modified: DateTime.now()); @@ -59,13 +60,13 @@ class DeviceStorage { return device; }).toList(); await saveDevices(updatedDevices); - return updatedDevices; + return (updatedDevices, updatedDevice); } /// Deletes a device from the list of devices /// [deviceId] the id of the device to delete - Future> deleteDevice(String deviceId) async { - final devices = await loadDevices(); + Future> deleteDevice( + String deviceId, List devices) async { final updatedDevices = devices.where((device) => device.id != deviceId).toList(); await saveDevices(updatedDevices); diff --git a/lib/services/network.dart b/lib/services/network.dart index a128ae4..3959648 100644 --- a/lib/services/network.dart +++ b/lib/services/network.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:dart_ping/dart_ping.dart'; +import 'package:simple_wake_on_lan/constants.dart'; import 'dart:io'; import 'package:wake_on_lan/wake_on_lan.dart'; import 'data.dart'; @@ -17,7 +18,7 @@ Stream findDevicesInNetwork( step ips away from the current as long as this ip is still within the subnet */ void pingDevice(int index) async { final address = '$networkPrefix.$index'; - final ping = Ping(address, count: 1, timeout: 1); + final ping = Ping(address, count: 1, timeout: AppConstants.homePingTimeout); // Wait for the current ping to complete await for (final response in ping.stream) { @@ -157,6 +158,19 @@ Stream> sendWolAndGetMessages( } } +/// ping a list of devices and return their status +Future pingDevice({required String ipAddress}) async { + final ping = Ping(ipAddress, count: 1, timeout: 3); + + // Wait for the current ping to complete + await for (final response in ping.stream) { + if (response.response != null && response.error == null) { + return true; + } + } + return false; +} + /// Playground: Test different Discover methods // void findDevicesMDNS() async { diff --git a/lib/widgets/layout_elements.dart b/lib/widgets/layout_elements.dart index 75cd99e..ac7338d 100644 --- a/lib/widgets/layout_elements.dart +++ b/lib/widgets/layout_elements.dart @@ -144,6 +144,7 @@ class DeviceCard extends StatelessWidget { final VoidCallback? onTap; final String? title, subtitle, deviceType; final Widget? trailing; + final bool? isOnline; const DeviceCard( {super.key, @@ -151,7 +152,8 @@ class DeviceCard extends StatelessWidget { this.title, this.subtitle, this.deviceType, - this.trailing}); + this.trailing, + this.isOnline}); @override Widget build(BuildContext context) { @@ -170,8 +172,26 @@ class DeviceCard extends StatelessWidget { leading: deviceType != null && getIcon(deviceType!) != null ? SizedBox( height: double.infinity, - child: Icon( - getIcon(deviceType!), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Icon( + getIcon(deviceType!), + ), + Positioned( + // draw a red marble + top: 12.0, + right: -4.0, + child: Icon(Icons.brightness_1, + size: 12.0, + color: isOnline == null + ? Colors.grey + : isOnline! + ? AppConstants.successMessageColor + : AppConstants.errorMessageColor), + ) + ], )) : null, trailing: trailing, diff --git a/lib/widgets/universal_ui_components.dart b/lib/widgets/universal_ui_components.dart index b9c8cab..6f32fce 100644 --- a/lib/widgets/universal_ui_components.dart +++ b/lib/widgets/universal_ui_components.dart @@ -30,7 +30,7 @@ Widget customDualChoiceAlertdialog( IconData? rightIcon, Function()? leftOnPressed, Function()? rightOnPressed}) { - return customAlertdialog( + return customAlertDialog( title: title, child: child, icon: icon, @@ -72,7 +72,7 @@ Widget customDualChoiceAlertdialog( ); } -Widget customAlertdialog( +Widget customAlertDialog( {String? title, Widget? child, IconData? icon, diff --git a/pubspec.yaml b/pubspec.yaml index 4513185..4b2e143 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,10 +16,10 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.1.0+27 +version: 1.2.0+28 environment: - sdk: '>=2.19.2 <3.0.0' + sdk: '>=3.0.0' # Dependencies specify other packages that your package needs in order to work. # To automatically upgrade your package dependencies to the latest versions