diff --git a/.github/workflows/elastic-ci.yml b/.github/workflows/elastic-ci.yml index af849dfe..3e5a86e7 100644 --- a/.github/workflows/elastic-ci.yml +++ b/.github/workflows/elastic-ci.yml @@ -44,7 +44,7 @@ jobs: run: dart run import_sorter:main --exit-if-changed - name: Analyze project source - run: flutter analyze --no-fatal-infos --no-fatal-warnings + run: flutter analyze --no-fatal-infos test: name: "Run Tests" runs-on: ubuntu-22.04 diff --git a/lib/main.dart b/lib/main.dart index 164ce51c..6c1cec15 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:elastic_dashboard/services/log.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; import 'package:elastic_dashboard/services/nt_widget_builder.dart'; import 'package:elastic_dashboard/services/settings.dart'; +import 'package:elastic_dashboard/services/update_checker.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -216,6 +217,7 @@ class _ElasticState extends State { ntConnection: widget.ntConnection, preferences: widget.preferences, version: widget.version, + updateChecker: UpdateChecker(currentVersion: widget.version), onColorChanged: (color) => setState(() { teamColor = color; widget.preferences.setInt(PrefKeys.teamColor, color.value); diff --git a/lib/pages/dashboard_page.dart b/lib/pages/dashboard_page.dart index 474d66d4..c3a99a41 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -12,6 +12,7 @@ import 'package:elegant_notification/elegant_notification.dart'; import 'package:elegant_notification/resources/stacked_options.dart'; import 'package:file_selector/file_selector.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; +import 'package:path/path.dart' as path; import 'package:popover/popover.dart'; import 'package:screen_retriever/screen_retriever.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -44,6 +45,7 @@ class DashboardPage extends StatefulWidget { final String version; final NTConnection ntConnection; final SharedPreferences preferences; + final UpdateChecker updateChecker; final Function(Color color)? onColorChanged; final Function(FlexSchemeVariant variant)? onThemeVariantChanged; @@ -52,6 +54,7 @@ class DashboardPage extends StatefulWidget { required this.ntConnection, required this.preferences, required this.version, + required this.updateChecker, this.onColorChanged, this.onThemeVariantChanged, }); @@ -62,7 +65,6 @@ class DashboardPage extends StatefulWidget { class _DashboardPageState extends State with WindowListener { late final SharedPreferences preferences = widget.preferences; - late final UpdateChecker _updateChecker; late final RobotNotificationsListener _robotNotificationListener; final List _tabData = []; @@ -72,6 +74,9 @@ class _DashboardPageState extends State with WindowListener { late int _gridSize = preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize; + UpdateCheckerResponse lastUpdateResponse = + UpdateCheckerResponse(updateAvailable: false, error: false); + int _currentTabIndex = 0; bool _addWidgetDialogVisible = false; @@ -79,7 +84,6 @@ class _DashboardPageState extends State with WindowListener { @override void initState() { super.initState(); - _updateChecker = UpdateChecker(currentVersion: widget.version); windowManager.addListener(this); if (!Platform.environment.containsKey('FLUTTER_TEST')) { @@ -315,51 +319,28 @@ class _DashboardPageState extends State with WindowListener { Future _saveLayout() async { Map jsonData = _toJson(); - ColorScheme colorScheme = Theme.of(context).colorScheme; - TextTheme textTheme = Theme.of(context).textTheme; - bool successful = await preferences.setString(PrefKeys.layout, jsonEncode(jsonData)); await _saveWindowPosition(); if (successful) { logger.info('Layout saved successfully!'); - ElegantNotification notification = ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xff01CB67), + _showNotification( + title: 'Saved', + message: 'Layout saved successfully!', + color: const Color(0xff01CB67), + icon: const Icon(Icons.error, color: Color(0xff01CB67)), width: 300, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.check_circle, color: Color(0xff01CB67)), - title: Text('Saved', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: const Text('Layout saved successfully!'), ); - if (mounted) { - notification.show(context); - } } else { logger.error('Could not save layout'); - ElegantNotification notification = ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xffFE355C), - width: 300, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), + _showNotification( + title: 'Error While Saving Layout', + message: 'Failed to save layout, please try again!', + color: const Color(0xffFE355C), icon: const Icon(Icons.error, color: Color(0xffFE355C)), - title: Text('Error', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: const Text('Failed to save layout, please try again!'), + width: 300, ); - if (mounted) { - notification.show(context); - } } } @@ -385,7 +366,11 @@ class _DashboardPageState extends State with WindowListener { ButtonThemeData buttonTheme = ButtonTheme.of(context); UpdateCheckerResponse updateResponse = - await _updateChecker.isUpdateAvailable(); + await widget.updateChecker.isUpdateAvailable(); + + if (mounted) { + setState(() => lastUpdateResponse = updateResponse); + } if (updateResponse.error && notifyIfError) { ElegantNotification notification = ElegantNotification( @@ -428,45 +413,33 @@ class _DashboardPageState extends State with WindowListener { ), icon: const Icon(Icons.info, color: Color(0xff0066FF)), description: const Text('A new update is available!'), - action: Text( - 'Update', - style: textTheme.bodyMedium!.copyWith( - color: buttonTheme.colorScheme?.primary, - fontWeight: FontWeight.bold, - ), - ), - onNotificationPressed: () async { - Uri url = Uri.parse(Settings.releasesLink); + action: TextButton( + onPressed: () async { + Uri url = Uri.parse(Settings.releasesLink); - if (await canLaunchUrl(url)) { - await launchUrl(url); - } - }, + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: Text('Update', + style: textTheme.bodyMedium!.copyWith( + color: buttonTheme.colorScheme?.primary, + fontWeight: FontWeight.bold, + )), + ), ); if (mounted) { notification.show(context); } } else if (updateResponse.onLatestVersion && notifyIfLatest) { - ElegantNotification notification = ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xff01CB67), - width: 350, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), + _showNotification( + title: 'No Updates Available', + message: 'You are running on the latest version of Elastic', + color: const Color(0xff01CB67), icon: const Icon(Icons.check_circle, color: Color(0xff01CB67)), - title: Text('No Updates Available', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: - const Text('You are running on the latest version of Elastic'), + width: 350, ); - - if (mounted) { - notification.show(context); - } } } @@ -571,23 +544,43 @@ class _DashboardPageState extends State with WindowListener { }); } - void _loadLayoutFromJsonData(String jsonString) { - logger.info('Loading layout from json'); - Map? jsonData = tryCast(jsonDecode(jsonString)); - + bool _validateJsonData(Map? jsonData) { if (jsonData == null) { _showJsonLoadingError('Invalid JSON format, aborting.'); - _createDefaultTabs(); - return; + return false; } if (!jsonData.containsKey('tabs')) { _showJsonLoadingError('JSON does not contain necessary data, aborting.'); + return false; + } + + for (Map data in jsonData['tabs']) { + if (tryCast(data['name']) == null) { + _showJsonLoadingError('Tab name not specified'); + return false; + } + + if (tryCast(data['grid_layout']) == null) { + _showJsonLoadingError( + 'Grid layout not specified for tab \'${data['name']}\''); + return false; + } + } + + return true; + } + + void _loadLayoutFromJsonData(String jsonString) { + logger.info('Loading layout from json'); + Map? jsonData = tryCast(jsonDecode(jsonString)); + + if (!_validateJsonData(jsonData)) { _createDefaultTabs(); return; } - if (jsonData.containsKey('grid_size')) { + if (jsonData!.containsKey('grid_size')) { _gridSize = tryCast(jsonData['grid_size']) ?? _gridSize; preferences.setInt(PrefKeys.gridSize, _gridSize); } @@ -595,17 +588,6 @@ class _DashboardPageState extends State with WindowListener { _tabData.clear(); for (Map data in jsonData['tabs']) { - if (tryCast(data['name']) == null) { - _showJsonLoadingWarning('Tab name not specified, ignoring tab data.'); - continue; - } - - if (tryCast(data['grid_layout']) == null) { - _showJsonLoadingWarning( - 'Grid layout not specified for tab \'${data['name']}\', ignoring tab data.'); - continue; - } - _tabData.add( TabData( name: data['name'], @@ -656,55 +638,70 @@ class _DashboardPageState extends State with WindowListener { void _showJsonLoadingError(String errorMessage) { logger.error(errorMessage); Future(() { - ColorScheme colorScheme = Theme.of(context).colorScheme; - TextTheme textTheme = Theme.of(context).textTheme; - int lines = '\n'.allMatches(errorMessage).length + 1; - ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: const Color(0xffFE355C), + _showNotification( + title: 'Error while loading JSON data', + message: errorMessage, + color: const Color(0xffFE355C), + icon: const Icon(Icons.error, color: Color(0xffFE355C)), width: 350, height: 100 + (lines - 1) * 10, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.error, color: Color(0xffFE355C)), - title: Text('Error while loading JSON data', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: Flexible(child: Text(errorMessage)), - ).show(context); + ); }); } void _showJsonLoadingWarning(String warningMessage) { logger.warning(warningMessage); SchedulerBinding.instance.addPostFrameCallback((_) { - ColorScheme colorScheme = Theme.of(context).colorScheme; - TextTheme textTheme = Theme.of(context).textTheme; - int lines = '\n'.allMatches(warningMessage).length + 1; - ElegantNotification( - background: colorScheme.surface, - progressIndicatorBackground: colorScheme.surface, - progressIndicatorColor: Colors.yellow, + _showNotification( + title: 'Warning while loading JSON data', + message: warningMessage, + color: Colors.yellow, + icon: const Icon(Icons.warning, color: Colors.yellow), width: 350, height: 100 + (lines - 1) * 10, - position: Alignment.bottomRight, - toastDuration: const Duration(seconds: 3, milliseconds: 500), - icon: const Icon(Icons.warning, color: Colors.yellow), - title: Text('Warning while loading JSON data', - style: textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.bold, - )), - description: Flexible(child: Text(warningMessage)), - ).show(context); + ); }); } + void _showNotification({ + required String title, + required String message, + required Color color, + required Widget icon, + Duration toastDuration = const Duration(seconds: 3, milliseconds: 500), + double? width, + double? height, + }) { + ColorScheme colorScheme = Theme.of(context).colorScheme; + TextTheme textTheme = Theme.of(context).textTheme; + + ElegantNotification notification = ElegantNotification( + background: colorScheme.surface, + progressIndicatorBackground: colorScheme.surface, + progressIndicatorColor: color, + width: width, + height: height, + position: Alignment.bottomRight, + toastDuration: toastDuration, + icon: icon, + title: Text( + title, + style: textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + description: Flexible(child: Text(message)), + ); + + if (mounted) { + notification.show(context); + } + } + void _setupShortcuts() { logger.info('Setting up shortcuts'); // Import Layout (Ctrl + O) @@ -1177,6 +1174,13 @@ class _DashboardPageState extends State with WindowListener { }, onColorChanged: widget.onColorChanged, onThemeVariantChanged: widget.onThemeVariantChanged, + onOpenAssetsFolderPressed: () async { + Uri uri = Uri.file( + '${path.dirname(Platform.resolvedExecutable)}/data/flutter_assets/assets/'); + if (await canLaunchUrl(uri)) { + launchUrl(uri); + } + }, ), ); } @@ -1423,7 +1427,7 @@ class _DashboardPageState extends State with WindowListener { style: menuButtonStyle, onPressed: !(preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) - ? () => _importLayout() + ? _importLayout : null, shortcut: const SingleActivator(LogicalKeyboardKey.keyO, control: true), @@ -1439,9 +1443,7 @@ class _DashboardPageState extends State with WindowListener { // Save MenuItemButton( style: menuButtonStyle, - onPressed: () { - _saveLayout(); - }, + onPressed: _saveLayout, shortcut: const SingleActivator(LogicalKeyboardKey.keyS, control: true), child: const Row( @@ -1453,13 +1455,10 @@ class _DashboardPageState extends State with WindowListener { ], ), ), - // Export layout MenuItemButton( style: menuButtonStyle, - onPressed: () { - _exportLayout(); - }, + onPressed: _exportLayout, shortcut: const SingleActivator(LogicalKeyboardKey.keyS, shift: true, control: true), child: const Row( @@ -1604,11 +1603,38 @@ class _DashboardPageState extends State with WindowListener { ], ); + final List trailing = [ + if (lastUpdateResponse.updateAvailable) ...[ + const VerticalDivider(), + Tooltip( + message: 'Download version ${lastUpdateResponse.latestVersion}', + child: MenuItemButton( + style: menuButtonStyle.copyWith( + minimumSize: + const WidgetStatePropertyAll(Size(36.0, double.infinity)), + maximumSize: + const WidgetStatePropertyAll(Size(36.0, double.infinity)), + ), + onPressed: () async { + Uri url = Uri.parse(Settings.releasesLink); + + if (await canLaunchUrl(url)) { + await launchUrl(url); + } + }, + child: const Icon(Icons.update, color: Colors.orange), + ), + ), + const VerticalDivider(), + ], + ]; + return Scaffold( appBar: CustomAppBar( titleText: appTitle, onWindowClose: onWindowClose, - menuBar: menuBar, + leading: menuBar, + trailing: trailing, ), body: Focus( autofocus: true, @@ -1924,6 +1950,7 @@ class _AddWidgetDialogState extends State<_AddWidgetDialog> { setState(() => _searchQuery = value), initialText: _searchQuery, allowEmptySubmission: true, + updateOnChanged: true, label: 'Search', ), ), diff --git a/lib/services/log.dart b/lib/services/log.dart index 8343523e..8cdafd3a 100644 --- a/lib/services/log.dart +++ b/lib/services/log.dart @@ -62,6 +62,10 @@ class Log { void debug(dynamic message, [dynamic error, StackTrace? stackTrace]) { log(Level.debug, message, error, stackTrace); } + + void trace(dynamic message, [dynamic error, StackTrace? stackTrace]) { + log(Level.trace, message, error, stackTrace); + } } Log get logger => Log.instance; diff --git a/lib/services/nt_connection.dart b/lib/services/nt_connection.dart index 2c5475de..c3186f40 100644 --- a/lib/services/nt_connection.dart +++ b/lib/services/nt_connection.dart @@ -17,11 +17,12 @@ class NTConnection { final ValueNotifier _ntConnected = ValueNotifier(false); ValueNotifier get ntConnected => _ntConnected; - bool _dsConnected = false; bool get isNT4Connected => _ntConnected.value; - bool get isDSConnected => _dsConnected; + final ValueNotifier _dsConnected = ValueNotifier(false); + bool get isDSConnected => _dsConnected.value; + ValueNotifier get dsConnected => _dsConnected; DSInteropClient get dsClient => _dsClient; int get serverTime => _ntClient.getServerTimeUS(); @@ -70,8 +71,8 @@ class NTConnection { _dsClient = DSInteropClient( onNewIPAnnounced: onIPAnnounced, onDriverStationDockChanged: onDriverStationDockChanged, - onConnect: () => _dsConnected = true, - onDisconnect: () => _dsConnected = false, + onConnect: () => _dsConnected.value = true, + onDisconnect: () => _dsConnected.value = false, ); } @@ -132,19 +133,6 @@ class NTConnection { } } - Stream dsConnectionStatus() async* { - yield _dsConnected; - bool lastYielded = _dsConnected; - - while (true) { - if (_dsConnected != lastYielded) { - yield _dsConnected; - lastYielded = _dsConnected; - } - await Future.delayed(const Duration(seconds: 1)); - } - } - Map announcedTopics() { return _ntClient.announcedTopics; } diff --git a/lib/services/nt_widget_builder.dart b/lib/services/nt_widget_builder.dart index eaf53600..8ac79a52 100644 --- a/lib/services/nt_widget_builder.dart +++ b/lib/services/nt_widget_builder.dart @@ -484,6 +484,14 @@ class NTWidgetBuilder { return DraggableWidgetContainer.snapToGrid(_normalSize, gridSize); } + static bool isRegistered(String name) { + ensureInitialized(); + + return (_modelNameBuildMap.containsKey(name) && + _modelJsonBuildMap.containsKey(name)) || + _widgetNameBuildMap.containsKey(name); + } + static void register({ required String name, diff --git a/lib/services/settings.dart b/lib/services/settings.dart index 60945529..6fc1efc2 100644 --- a/lib/services/settings.dart +++ b/lib/services/settings.dart @@ -17,16 +17,19 @@ class Defaults { static IPAddressMode ipAddressMode = IPAddressMode.driverStation; static FlexSchemeVariant themeVariant = FlexSchemeVariant.material3Legacy; - static const String defaultVariantName = 'Material-3 Legacy (Default)'; + static const String defaultVariantName = 'Material-3 Legacy (Default)'; static const String ipAddress = '127.0.0.1'; + static const int teamNumber = 9999; static const int gridSize = 128; + static const bool layoutLocked = false; - static const double cornerRadius = 15.0; static const bool showGrid = true; static const bool autoResizeToDS = false; + static const bool showOpenAssetsFolderWarning = true; + static const double cornerRadius = 15.0; static const double defaultPeriod = 0.06; static const double defaultGraphPeriod = 0.033; } @@ -46,6 +49,6 @@ class PrefKeys { static String rememberWindowPosition = 'remember_window_position'; static String defaultPeriod = 'default_period'; static String defaultGraphPeriod = 'default_graph_period'; - + static String showOpenAssetsFolderWarning = "show_assets_folder_warning"; static String windowPosition = 'window_position'; } diff --git a/lib/widgets/custom_appbar.dart b/lib/widgets/custom_appbar.dart index bc58151c..4709e0ee 100644 --- a/lib/widgets/custom_appbar.dart +++ b/lib/widgets/custom_appbar.dart @@ -8,26 +8,28 @@ import 'package:elastic_dashboard/services/settings.dart'; class CustomAppBar extends AppBar { final String titleText; final Color? appBarColor; - final MenuBar menuBar; final VoidCallback? onWindowClose; + final List trailing; + static const double _leadingSize = 500; static const ThemeType buttonType = ThemeType.materia; - CustomAppBar( - {super.key, - this.titleText = 'Elastic', - this.appBarColor, - this.onWindowClose, - required this.menuBar}) - : super( + CustomAppBar({ + super.key, + this.titleText = 'Elastic', + this.appBarColor, + this.onWindowClose, + this.trailing = const [], + required super.leading, + }) : super( toolbarHeight: 36, backgroundColor: appBarColor ?? const Color.fromARGB(255, 25, 25, 25), elevation: 0.0, scrolledUnderElevation: 0.0, - leading: menuBar, leadingWidth: _leadingSize, centerTitle: true, + notificationPredicate: (_) => false, actions: [ SizedBox( width: _leadingSize, @@ -37,6 +39,7 @@ class CustomAppBar extends AppBar { const Expanded( child: _WindowDragArea(), ), + ...trailing.map((e) => ExcludeFocus(child: e)), InkWell( canRequestFocus: false, onTap: () async => await windowManager.minimize(), diff --git a/lib/widgets/dialog_widgets/dialog_color_picker.dart b/lib/widgets/dialog_widgets/dialog_color_picker.dart index 187c7004..be99e546 100644 --- a/lib/widgets/dialog_widgets/dialog_color_picker.dart +++ b/lib/widgets/dialog_widgets/dialog_color_picker.dart @@ -8,12 +8,14 @@ class DialogColorPicker extends StatefulWidget { final String label; final Color initialColor; final Color? defaultColor; + final MainAxisSize rowSize; const DialogColorPicker({ super.key, required this.onColorPicked, required this.label, required this.initialColor, + this.rowSize = MainAxisSize.min, this.defaultColor, }); @@ -39,7 +41,7 @@ class _DialogColorPickerState extends State { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, + mainAxisSize: widget.rowSize, children: [ Text(widget.label), const SizedBox(width: 5), diff --git a/lib/widgets/dialog_widgets/dialog_text_input.dart b/lib/widgets/dialog_widgets/dialog_text_input.dart index 8113ea18..401e165d 100644 --- a/lib/widgets/dialog_widgets/dialog_text_input.dart +++ b/lib/widgets/dialog_widgets/dialog_text_input.dart @@ -9,6 +9,7 @@ class DialogTextInput extends StatefulWidget { final bool allowEmptySubmission; final bool autoFocus; final bool enabled; + final bool updateOnChanged; final TextEditingController? textEditingController; @@ -22,6 +23,7 @@ class DialogTextInput extends StatefulWidget { this.formatter, this.textEditingController, this.autoFocus = false, + this.updateOnChanged = false, }); @override @@ -61,6 +63,12 @@ class _DialogTextInputState extends State { autofocus: widget.autoFocus, enabled: widget.enabled, onChanged: (value) { + if (widget.updateOnChanged && + (value.isNotEmpty || widget.allowEmptySubmission)) { + widget.onSubmit.call(value); + } + }, + onSubmitted: (value) { if (value.isNotEmpty || widget.allowEmptySubmission) { widget.onSubmit.call(value); focused = false; diff --git a/lib/widgets/draggable_containers/draggable_widget_container.dart b/lib/widgets/draggable_containers/draggable_widget_container.dart index 740747af..618bdbac 100644 --- a/lib/widgets/draggable_containers/draggable_widget_container.dart +++ b/lib/widgets/draggable_containers/draggable_widget_container.dart @@ -54,29 +54,29 @@ class DraggableWidgetContainer extends StatelessWidget { ); }, onDragStart: (event) { - model.setDragging(true); - model.setPreviewVisible(true); - model.setDraggingIntoLayout(false); - model.setDragStartLocation(model.displayRect); - model.setPreviewRect(model.dragStartLocation); - model.setValidLocation( + model.dragging = true; + model.previewVisible = true; + model.draggingIntoLayout = false; + model.dragStartLocation = model.displayRect; + model.previewRect = model.dragStartLocation; + model.validLocation = updateFunctions?.isValidMoveLocation(model, model.previewRect) ?? - true); + true; updateFunctions?.onDragBegin(model); controller?.setRect(model.draggingRect); }, onResizeStart: (handle, event) { - model.setDragging(true); - model.setResizing(true); - model.setPreviewVisible(true); - model.setDraggingIntoLayout(false); - model.setDragStartLocation(model.displayRect); - model.setPreviewRect(model.dragStartLocation); - model.setValidLocation( + model.dragging = true; + model.resizing = true; + model.previewVisible = true; + model.draggingIntoLayout = false; + model.dragStartLocation = model.displayRect; + model.previewRect = model.dragStartLocation; + model.validLocation = updateFunctions?.isValidMoveLocation(model, model.previewRect) ?? - true); + true; updateFunctions?.onResizeBegin.call(model); @@ -88,7 +88,7 @@ class DraggableWidgetContainer extends StatelessWidget { return; } - model.setCursorGlobalLocation(event.globalPosition); + model.cursorGlobalLocation = event.globalPosition; updateFunctions?.onUpdate(model, result.rect, result); @@ -98,7 +98,7 @@ class DraggableWidgetContainer extends StatelessWidget { if (!model.dragging) { return; } - model.setDragging(false); + model.dragging = false; updateFunctions?.onDragEnd(model, model.draggingRect, globalPosition: model.cursorGlobalLocation); @@ -107,7 +107,7 @@ class DraggableWidgetContainer extends StatelessWidget { }, onDragCancel: () { Future(() { - model.setDragging(false); + model.dragging = false; }); updateFunctions?.onDragCancel(model); @@ -118,16 +118,16 @@ class DraggableWidgetContainer extends StatelessWidget { if (!model.dragging && !model.resizing) { return; } - model.setDragging(false); - model.setResizing(false); + model.dragging = false; + model.resizing = false; updateFunctions?.onResizeEnd(model, model.draggingRect); controller?.setRect(model.draggingRect); }, onResizeCancel: (handle) { - model.setDragging(false); - model.setResizing(false); + model.dragging = false; + model.resizing = false; updateFunctions?.onDragCancel(model); diff --git a/lib/widgets/draggable_containers/models/layout_container_model.dart b/lib/widgets/draggable_containers/models/layout_container_model.dart index 7e61d707..56f5c4c4 100644 --- a/lib/widgets/draggable_containers/models/layout_container_model.dart +++ b/lib/widgets/draggable_containers/models/layout_container_model.dart @@ -2,7 +2,6 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; -import 'nt_widget_container_model.dart'; import 'widget_container_model.dart'; abstract class LayoutContainerModel extends WidgetContainerModel { @@ -41,5 +40,7 @@ abstract class LayoutContainerModel extends WidgetContainerModel { bool willAcceptWidget(WidgetContainerModel widget, {Offset? globalPosition}); - void addWidget(NTWidgetContainerModel model); + void addWidget(WidgetContainerModel widget); + + void removeWidget(WidgetContainerModel widget); } diff --git a/lib/widgets/draggable_containers/models/list_layout_model.dart b/lib/widgets/draggable_containers/models/list_layout_model.dart index 66676671..85b0d722 100644 --- a/lib/widgets/draggable_containers/models/list_layout_model.dart +++ b/lib/widgets/draggable_containers/models/list_layout_model.dart @@ -14,10 +14,15 @@ import 'package:elastic_dashboard/widgets/dialog_widgets/dialog_text_input.dart' import 'package:elastic_dashboard/widgets/draggable_containers/draggable_widget_container.dart'; import 'package:elastic_dashboard/widgets/draggable_containers/models/layout_container_model.dart'; import 'package:elastic_dashboard/widgets/nt_widgets/nt_widget.dart'; -import 'package:elastic_dashboard/widgets/tab_grid.dart'; import 'nt_widget_container_model.dart'; import 'widget_container_model.dart'; +typedef DragOutFunctions = ({ + bool Function(WidgetContainerModel widget) dragOutEnd, + void Function( + WidgetContainerModel widget, Offset globalPosition) dragOutUpdate +}); + class ListLayoutModel extends LayoutContainerModel { @override String type = 'List Layout'; @@ -26,7 +31,7 @@ class ListLayoutModel extends LayoutContainerModel { String labelPosition = 'TOP'; - final TabGridModel tabGrid; + final DragOutFunctions? dragOutFunctions; final NTWidgetContainerModel? Function( SharedPreferences preferences, Map jsonData, @@ -48,7 +53,7 @@ class ListLayoutModel extends LayoutContainerModel { required super.preferences, required super.initialPosition, required super.title, - required this.tabGrid, + this.dragOutFunctions, required this.onDragCancel, this.ntWidgetBuilder, List? children, @@ -65,7 +70,7 @@ class ListLayoutModel extends LayoutContainerModel { required super.jsonData, required super.preferences, required this.ntWidgetBuilder, - required this.tabGrid, + this.dragOutFunctions, required this.onDragCancel, super.enabled, super.minWidth, @@ -160,12 +165,12 @@ class ListLayoutModel extends LayoutContainerModel { } @override - void setEnabled(bool enabled) { + set enabled(bool enabled) { for (var container in children) { - container.setEnabled(enabled); + container.enabled = enabled; } - super.setEnabled(enabled); + super.enabled = enabled; } @override @@ -300,7 +305,7 @@ class ListLayoutModel extends LayoutContainerModel { DialogTextInput( onSubmit: (value) { setState(() { - container.setTitle(value); + container.title = value; notifyListeners(); }); @@ -332,8 +337,14 @@ class ListLayoutModel extends LayoutContainerModel { } @override - void addWidget(NTWidgetContainerModel model) { - children.add(model); + void addWidget(WidgetContainerModel widget) { + children.add(widget as NTWidgetContainerModel); + notifyListeners(); + } + + @override + void removeWidget(WidgetContainerModel widget) { + children.remove(widget); notifyListeners(); } @@ -473,13 +484,11 @@ class ListLayoutModel extends LayoutContainerModel { } widget.cursorGlobalLocation = details.globalPosition; - Future(() { - if (dragging || resizing) { - onDragCancel?.call(this); - } + if (dragging || resizing) { + onDragCancel?.call(this); + } - setDraggable(false); - }); + draggable = false; }, onPanUpdate: (details) { if (preferences.getBool(PrefKeys.layoutLocked) ?? @@ -491,48 +500,32 @@ class ListLayoutModel extends LayoutContainerModel { Offset location = details.globalPosition - Offset(widget.displayRect.width, widget.displayRect.height) / 2; - tabGrid.layoutDragOutUpdate(widget, location); + dragOutFunctions?.dragOutUpdate(widget, location); }, onPanEnd: (details) { if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - Future(() => setDraggable(true)); - - int? gridSize = preferences.getInt(PrefKeys.gridSize); - - Rect previewLocation = Rect.fromLTWH( - DraggableWidgetContainer.snapToGrid( - widget.draggingRect.left, gridSize), - DraggableWidgetContainer.snapToGrid( - widget.draggingRect.top, gridSize), - widget.displayRect.width, - widget.displayRect.height, - ); - - if ((tabGrid.isValidMoveLocation(widget, previewLocation) || - tabGrid - .isValidLayoutLocation(widget.cursorGlobalLocation)) && - tabGrid.isDraggingInContainer()) { + + if (dragOutFunctions?.dragOutEnd(widget) ?? false) { children.remove(widget); notifyListeners(); } - tabGrid.layoutDragOutEnd(widget); + draggable = true; }, onPanCancel: () { if (preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked) { return; } - Future(() { - if (dragging || resizing) { - onDragCancel?.call(this); - } - setDraggable(true); - }); + if (dragging || resizing) { + onDragCancel?.call(this); + } + + draggable = true; }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2.5, vertical: 2.5), diff --git a/lib/widgets/draggable_containers/models/widget_container_model.dart b/lib/widgets/draggable_containers/models/widget_container_model.dart index 51847716..498e0da8 100644 --- a/lib/widgets/draggable_containers/models/widget_container_model.dart +++ b/lib/widgets/draggable_containers/models/widget_container_model.dart @@ -12,67 +12,165 @@ abstract class WidgetContainerModel extends ChangeNotifier { final Key key = UniqueKey(); final SharedPreferences preferences; - String? title; + String? _title; - late bool draggable = + String? get title => _title; + + set title(String? value) { + _title = value; + notifyListeners(); + } + + late bool _draggable = !(preferences.getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked); + + bool get draggable => _draggable; + + set draggable(bool value) { + _draggable = value; + notifyListeners(); + } + bool _disposed = false; bool _forceDispose = false; - late Rect draggingRect = Rect.fromLTWH( + late Rect _draggingRect = Rect.fromLTWH( 0, 0, (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(), (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble()); - Offset cursorGlobalLocation = const Offset(double.nan, double.nan); + Rect get draggingRect => _draggingRect; + + set draggingRect(Rect value) { + _draggingRect = value; + notifyListeners(); + } + + Offset _cursorGlobalLocation = const Offset(double.nan, double.nan); - late Rect displayRect = Rect.fromLTWH( + Offset get cursorGlobalLocation => _cursorGlobalLocation; + + set cursorGlobalLocation(Offset value) { + _cursorGlobalLocation = value; + notifyListeners(); + } + + late Rect _displayRect = Rect.fromLTWH( 0, 0, (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(), (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble()); - late Rect previewRect = Rect.fromLTWH( + Rect get displayRect => _displayRect; + + set displayRect(Rect value) { + _displayRect = value; + notifyListeners(); + } + + late Rect _previewRect = Rect.fromLTWH( 0, 0, (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(), (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble()); - bool enabled = false; - bool dragging = false; - bool resizing = false; - bool draggingIntoLayout = false; - bool previewVisible = false; - bool validLocation = true; + Rect get previewRect => _previewRect; + + set previewRect(Rect value) { + _previewRect = value; + notifyListeners(); + } + + bool _enabled = false; + + bool get enabled => _enabled; + + set enabled(bool value) { + _enabled = value; + notifyListeners(); + } + + bool _dragging = false; + + bool get dragging => _dragging; + + set dragging(bool value) { + _dragging = value; + notifyListeners(); + } + + bool _resizing = false; + + bool get resizing => _resizing; + + set resizing(bool value) { + _resizing = value; + notifyListeners(); + } + + bool _draggingIntoLayout = false; + + bool get draggingIntoLayout => _draggingIntoLayout; + + set draggingIntoLayout(bool value) { + _draggingIntoLayout = value; + notifyListeners(); + } + + bool _previewVisible = false; + + bool get previewVisible => _previewVisible; + + set previewVisible(bool value) { + _previewVisible = value; + notifyListeners(); + } + + bool _validLocation = true; + + bool get validLocation => _validLocation; + + set validLocation(bool value) { + _validLocation = value; + notifyListeners(); + } late double minWidth = (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(); late double minHeight = (preferences.getInt(PrefKeys.gridSize) ?? Defaults.gridSize).toDouble(); - late Rect dragStartLocation; + late Rect _dragStartLocation; + + Rect get dragStartLocation => _dragStartLocation; + + set dragStartLocation(Rect value) { + _dragStartLocation = value; + notifyListeners(); + } WidgetContainerModel({ required this.preferences, required Rect initialPosition, - required this.title, - this.enabled = false, + required String? title, + bool enabled = false, this.minWidth = 128.0, this.minHeight = 128.0, - }) { - displayRect = initialPosition; + }) : _title = title, + _enabled = enabled { + _displayRect = initialPosition; init(); } WidgetContainerModel.fromJson({ required Map jsonData, required this.preferences, - this.enabled = false, + bool enabled = false, this.minWidth = 128.0, this.minHeight = 128.0, Function(String errorMessage)? onJsonLoadingWarning, - }) { + }) : _enabled = enabled { fromJson(jsonData); init(); } @@ -159,72 +257,6 @@ abstract class WidgetContainerModel extends ChangeNotifier { notifyListeners(); } - void setTitle(String title) { - this.title = title; - - notifyListeners(); - } - - void setDraggable(bool draggable) { - this.draggable = draggable; - notifyListeners(); - } - - void setDragging(bool dragging) { - this.dragging = dragging; - notifyListeners(); - } - - void setResizing(bool resizing) { - this.resizing = resizing; - notifyListeners(); - } - - void setPreviewVisible(bool previewVisible) { - this.previewVisible = previewVisible; - notifyListeners(); - } - - void setValidLocation(bool validLocation) { - this.validLocation = validLocation; - notifyListeners(); - } - - void setDraggingIntoLayout(bool draggingIntoLayout) { - this.draggingIntoLayout = draggingIntoLayout; - notifyListeners(); - } - - void setEnabled(bool enabled) { - this.enabled = enabled; - notifyListeners(); - } - - void setDisplayRect(Rect displayRect) { - this.displayRect = displayRect; - notifyListeners(); - } - - void setDraggingRect(Rect draggingRect) { - this.draggingRect = draggingRect; - notifyListeners(); - } - - void setPreviewRect(Rect previewRect) { - this.previewRect = previewRect; - notifyListeners(); - } - - void setDragStartLocation(Rect dragStartLocation) { - this.dragStartLocation = dragStartLocation; - notifyListeners(); - } - - void setCursorGlobalLocation(Offset globalLocation) { - cursorGlobalLocation = globalLocation; - notifyListeners(); - } - void showEditProperties(BuildContext context) { showDialog( context: context, @@ -261,7 +293,7 @@ abstract class WidgetContainerModel extends ChangeNotifier { const SizedBox(height: 5), DialogTextInput( onSubmit: (value) { - setTitle(value); + title = value; }, label: 'Title', initialText: title, diff --git a/lib/widgets/editable_tab_bar.dart b/lib/widgets/editable_tab_bar.dart index 5fa89342..cfb71d58 100644 --- a/lib/widgets/editable_tab_bar.dart +++ b/lib/widgets/editable_tab_bar.dart @@ -172,6 +172,7 @@ class EditableTabBar extends StatelessWidget { }, ); }, + // The tab itself child: AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutExpo, @@ -199,12 +200,14 @@ class EditableTabBar extends StatelessWidget { : theme.colorScheme.onPrimaryContainer, ), ), + // Spacing for close button Visibility( visible: !(preferences .getBool(PrefKeys.layoutLocked) ?? Defaults.layoutLocked), child: const SizedBox(width: 10), ), + // Close button Visibility( visible: !(preferences .getBool(PrefKeys.layoutLocked) ?? @@ -235,6 +238,7 @@ class EditableTabBar extends StatelessWidget { ), ), const SizedBox(width: 16), + // Tab movement buttons (move left, close, move right) Row( children: [ IconButton( diff --git a/lib/widgets/mjpeg.dart b/lib/widgets/mjpeg.dart index fc6c1dca..3fa17c61 100644 --- a/lib/widgets/mjpeg.dart +++ b/lib/widgets/mjpeg.dart @@ -4,32 +4,10 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:http/http.dart'; import 'package:visibility_detector/visibility_detector.dart'; -class _MjpegStateNotifier extends ChangeNotifier { - bool _mounted = true; - bool _visible = true; - - _MjpegStateNotifier() : super(); - - bool get mounted => _mounted; - - bool get visible => _visible; - - set visible(value) { - _visible = value; - notifyListeners(); - } - - @override - void dispose() { - _mounted = false; - notifyListeners(); - super.dispose(); - } -} +import 'package:elastic_dashboard/services/log.dart'; /// A preprocessor for each JPEG frame from an MJPEG stream. class MjpegPreprocessor { @@ -37,107 +15,132 @@ class MjpegPreprocessor { } /// An Mjpeg. -class Mjpeg extends HookWidget { - final streamKey = UniqueKey(); - final MjpegStreamState mjpegStream; +class Mjpeg extends StatefulWidget { + final MjpegController controller; final BoxFit? fit; + final bool expandToFit; final double? width; final double? height; final WidgetBuilder? loading; final Widget Function(BuildContext contet, dynamic error, dynamic stack)? error; - Mjpeg({ - required this.mjpegStream, + const Mjpeg({ + required this.controller, this.width, this.height, this.fit, + this.expandToFit = false, this.error, this.loading, super.key, }); + @override + State createState() => _MjpegState(); +} + +class _MjpegState extends State { + final streamKey = UniqueKey(); + + late void Function() listener; + + @override + void initState() { + listener = () => setState(() {}); + widget.controller.addListener(listener); + super.initState(); + } + + @override + void dispose() { + widget.controller.removeListener(listener); + + widget.controller.setMounted(streamKey, false); + widget.controller.setVisible(streamKey, false); + + super.dispose(); + } + + @override + void didUpdateWidget(Mjpeg oldWidget) { + final controller = widget.controller; + final oldController = oldWidget.controller; + + if (oldController != controller) { + oldController.removeListener(listener); + controller.addListener(listener); + + controller.setMounted(streamKey, oldController.isMounted(streamKey)); + controller.setVisible(streamKey, oldController.isVisible(streamKey)); + } + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { - final image = useState(null); - final state = useMemoized(() => _MjpegStateNotifier()); - final visible = useListenable(state); - final errorState = useState?>(null); - isMounted() => context.mounted; - - final manager = useMemoized( - () => _StreamManager( - mjpegStream: mjpegStream, - mounted: isMounted, - visible: () => visible.visible, - ), - [ - visible.visible, - isMounted(), - mjpegStream, - ]); - - final key = useMemoized(() => UniqueKey(), [manager]); - - useEffect(() { - errorState.value = null; - manager.updateStream(streamKey, image, errorState); - return () { - if (visible.visible && isMounted()) { - return; - } - mjpegStream.cancelSubscription(streamKey); - }; - }, [manager]); + final controller = widget.controller; + + controller.setMounted(streamKey, context.mounted); + + if (controller.isVisible(streamKey)) { + controller.startStream(); + } - if (errorState.value != null && kDebugMode) { + if (controller.errorState.value != null && kDebugMode) { return SizedBox( - width: width, - height: height, - child: error == null + width: widget.width, + height: widget.height, + child: widget.error == null ? Center( child: Padding( padding: const EdgeInsets.all(8.0), child: Text( - '${errorState.value}', + '${controller.errorState.value}', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red), ), ), ) - : error!(context, errorState.value!.first, errorState.value!.last), + : widget.error!(context, controller.errorState.value!.first, + controller.errorState.value!.last), ); } - if ((image.value == null && mjpegStream.previousImage == null) || - errorState.value != null) { - return SizedBox( - width: width, - height: height, - child: loading == null - ? const Center(child: CircularProgressIndicator()) - : loading!(context)); - } - return VisibilityDetector( - key: key, - child: Image( - image: image.value ?? mjpegStream.previousImage!, - width: width, - height: height, - gaplessPlayback: true, - fit: fit, - ), + key: streamKey, + child: StreamBuilder?>( + stream: controller.imageStream.stream, + builder: (context, snapshot) { + if ((snapshot.data == null && controller.previousImage == null) || + controller.errorState.value != null) { + return SizedBox( + width: widget.width, + height: widget.height, + child: widget.loading?.call(context) ?? + const Center(child: CircularProgressIndicator()), + ); + } + + return Image.memory( + Uint8List.fromList(snapshot.data ?? controller.previousImage!), + width: widget.width, + height: widget.height, + gaplessPlayback: true, + fit: widget.fit, + scale: (widget.expandToFit) ? 1e-6 : 1.0, + ); + }), onVisibilityChanged: (VisibilityInfo info) { - if (visible.mounted) { - visible.visible = info.visibleFraction != 0; + if (controller.isMounted(streamKey)) { + controller.setVisible(streamKey, info.visibleFraction != 0); } }, ); } } -class MjpegStreamState { +class MjpegController extends ChangeNotifier { static const _trigger = 0xFF; static const _soi = 0xD8; static const _eoi = 0xD9; @@ -147,220 +150,188 @@ class MjpegStreamState { final Duration timeout; final Map headers; Client httpClient = Client(); - Stream>? byteStream; + + StreamSubscription>? _rawSubscription; + + ValueNotifier bandwidth = ValueNotifier(0); + ValueNotifier framesPerSecond = ValueNotifier(0); + + Timer? _metricsTimer; + + int _bitCount = 0; + int _frameCount = 0; + + ValueNotifier?> errorState = ValueNotifier(null); + StreamController?> imageStream = StreamController.broadcast(); + List? previousImage; final MjpegPreprocessor? preprocessor; - MemoryImage? previousImage; + final Set _mountedKeys = {}; + final Set _visibleKeys = {}; + + bool isVisible(Key key) => _visibleKeys.contains(key); - final Map _subscriptions = {}; + void setVisible(Key key, bool value) { + logger.trace('Setting visibility to $value for $stream'); + if (value) { + bool hasChanged = !_visibleKeys.contains(key); + _visibleKeys.add(key); - StreamSubscription? _bitSubscription; - int bitCount = 0; - double bandwidth = 0.0; + if (hasChanged) { + logger.trace( + 'Visibility changed to true, notifying listeners for mjpeg stream'); + notifyListeners(); + } + } else { + _visibleKeys.remove(key); + + if (_visibleKeys.isEmpty) { + stopStream(); + } + } + } - late final Timer bandwidthTimer; + bool isMounted(Key key) => _mountedKeys.contains(key); - MjpegStreamState({ + void setMounted(Key key, bool value) { + logger.trace('Setting mounted to $value for $stream'); + if (value) { + _mountedKeys.add(key); + } else { + _mountedKeys.remove(key); + } + } + + bool get isStreaming => _rawSubscription != null; + + MjpegController({ required this.stream, this.isLive = true, this.timeout = const Duration(seconds: 5), this.headers = const {}, this.preprocessor, }) { - bandwidthTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - bandwidth = bitCount / 1e6; + errorState.addListener(notifyListeners); + } - bitCount = 0; - }); + @override + void dispose() { + errorState.removeListener(notifyListeners); + stopStream(); + imageStream.close(); + super.dispose(); } - void dispose({bool deleting = false}) { - for (StreamSubscription subscription in _subscriptions.values) { - subscription.cancel(); + void startStream() async { + if (isStreaming) { + return; + } + logger.debug('Starting camera stream on URL $stream'); + Stream>? byteStream; + try { + final request = Request('GET', Uri.parse(stream)); + request.headers.addAll(headers); + final response = await httpClient.send(request).timeout( + timeout); //timeout is to prevent process to hang forever in some case + + if (response.statusCode >= 200 && response.statusCode < 300) { + byteStream = response.stream; + } else { + if (_mountedKeys.isNotEmpty) { + errorState.value = [ + HttpException('Stream returned ${response.statusCode} status'), + StackTrace.current + ]; + imageStream.add(null); + } + stopStream(); + } + } catch (error, stack) { + // we ignore those errors in case play/pause is triggers + if (!error + .toString() + .contains('Connection closed before full header was received')) { + if (_mountedKeys.isNotEmpty) { + errorState.value = [error, stack]; + imageStream.add(null); + } + } } - _subscriptions.clear(); - _bitSubscription?.cancel(); - _bitSubscription = null; - byteStream = null; - httpClient.close(); - httpClient = Client(); - bitCount = 0; - if (deleting) { - bandwidthTimer.cancel(); + if (byteStream == null) { + return; } - } - void cancelSubscription(Key key) { - if (_subscriptions.containsKey(key)) { - _subscriptions.remove(key)!.cancel(); + var buffer = []; + _rawSubscription = byteStream.listen((data) { + _bitCount += data.length * Uint8List.bytesPerElement * 8; + _handleData(buffer, data); + }); - if (_subscriptions.isEmpty) { - dispose(); - } - } + _metricsTimer = Timer.periodic(const Duration(seconds: 1), _updateMetrics); } - void sendImage( - ValueNotifier image, - ValueNotifier errorState, - List chunks, { - required bool Function() mounted, - }) async { - // pass image through preprocessor sending to [Image] for rendering - final List? imageData; - - if (preprocessor != null) { - imageData = preprocessor?.process(chunks); - } else { - imageData = chunks; - } + void _updateMetrics(_) { + bandwidth.value = _bitCount / 1e6; + framesPerSecond.value = _frameCount; - if (imageData == null) return; + _bitCount = 0; + _frameCount = 0; + } - final imageMemory = MemoryImage(Uint8List.fromList(imageData)); - previousImage?.evict(); - previousImage = imageMemory; - if (mounted()) { - errorState.value = null; - image.value = imageMemory; - } + void stopStream() async { + logger.debug('Stopping camera stream on URL $stream'); + await _rawSubscription?.cancel(); + _metricsTimer?.cancel(); + _rawSubscription = null; + _bitCount = 0; + _frameCount = 0; + httpClient.close(); + httpClient = Client(); } - void _onDataReceived({ - required List carry, - required List chunk, - required ValueNotifier image, - required ValueNotifier?> errorState, - required bool Function() mounted, - }) async { - if (carry.isNotEmpty && carry.last == _trigger) { - if (chunk.first == _eoi) { - carry.add(chunk.first); - sendImage(image, errorState, carry, mounted: mounted); - carry = []; + void _handleNewPacket(List packet) { + logger.trace('Handling a ${packet.length} byte packet'); + previousImage = packet; + List imageData = preprocessor?.process(packet) ?? packet; + imageStream.add(imageData); + _frameCount++; + } + + void _handleData(List buffer, List data) { + if (buffer.isNotEmpty && buffer.last == _trigger) { + if (data.first == _eoi) { + buffer.add(data.first); + _handleNewPacket(buffer); + buffer = []; if (!isLive) { dispose(); } } } - - for (var i = 0; i < chunk.length - 1; i++) { - final d = chunk[i]; - final d1 = chunk[i + 1]; + for (var i = 0; i < data.length - 1; i++) { + final d = data[i]; + final d1 = data[i + 1]; if (d == _trigger && d1 == _soi) { - carry = []; - carry.add(d); - } else if (d == _trigger && d1 == _eoi && carry.isNotEmpty) { - carry.add(d); - carry.add(d1); - - sendImage(image, errorState, carry, mounted: mounted); - carry = []; + buffer = []; + buffer.add(d); + } else if (d == _trigger && d1 == _eoi && buffer.isNotEmpty) { + buffer.add(d); + buffer.add(d1); + + _handleNewPacket(buffer); + buffer = []; if (!isLive) { dispose(); } - } else if (carry.isNotEmpty) { - carry.add(d); - if (i == chunk.length - 2) { - carry.add(d1); - } - } - } - } - - void updateStream( - Key key, - ValueNotifier image, - ValueNotifier?> errorState, { - required bool Function() visible, - required bool Function() mounted, - }) async { - if (byteStream == null && visible() && mounted()) { - try { - final request = Request('GET', Uri.parse(stream)); - request.headers.addAll(headers); - final response = await httpClient.send(request).timeout( - timeout); //timeout is to prevent process to hang forever in some case - - if (response.statusCode >= 200 && response.statusCode < 300) { - byteStream = response.stream.asBroadcastStream(); - - _bitSubscription = byteStream!.listen((data) { - bitCount += data.length * Uint8List.bytesPerElement * 8; - }); - } else { - if (mounted()) { - errorState.value = [ - HttpException('Stream returned ${response.statusCode} status'), - StackTrace.current - ]; - image.value = null; - } - dispose(); - } - } catch (error, stack) { - // we ignore those errors in case play/pause is triggers - if (!error - .toString() - .contains('Connection closed before full header was received')) { - if (mounted()) { - errorState.value = [error, stack]; - image.value = null; - } + } else if (buffer.isNotEmpty) { + buffer.add(d); + if (i == buffer.length - 2) { + buffer.add(d1); } } } - - if (byteStream == null) { - return; - } - - var carry = []; - _subscriptions.putIfAbsent( - key, - () => byteStream!.listen((chunk) { - if (!visible() || !mounted()) { - carry.clear(); - return; - } - _onDataReceived( - carry: carry, - chunk: chunk, - image: image, - errorState: errorState, - mounted: mounted, - ); - }, onError: (error, stack) { - try { - if (mounted()) { - errorState.value = [error, stack]; - image.value = null; - } - } finally { - dispose(); - } - }, cancelOnError: true)); - } -} - -class _StreamManager { - final MjpegStreamState mjpegStream; - - final bool Function() mounted; - final bool Function() visible; - - _StreamManager({ - required this.mjpegStream, - required this.mounted, - required this.visible, - }); - - void updateStream(Key key, ValueNotifier image, - ValueNotifier?> errorState) async { - mjpegStream.updateStream(key, image, errorState, - visible: visible, mounted: mounted); } } diff --git a/lib/widgets/network_tree/networktables_tree.dart b/lib/widgets/network_tree/networktables_tree.dart index 927d702c..4289e920 100644 --- a/lib/widgets/network_tree/networktables_tree.dart +++ b/lib/widgets/network_tree/networktables_tree.dart @@ -212,7 +212,7 @@ class _NetworkTableTreeState extends State { } } -class TreeTile extends StatelessWidget { +class TreeTile extends StatefulWidget { final SharedPreferences preferences; final TreeEntry entry; final VoidCallback onTap; @@ -223,9 +223,7 @@ class TreeTile extends StatelessWidget { onDragUpdate; final Function(WidgetContainerModel widget)? onDragEnd; - WidgetContainerModel? draggingWidget; - - TreeTile({ + const TreeTile({ super.key, required this.preferences, required this.entry, @@ -235,6 +233,23 @@ class TreeTile extends StatelessWidget { this.onDragEnd, }); + @override + State createState() => _TreeTileState(); +} + +class _TreeTileState extends State { + WidgetContainerModel? draggingWidget; + bool dragging = false; + + @override + void dispose() { + draggingWidget?.unSubscribe(); + draggingWidget?.disposeModel(deleting: true); + draggingWidget?.forceDispose(); + + super.dispose(); + } + @override Widget build(BuildContext context) { TextStyle trailingStyle = @@ -245,7 +260,7 @@ class TreeTile extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ InkWell( - onTap: onTap, + onTap: widget.onTap, child: GestureDetector( supportedDevices: PointerDeviceKind.values .whereNot((element) => element == PointerDeviceKind.trackpad) @@ -254,9 +269,17 @@ class TreeTile extends StatelessWidget { if (draggingWidget != null) { return; } + dragging = true; + + draggingWidget = await widget.entry.node.toWidgetContainerModel( + listLayoutBuilder: widget.listLayoutBuilder); + if (!dragging) { + draggingWidget?.unSubscribe(); + draggingWidget?.disposeModel(deleting: true); + draggingWidget?.forceDispose(); - draggingWidget = await entry.node.toWidgetContainerModel( - listLayoutBuilder: listLayoutBuilder); + draggingWidget = null; + } }, onPanUpdate: (details) { if (draggingWidget == null) { @@ -272,37 +295,45 @@ class TreeTile extends StatelessWidget { ) / 2; - onDragUpdate?.call(position, draggingWidget!); + widget.onDragUpdate?.call(position, draggingWidget!); }, onPanEnd: (details) { if (draggingWidget == null) { + dragging = false; return; } - onDragEnd?.call(draggingWidget!); + widget.onDragEnd?.call(draggingWidget!); draggingWidget = null; + + dragging = false; }, child: Padding( - padding: EdgeInsetsDirectional.only(start: entry.level * 16.0), + padding: EdgeInsetsDirectional.only( + start: widget.entry.level * 16.0), child: Column( children: [ ListTile( dense: true, contentPadding: const EdgeInsets.only(right: 20.0), - leading: (entry.hasChildren || - entry.node.containsOnlyMetadata()) + leading: (widget.entry.hasChildren || + widget.entry.node.containsOnlyMetadata()) ? FolderButton( openedIcon: const Icon(Icons.arrow_drop_down), closedIcon: const Icon(Icons.arrow_right), iconSize: 24, - isOpen: entry.hasChildren && entry.isExpanded, - onPressed: entry.hasChildren ? onTap : null, + isOpen: widget.entry.hasChildren && + widget.entry.isExpanded, + onPressed: widget.entry.hasChildren + ? widget.onTap + : null, ) : const SizedBox(width: 8.0), - title: Text(entry.node.rowName), - trailing: (entry.node.ntTopic != null) - ? Text(entry.node.ntTopic!.type, style: trailingStyle) + title: Text(widget.entry.node.rowName), + trailing: (widget.entry.node.ntTopic != null) + ? Text(widget.entry.node.ntTopic!.type, + style: trailingStyle) : null, ), ], diff --git a/lib/widgets/network_tree/networktables_tree_row.dart b/lib/widgets/network_tree/networktables_tree_row.dart index f0af2870..9f70a48e 100644 --- a/lib/widgets/network_tree/networktables_tree_row.dart +++ b/lib/widgets/network_tree/networktables_tree_row.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; @@ -183,7 +184,8 @@ class NetworkTableTreeRow { } Future getTypeString(String typeTopic) async { - return ntConnection.subscribeAndRetrieveData(typeTopic); + return ntConnection.subscribeAndRetrieveData(typeTopic, + timeout: const Duration(milliseconds: 500)); } Future? getTypedWidget(String typeTopic) async { @@ -202,24 +204,18 @@ class NetworkTableTreeRow { } Future?> getListLayoutChildren() async { - List listChildren = []; - for (NetworkTableTreeRow child in children) { - if (child.rowName.startsWith('.')) { - continue; - } - WidgetContainerModel? childModel = - await child.toWidgetContainerModel(resortToListLayout: false); + Iterable> childrenFutures = children + .whereNot((e) => e.rowName.startsWith('.')) + .map((e) => e.toWidgetContainerModel(resortToListLayout: false)); - if (childModel is NTWidgetContainerModel) { - listChildren.add(childModel); - } - } + Iterable listChildren = + (await Future.wait(childrenFutures)).whereType(); if (listChildren.isEmpty) { return null; } - return listChildren; + return listChildren.toList(); } Future toWidgetContainerModel({ @@ -228,13 +224,17 @@ class NetworkTableTreeRow { }) async { NTWidgetModel? primary = await getPrimaryWidget(); - if (primary == null) { - if (resortToListLayout) { + if (primary == null || !NTWidgetBuilder.isRegistered(primary.type)) { + primary?.unSubscribe(); + primary?.disposeWidget(deleting: true); + primary?.forceDispose(); + + if (resortToListLayout && listLayoutBuilder != null) { List? listLayoutChildren = await getListLayoutChildren(); if (listLayoutChildren != null) { - return listLayoutBuilder?.call( + return listLayoutBuilder.call( title: rowName, children: listLayoutChildren, ); @@ -246,6 +246,9 @@ class NetworkTableTreeRow { NTWidget? widget = NTWidgetBuilder.buildNTWidgetFromModel(primary); if (widget == null) { + primary.unSubscribe(); + primary.disposeWidget(deleting: true); + primary.forceDispose(); return null; } diff --git a/lib/widgets/nt_widgets/multi-topic/camera_stream.dart b/lib/widgets/nt_widgets/multi-topic/camera_stream.dart index 96646eb0..56336c04 100644 --- a/lib/widgets/nt_widgets/multi-topic/camera_stream.dart +++ b/lib/widgets/nt_widgets/multi-topic/camera_stream.dart @@ -25,13 +25,7 @@ class CameraStreamModel extends MultiTopicNTWidgetModel { int? _fps; Size? _resolution; - MemoryImage? _lastDisplayedImage; - - MjpegStreamState? mjpegStream; - - MemoryImage? get lastDisplayedImage => _lastDisplayedImage; - - set lastDisplayedImage(value) => _lastDisplayedImage = value; + MjpegController? controller; int? get quality => _quality; @@ -91,7 +85,12 @@ class CameraStreamModel extends MultiTopicNTWidgetModel { .toList(); if (resolution != null && resolution.length > 1) { - _resolution = Size(resolution[0].toDouble(), resolution[1].toDouble()); + if (resolution[0] % 2 != 0) { + resolution[0] += 1; + } + if (resolution[0] > 0 && resolution[1] > 0) { + _resolution = Size(resolution[0].toDouble(), resolution[1].toDouble()); + } } } @@ -164,7 +163,12 @@ class CameraStreamModel extends MultiTopicNTWidgetModel { return; } - resolution = Size(newWidth.toDouble(), + if (newWidth! % 2 != 0) { + // Won't allow += for some reason + newWidth = newWidth! + 1; + } + + resolution = Size(newWidth!.toDouble(), resolution?.height.toDouble() ?? 0); }); }, @@ -232,19 +236,15 @@ class CameraStreamModel extends MultiTopicNTWidgetModel { @override void disposeWidget({bool deleting = false}) { if (deleting) { - _lastDisplayedImage?.evict(); - mjpegStream?.previousImage?.evict(); - mjpegStream?.dispose(deleting: deleting); + controller?.dispose(); } super.disposeWidget(deleting: deleting); } void closeClient() { - _lastDisplayedImage?.evict(); - _lastDisplayedImage = mjpegStream?.previousImage; - mjpegStream?.dispose(); - mjpegStream = null; + controller?.dispose(); + controller = null; } } @@ -281,13 +281,11 @@ class CameraStreamWidget extends NTWidget { return Stack( fit: StackFit.expand, children: [ - if (model.mjpegStream?.previousImage != null || - model.lastDisplayedImage != null) + if (model.controller?.previousImage != null) Opacity( opacity: 0.35, - child: Image( - image: model.mjpegStream?.previousImage ?? - model.lastDisplayedImage!, + child: Image.memory( + Uint8List.fromList(model.controller!.previousImage!), fit: BoxFit.contain, ), ), @@ -309,28 +307,47 @@ class CameraStreamWidget extends NTWidget { ); } - bool createNewWidget = model.mjpegStream == null; + bool createNewWidget = model.controller == null; String stream = model.getUrlWithParameters(streams.last); createNewWidget = - createNewWidget || (model.mjpegStream?.stream != stream); + createNewWidget || (model.controller?.stream != stream); if (createNewWidget) { - model.lastDisplayedImage?.evict(); - model.mjpegStream?.dispose(deleting: true); + model.controller?.dispose(); - model.mjpegStream = MjpegStreamState(stream: stream); + model.controller = MjpegController(stream: stream); } - return Stack( - fit: StackFit.expand, - children: [ - Mjpeg( - mjpegStream: model.mjpegStream!, - fit: BoxFit.contain, - ), - ], + return IntrinsicWidth( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + ValueListenableBuilder( + valueListenable: model.controller!.framesPerSecond, + builder: (context, value, child) => Text('FPS: $value'), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: model.controller!.bandwidth, + builder: (context, value, child) => + Text('Bandwidth: ${value.toStringAsFixed(2)} Mbps'), + ), + ], + ), + Flexible( + child: Mjpeg( + controller: model.controller!, + fit: BoxFit.contain, + expandToFit: true, + ), + ), + const Text(''), + ], + ), ); }, ); diff --git a/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart b/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart index e1cdf611..ae5aae3d 100644 --- a/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart +++ b/lib/widgets/nt_widgets/multi-topic/combo_box_chooser.dart @@ -46,7 +46,6 @@ class ComboBoxChooserModel extends MultiTopicNTWidgetModel { StringChooserData? previousData; NT4Topic? _selectedTopic; - NT4Topic? _activeTopic; bool _sortOptions = false; @@ -124,26 +123,6 @@ class ComboBoxChooserModel extends MultiTopicNTWidgetModel { ntConnection.updateDataFromTopic(_selectedTopic!, selected); } - - void _publishActiveValue(String? active) { - if (active == null || !ntConnection.isNT4Connected) { - return; - } - - bool publishTopic = _activeTopic == null; - - _activeTopic ??= ntConnection.getTopicFromName(activeTopicName); - - if (_activeTopic == null) { - return; - } - - if (publishTopic) { - ntConnection.publishTopic(_activeTopic!); - } - - ntConnection.updateDataFromTopic(_activeTopic!, active); - } } class ComboBoxChooser extends NTWidget { diff --git a/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart b/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart index 8764b13a..ee8f2bf7 100644 --- a/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart +++ b/lib/widgets/nt_widgets/multi-topic/split_button_chooser.dart @@ -34,7 +34,6 @@ class SplitButtonChooserModel extends MultiTopicNTWidgetModel { StringChooserData? previousData; NT4Topic? _selectedTopic; - NT4Topic? _activeTopic; SplitButtonChooserModel({ required super.ntConnection, @@ -78,26 +77,6 @@ class SplitButtonChooserModel extends MultiTopicNTWidgetModel { ntConnection.updateDataFromTopic(_selectedTopic!, selected); } - - void _publishActiveValue(String? active) { - if (active == null || !ntConnection.isNT4Connected) { - return; - } - - bool publishTopic = _activeTopic == null; - - _activeTopic ??= ntConnection.getTopicFromName(activeTopicName); - - if (_activeTopic == null) { - return; - } - - if (publishTopic) { - ntConnection.publishTopic(_activeTopic!); - } - - ntConnection.updateDataFromTopic(_activeTopic!, active); - } } class SplitButtonChooser extends NTWidget { diff --git a/lib/widgets/settings_dialog.dart b/lib/widgets/settings_dialog.dart index a998bdf8..7ebbe2cd 100644 --- a/lib/widgets/settings_dialog.dart +++ b/lib/widgets/settings_dialog.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:collection/collection.dart'; -import 'package:dot_cast/dot_cast.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -40,25 +39,26 @@ class SettingsDialog extends StatefulWidget { final Function(String? value)? onDefaultPeriodChanged; final Function(String? value)? onDefaultGraphPeriodChanged; final Function(FlexSchemeVariant variant)? onThemeVariantChanged; + final Function()? onOpenAssetsFolderPressed; - const SettingsDialog({ - super.key, - required this.ntConnection, - required this.preferences, - this.onTeamNumberChanged, - this.onIPAddressModeChanged, - this.onIPAddressChanged, - this.onColorChanged, - this.onGridToggle, - this.onGridSizeChanged, - this.onCornerRadiusChanged, - this.onResizeToDSChanged, - this.onRememberWindowPositionChanged, - this.onLayoutLock, - this.onDefaultPeriodChanged, - this.onDefaultGraphPeriodChanged, - this.onThemeVariantChanged, - }); + const SettingsDialog( + {super.key, + required this.ntConnection, + required this.preferences, + this.onTeamNumberChanged, + this.onIPAddressModeChanged, + this.onIPAddressChanged, + this.onColorChanged, + this.onGridToggle, + this.onGridSizeChanged, + this.onCornerRadiusChanged, + this.onResizeToDSChanged, + this.onRememberWindowPositionChanged, + this.onLayoutLock, + this.onDefaultPeriodChanged, + this.onDefaultGraphPeriodChanged, + this.onThemeVariantChanged, + this.onOpenAssetsFolderPressed}); @override State createState() => _SettingsDialogState(); @@ -70,37 +70,82 @@ class _SettingsDialogState extends State { return AlertDialog( title: const Text('Settings'), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), - content: Container( - constraints: const BoxConstraints( - maxHeight: 350, - maxWidth: 725, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ..._generalSettings(), - const Divider(), - ..._gridSettings(), + content: DefaultTabController( + length: 3, + child: SizedBox( + width: 450, + height: 400, + child: Column( + children: [ + const TabBar( + tabs: [ + Tab( + icon: Icon( + Icons.wifi_outlined, + ), + child: Text('Network'), + ), + Tab( + icon: Icon( + Icons.color_lens_outlined, + ), + child: Text('Appearance'), + ), + Tab( + icon: Icon( + Icons.code, + ), + child: Text('Developer'), + ), ], ), - ), - const VerticalDivider(), - Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ..._ipAddressSettings(), - const Divider(), - ..._networkTablesSettings(), - ], + const SizedBox(height: 10), + Expanded( + child: TabBarView( + children: [ + // Network Tab + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ..._ipAddressSettings(), + const Divider(), + ..._networkTablesSettings(), + ], + ), + ), + // Style Preferences Tab + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 350), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + ..._themeSettings(), + const Divider(), + ..._gridSettings(), + ], + ), + ), + ), + ), + // Advanced Settings Tab + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Column( + children: [ + ..._advancedSettings(), + ], + ), + ), + ], + ), ), - ), - ], + ], + ), ), ), actions: [ @@ -112,7 +157,46 @@ class _SettingsDialogState extends State { ); } - List _generalSettings() { + List _advancedSettings() { + return [ + Row( + children: [ + const Icon(Icons.warning, color: Colors.yellow), + const SizedBox(width: 5), + Flexible( + child: Text( + 'WARNING: These are advanced settings that could cause issues if changed incorrectly. It is advised to not change anything here unless if you know what you are doing.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 3, + ), + ), + const SizedBox(width: 5), + const Icon( + Icons.warning, + color: Colors.yellow, + ), + ], + ), + const Divider(), + Row( + children: [ + TextButton.icon( + onPressed: () { + widget.onOpenAssetsFolderPressed?.call(); + }, + icon: const Icon(Icons.folder_outlined), + label: const Text('Open Assets Folder'), + ), + const Spacer(), + ], + ), + ]; + } + + List _themeSettings() { Color currentColor = Color(widget.preferences.getInt(PrefKeys.teamColor) ?? Colors.blueAccent.value); @@ -129,60 +213,54 @@ class _SettingsDialogState extends State { } return [ - Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Row( - children: [ - Expanded( - child: DialogTextInput( - initialText: widget.preferences - .getInt(PrefKeys.teamNumber) - ?.toString() ?? - Defaults.teamNumber.toString(), - label: 'Team Number', - onSubmit: (data) async { - await widget.onTeamNumberChanged?.call(data); - setState(() {}); - }, - formatter: FilteringTextInputFormatter.digitsOnly, - ), - ), - Expanded( + const Align( + alignment: Alignment.topLeft, + child: Text('Theme Settings'), + ), + IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Flexible( + flex: 2, + child: UnconstrainedBox( + constrainedAxis: Axis.horizontal, child: DialogColorPicker( onColorPicked: (color) => widget.onColorChanged?.call(color), label: 'Team Color', initialColor: currentColor, defaultColor: Colors.blueAccent, + rowSize: MainAxisSize.max, ), ), - ], - ), - Row( - children: [ - const Text('Theme Variant'), - const SizedBox(width: 5), - Flexible( - child: DialogDropdownChooser( - onSelectionChanged: (variantName) { - if (variantName == null) return; - FlexSchemeVariant variant = FlexSchemeVariant.values - .firstWhereOrNull( - (e) => e.variantName == variantName) ?? - FlexSchemeVariant.material3Legacy; + ), + const VerticalDivider(), + Flexible( + flex: 4, + child: Column( + children: [ + const Text('Theme Variant'), + DialogDropdownChooser( + onSelectionChanged: (variantName) { + if (variantName == null) return; + FlexSchemeVariant variant = FlexSchemeVariant.values + .firstWhereOrNull( + (e) => e.variantName == variantName) ?? + FlexSchemeVariant.material3Legacy; - widget.onThemeVariantChanged?.call(variant); - setState(() {}); - }, - choices: - themeVariantsOverride ?? SettingsDialog.themeVariants, - initialValue: - widget.preferences.getString(PrefKeys.themeVariant) ?? - Defaults.defaultVariantName), + widget.onThemeVariantChanged?.call(variant); + setState(() {}); + }, + choices: + themeVariantsOverride ?? SettingsDialog.themeVariants, + initialValue: + widget.preferences.getString(PrefKeys.themeVariant) ?? + Defaults.defaultVariantName), + ], ), - ], - ), - ], + ), + ], + ), ), ]; } @@ -191,46 +269,73 @@ class _SettingsDialogState extends State { return [ const Align( alignment: Alignment.topLeft, - child: Text('IP Address Settings'), + child: Text('Connection Settings'), ), const SizedBox(height: 5), - const Text('IP Address Mode'), - DialogDropdownChooser( - onSelectionChanged: (mode) { - if (mode == null) { - return; - } - - widget.onIPAddressModeChanged?.call(mode); - - setState(() {}); - }, - choices: IPAddressMode.values, - initialValue: IPAddressMode.fromIndex( - widget.preferences.getInt(PrefKeys.ipAddressMode)), + Row( + children: [ + Flexible( + flex: 2, + child: DialogTextInput( + initialText: + widget.preferences.getInt(PrefKeys.teamNumber)?.toString() ?? + Defaults.teamNumber.toString(), + label: 'Team Number', + onSubmit: (data) async { + await widget.onTeamNumberChanged?.call(data); + setState(() {}); + }, + formatter: FilteringTextInputFormatter.digitsOnly, + ), + ), + Flexible( + flex: 3, + child: ValueListenableBuilder( + valueListenable: widget.ntConnection.dsConnected, + builder: (context, connected, child) { + return DialogTextInput( + enabled: widget.preferences.getInt(PrefKeys.ipAddressMode) == + IPAddressMode.custom.index || + (widget.preferences.getInt(PrefKeys.ipAddressMode) == + IPAddressMode.driverStation.index && + !connected), + initialText: + widget.preferences.getString(PrefKeys.ipAddress) ?? + Defaults.ipAddress, + label: 'IP Address', + onSubmit: (String? data) async { + await widget.onIPAddressChanged?.call(data); + setState(() {}); + }, + ); + }, + ), + ), + ], ), const SizedBox(height: 5), - StreamBuilder( - stream: widget.ntConnection.dsConnectionStatus(), - initialData: widget.ntConnection.isDSConnected, - builder: (context, snapshot) { - bool dsConnected = tryCast(snapshot.data) ?? false; + Row( + children: [ + const Text('IP Address Mode'), + const SizedBox(width: 5), + Flexible( + child: DialogDropdownChooser( + onSelectionChanged: (mode) { + if (mode == null) { + return; + } + + widget.onIPAddressModeChanged?.call(mode); - return DialogTextInput( - enabled: widget.preferences.getInt(PrefKeys.ipAddressMode) == - IPAddressMode.custom.index || - (widget.preferences.getInt(PrefKeys.ipAddressMode) == - IPAddressMode.driverStation.index && - !dsConnected), - initialText: widget.preferences.getString(PrefKeys.ipAddress) ?? - Defaults.ipAddress, - label: 'IP Address', - onSubmit: (String? data) async { - await widget.onIPAddressChanged?.call(data); setState(() {}); }, - ); - }) + choices: IPAddressMode.values, + initialValue: IPAddressMode.fromIndex( + widget.preferences.getInt(PrefKeys.ipAddressMode)), + ), + ), + ], + ), ]; } diff --git a/lib/widgets/tab_grid.dart b/lib/widgets/tab_grid.dart index a6ea6186..8ece0484 100644 --- a/lib/widgets/tab_grid.dart +++ b/lib/widgets/tab_grid.dart @@ -54,6 +54,10 @@ class TabGridModel extends ChangeNotifier { if (jsonData['layouts'] != null) { loadLayoutsFromJson(jsonData, onJsonLoadingWarning: onJsonLoadingWarning); } + + for (WidgetContainerModel model in _widgetModels) { + model.addListener(notifyListeners); + } } void loadContainersFromJson(Map jsonData, @@ -96,7 +100,10 @@ class TabGridModel extends ChangeNotifier { onJsonLoadingWarning: onJsonLoadingWarning, ), enabled: ntConnection.isNT4Connected, - tabGrid: this, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), onDragCancel: _layoutContainerOnDragCancel, minWidth: 128.0 * 2, minHeight: 128.0 * 2, @@ -201,16 +208,16 @@ class TabGridModel extends ChangeNotifier { void onWidgetResizeEnd(WidgetContainerModel model) { if (model.validLocation) { - model.setDraggingRect(model.previewRect); + model.draggingRect = model.previewRect; } else { - model.setDraggingRect(model.dragStartLocation); + model.draggingRect = model.dragStartLocation; } - model.setDisplayRect(model.draggingRect); + model.displayRect = model.draggingRect; - model.setPreviewRect(model.draggingRect); - model.setPreviewVisible(false); - model.setValidLocation(true); + model.previewRect = model.draggingRect; + model.previewVisible = false; + model.validLocation = true; if (model is NTWidgetContainerModel && model.childModel is FieldWidgetModel) { @@ -222,16 +229,16 @@ class TabGridModel extends ChangeNotifier { void onWidgetDragEnd(WidgetContainerModel model) { if (model.validLocation) { - model.setDraggingRect(model.previewRect); + model.draggingRect = model.previewRect; } else { - model.setDraggingRect(model.dragStartLocation); + model.draggingRect = model.dragStartLocation; } - model.setDisplayRect(model.draggingRect); + model.displayRect = model.draggingRect; - model.setPreviewRect(model.draggingRect); - model.setPreviewVisible(false); - model.setValidLocation(true); + model.previewRect = model.draggingRect; + model.previewVisible = false; + model.validLocation = true; model.disposeModel(deleting: false); } @@ -241,16 +248,16 @@ class TabGridModel extends ChangeNotifier { return; } - model.setDraggingRect(model.dragStartLocation); - model.setDisplayRect(model.draggingRect); - model.setPreviewRect(model.draggingRect); + model.draggingRect = model.dragStartLocation; + model.displayRect = model.draggingRect; + model.previewRect = model.draggingRect; - model.setPreviewVisible(false); - model.setValidLocation(true); + model.previewVisible = false; + model.validLocation = true; - model.setDragging(false); - model.setResizing(false); - model.setDraggingIntoLayout(false); + model.dragging = false; + model.resizing = false; + model.draggingIntoLayout = false; model.disposeModel(); } @@ -333,37 +340,31 @@ class TabGridModel extends ChangeNotifier { Rect preview = Rect.fromLTWH(previewX, previewY, previewWidth, previewHeight); - model.setDraggingRect(constrainedRect); - model.setPreviewRect(preview); - model.setPreviewVisible(true); + model.draggingRect = constrainedRect; + model.previewRect = preview; + model.previewVisible = true; bool validLocation = isValidMoveLocation(model, preview); if (validLocation) { - model.setValidLocation(true); - - model.setDraggingIntoLayout(false); + model.validLocation = true; + model.draggingIntoLayout = false; } else { validLocation = isValidLayoutLocation(model.cursorGlobalLocation) && model is! LayoutContainerModel && !model.resizing; - model.setDraggingIntoLayout(validLocation); - - model.setValidLocation(validLocation); + model.draggingIntoLayout = validLocation; + model.validLocation = validLocation; } } void _ntContainerOnUpdate( WidgetContainerModel widget, Rect newRect, TransformResult result) { onWidgetUpdate(widget, newRect, result); - - refresh(); } - void _ntContainerOnDragBegin(WidgetContainerModel widget) { - refresh(); - } + void _ntContainerOnDragBegin(WidgetContainerModel widget) {} void _ntContainerOnDragEnd(WidgetContainerModel model, Rect releaseRect, {Offset? globalPosition}) { @@ -377,117 +378,92 @@ class TabGridModel extends ChangeNotifier { if (layoutModel != null) { layoutModel.addWidget(ntContainer); _widgetModels.remove(ntContainer); + ntContainer.removeListener(notifyListeners); } } - refresh(); } void _ntContainerOnDragCancel(WidgetContainerModel widget) { onWidgetDragCancel(widget); - - refresh(); } - void _ntContainerOnResizeBegin(WidgetContainerModel widget) { - refresh(); - } + void _ntContainerOnResizeBegin(WidgetContainerModel widget) {} void _ntContainerOnResizeEnd(WidgetContainerModel widget, Rect releaseRect) { onWidgetResizeEnd(widget); - - refresh(); } void _layoutContainerOnUpdate( WidgetContainerModel widget, Rect newRect, TransformResult result) { onWidgetUpdate(widget, newRect, result); - - refresh(); } - void _layoutContainerOnDragBegin(WidgetContainerModel widget) { - refresh(); - } + void _layoutContainerOnDragBegin(WidgetContainerModel widget) {} void _layoutContainerOnDragEnd(WidgetContainerModel widget, Rect releaseRect, {Offset? globalPosition}) { onWidgetDragEnd(widget); - - refresh(); } void _layoutContainerOnDragCancel(WidgetContainerModel widget) { onWidgetDragCancel(widget); - - refresh(); } - void _layoutContainerOnResizeBegin(WidgetContainerModel widget) { - refresh(); - } + void _layoutContainerOnResizeBegin(WidgetContainerModel widget) {} void _layoutContainerOnResizeEnd( WidgetContainerModel widget, Rect releaseRect) { onWidgetResizeEnd(widget); - - refresh(); } - void layoutDragOutEnd(WidgetContainerModel widget) { + bool layoutDragOutEnd(WidgetContainerModel widget) { if (widget is NTWidgetContainerModel) { - placeDragInWidget(widget, true); + return placeDragInWidget(widget, true); } + return false; } void layoutDragOutUpdate(WidgetContainerModel model, Offset globalPosition) { Offset localPosition = getLocalPosition(globalPosition); - model.setDraggingRect( - Rect.fromLTWH( - localPosition.dx, - localPosition.dy, - model.draggingRect.width, - model.draggingRect.height, - ), + model.draggingRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + model.draggingRect.width, + model.draggingRect.height, ); _containerDraggingIn = MapEntry(model, globalPosition); - refresh(); + notifyListeners(); } void onNTConnect() { for (WidgetContainerModel model in _widgetModels) { - model.setEnabled(true); + model.enabled = true; } - - refresh(); } void onNTDisconnect() { for (WidgetContainerModel container in _widgetModels) { - container.setEnabled(false); + container.enabled = false; } - - refresh(); } void addDragInWidget(WidgetContainerModel widget, Offset globalPosition) { Offset localPosition = getLocalPosition(globalPosition); - widget.setDraggingRect( - Rect.fromLTWH( - localPosition.dx, - localPosition.dy, - widget.draggingRect.width, - widget.draggingRect.height, - ), + widget.draggingRect = Rect.fromLTWH( + localPosition.dx, + localPosition.dy, + widget.draggingRect.width, + widget.draggingRect.height, ); _containerDraggingIn = MapEntry(widget, globalPosition); - refresh(); + notifyListeners(); } - void placeDragInWidget(WidgetContainerModel widget, + bool placeDragInWidget(WidgetContainerModel widget, [bool fromLayout = false]) { if (_containerDraggingIn == null) { - return; + return false; } Offset globalPosition = _containerDraggingIn!.value; @@ -505,10 +481,10 @@ class TabGridModel extends ChangeNotifier { double height = widget.displayRect.height; Rect previewLocation = Rect.fromLTWH(previewX, previewY, width, height); - widget.setPreviewRect(previewLocation); + widget.previewRect = previewLocation; widget.tryCast()?.updateMinimumSize(); - widget.setEnabled(ntConnection.isNT4Connected); + widget.enabled = ntConnection.isNT4Connected; // If dragging into layout if (widget is NTWidgetContainerModel && @@ -518,6 +494,15 @@ class TabGridModel extends ChangeNotifier { if (layoutContainer.willAcceptWidget(widget)) { layoutContainer.addWidget(widget); + } else { + widget.disposeModel(deleting: !fromLayout); + if (!fromLayout) { + widget.unSubscribe(); + widget.forceDispose(); + } + + notifyListeners(); + return false; } } else if (!isValidMoveLocation(widget, previewLocation)) { _containerDraggingIn = null; @@ -528,8 +513,8 @@ class TabGridModel extends ChangeNotifier { widget.forceDispose(); } - refresh(); - return; + notifyListeners(); + return false; } else { widget.displayRect = previewLocation; widget.draggingRect = Rect.fromLTWH(previewX, previewY, width, height); @@ -540,7 +525,9 @@ class TabGridModel extends ChangeNotifier { _containerDraggingIn = null; widget.disposeModel(); - refresh(); + notifyListeners(); + + return true; } ListLayoutModel createListLayout( @@ -559,13 +546,18 @@ class TabGridModel extends ChangeNotifier { children: children, minWidth: 128.0, minHeight: 128.0, - tabGrid: this, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), onDragCancel: _layoutContainerOnDragCancel, ); } void addWidget(WidgetContainerModel widget) { _widgetModels.add(widget); + widget.addListener(notifyListeners); + notifyListeners(); } void addWidgetFromTabJson(Map widgetData) { @@ -576,7 +568,7 @@ class TabGridModel extends ChangeNotifier { tryCast(widgetData['height']) ?? 0.0, ); // If the widget is already in the tab, don't add it - if (!widgetData['layout']) { + if (!(widgetData.containsKey('layout') && widgetData['layout'])) { for (NTWidgetContainerModel container in _widgetModels.whereType()) { String? title = container.title; @@ -606,10 +598,10 @@ class TabGridModel extends ChangeNotifier { } } - if (widgetData['layout']) { + if (widgetData.containsKey('layout') && widgetData['layout']) { switch (widgetData['type']) { case 'List Layout': - _widgetModels.add( + addWidget( ListLayoutModel.fromJson( preferences: preferences, jsonData: widgetData, @@ -622,7 +614,10 @@ class TabGridModel extends ChangeNotifier { onJsonLoadingWarning: onJsonLoadingWarning, ), enabled: ntConnection.isNT4Connected, - tabGrid: this, + dragOutFunctions: ( + dragOutUpdate: layoutDragOutUpdate, + dragOutEnd: layoutDragOutEnd, + ), onDragCancel: _layoutContainerOnDragCancel, minWidth: 128.0 * 2, minHeight: 128.0 * 2, @@ -631,7 +626,7 @@ class TabGridModel extends ChangeNotifier { break; } } else { - _widgetModels.add( + addWidget( NTWidgetContainerModel.fromJson( ntConnection: ntConnection, preferences: preferences, @@ -640,27 +635,27 @@ class TabGridModel extends ChangeNotifier { ), ); } - - refresh(); } void removeWidget(WidgetContainerModel widget) { + widget.removeListener(notifyListeners); widget.disposeModel(deleting: true); widget.unSubscribe(); widget.forceDispose(); _widgetModels.remove(widget); - refresh(); + notifyListeners(); } @visibleForTesting void clearWidgets() { for (WidgetContainerModel container in _widgetModels) { + container.removeListener(notifyListeners); container.disposeModel(deleting: true); container.unSubscribe(); container.forceDispose(); } _widgetModels.clear(); - refresh(); + notifyListeners(); } void confirmClearWidgets(BuildContext context) { @@ -696,18 +691,19 @@ class TabGridModel extends ChangeNotifier { void lockLayout() { for (WidgetContainerModel container in _widgetModels) { - container.setDraggable(false); + container.draggable = false; } } void unlockLayout() { for (WidgetContainerModel container in _widgetModels) { - container.setDraggable(true); + container.draggable = true; } } void onDestroy() { for (WidgetContainerModel container in _widgetModels) { + container.removeListener(notifyListeners); container.disposeModel(deleting: true); container.unSubscribe(); container.forceDispose(); @@ -719,7 +715,7 @@ class TabGridModel extends ChangeNotifier { for (WidgetContainerModel widget in _widgetModels) { widget.updateGridSize(oldSize, newSize); } - refresh(); + notifyListeners(); } void refreshAllContainers() { @@ -729,10 +725,6 @@ class TabGridModel extends ChangeNotifier { } }); } - - void refresh() { - notifyListeners(); - } } class TabGrid extends StatelessWidget { @@ -1048,8 +1040,7 @@ class TabGrid extends StatelessWidget { WidgetContainerModel copiedWidget = createWidgetFromJson(grid, widgetJson); - grid._widgetModels.add(copiedWidget); - grid.refresh(); + grid.addWidget(copiedWidget); } } @@ -1059,7 +1050,10 @@ class TabGrid extends StatelessWidget { return ListLayoutModel.fromJson( preferences: grid.preferences, jsonData: json, - tabGrid: grid, + dragOutFunctions: ( + dragOutUpdate: grid.layoutDragOutUpdate, + dragOutEnd: grid.layoutDragOutEnd, + ), ntWidgetBuilder: (preferences, jsonData, enabled, {onJsonLoadingWarning}) => NTWidgetContainerModel.fromJson( diff --git a/pubspec.lock b/pubspec.lock index 61bb595c..35f7cf27 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -406,14 +406,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - flutter_hooks: - dependency: "direct main" - description: - name: flutter_hooks - sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70 - url: "https://pub.dev" - source: hosted - version: "0.20.5" flutter_launcher_icons: dependency: "direct main" description: @@ -1081,10 +1073,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "6ce1e04375be4eed30548f10a315826fd933c1e493206eab82eed01f438c8d2e" + sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" url: "https://pub.dev" source: hosted - version: "6.2.6" + version: "6.3.0" url_launcher_android: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4cb79e21..8560f9b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,7 +21,6 @@ dependencies: flutter_colorpicker: ^1.1.0 flutter_context_menu: ^0.1.3 flutter_fancy_tree_view: ^1.1.1 - flutter_hooks: ^0.20.0 flutter_launcher_icons: ^0.13.1 github: ^9.17.0 http: ^1.2.0 @@ -43,7 +42,7 @@ dependencies: syncfusion_flutter_gauges: ^23.1.44 titlebar_buttons: ^1.0.0 transitioned_indexed_stack: ^1.0.2 - url_launcher: ^6.1.14 + url_launcher: ^6.3.0 uuid: ^4.2.2 vector_math: ^2.1.4 version: ^3.0.2 diff --git a/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index 7546e75c..b7316a88 100644 --- a/test/pages/dashboard_page_test.dart +++ b/test/pages/dashboard_page_test.dart @@ -72,6 +72,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -96,6 +97,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -120,6 +122,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -152,6 +155,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -176,6 +180,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -265,7 +270,6 @@ void main() { testWidgets('Add widget dialog (widgets)', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; - createMockOnlineNT4(); await widgetTester.pumpWidget( MaterialApp( @@ -273,6 +277,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -336,6 +341,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -382,6 +388,147 @@ void main() { expect(listLayoutContainer, findsOneWidget); }); + testWidgets('Add widget dialog (list layout sub-table)', + (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Non-Typed/Value 1', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Typed/Value 2', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Typed/Value 3', + type: NT4TypeStr.kInt, + properties: {}, + ), + ], + ), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; + + addWidgetButton.onPressed?.call(); + + await widgetTester.pumpAndSettle(); + + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + + final nonTypedTile = find.widgetWithText(TreeTile, 'Non-Typed'); + + expect(nonTypedTile, findsOneWidget); + + final nonTypedContainer = find.widgetWithText(WidgetContainer, 'Non-Typed'); + expect(nonTypedContainer, findsNothing); + + await widgetTester.drag( + nonTypedTile, + const Offset(250, 0), + kind: PointerDeviceKind.mouse, + ); + await widgetTester.pumpAndSettle(); + + expect(nonTypedContainer, findsOneWidget); + }); + + testWidgets('Add widget dialog (unregistered sendable)', + (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Non-Registered/.type', + type: NT4TypeStr.kString, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 1', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 2', + type: NT4TypeStr.kInt, + properties: {}, + ), + NT4Topic( + name: '/Non-Registered/Value 3', + type: NT4TypeStr.kInt, + properties: {}, + ), + ], + virtualValues: { + '/Non-Registered/.type': 'Non Registered Type', + }, + ), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), + ), + ), + ); + + await widgetTester.pumpAndSettle(); + + final addWidget = find.widgetWithText(MenuItemButton, 'Add Widget'); + + expect(addWidget, findsOneWidget); + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsNothing); + + MenuItemButton addWidgetButton = + addWidget.evaluate().first.widget as MenuItemButton; + + addWidgetButton.onPressed?.call(); + + await widgetTester.pumpAndSettle(); + + expect(find.widgetWithText(DraggableDialog, 'Add Widget'), findsOneWidget); + + final nonRegisteredTile = find.widgetWithText(TreeTile, 'Non-Registered'); + + expect(nonRegisteredTile, findsOneWidget); + + final nonRegistered = + find.widgetWithText(WidgetContainer, 'Non-Registered'); + expect(nonRegistered, findsNothing); + + await widgetTester.drag( + nonRegisteredTile, + const Offset(250, 0), + kind: PointerDeviceKind.mouse, + ); + await widgetTester.pumpAndSettle(); + + expect(nonRegistered, findsOneWidget); + }); + testWidgets('List Layouts', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; @@ -391,6 +538,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -537,6 +685,7 @@ void main() { ntConnection: mockNT4Connection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -626,6 +775,7 @@ void main() { ntConnection: ntConnection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -672,6 +822,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -704,6 +855,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -733,6 +885,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -763,6 +916,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -789,6 +943,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -831,6 +986,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -867,6 +1023,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -925,6 +1082,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -981,6 +1139,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1041,6 +1200,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1091,6 +1251,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1143,6 +1304,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1175,6 +1337,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1199,6 +1362,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1231,6 +1395,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1267,6 +1432,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1309,6 +1475,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1338,6 +1505,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1376,6 +1544,7 @@ void main() { ntConnection: ntConnection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1445,85 +1614,108 @@ void main() { expect(preferences.getString(PrefKeys.ipAddress), '0.0.0.0'); }); - testWidgets( - 'Robot Notifications', - (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - final Map data = { - 'title': 'Robot Notification Title', - 'description': 'Robot Notification Description', - 'level': 'INFO', - 'displayTime': 350, - 'width': 300.0, - 'height': 300.0, - }; - - MockNTConnection connection = createMockOnlineNT4( - virtualTopics: [ - NT4Topic( - name: '/Elastic/RobotNotifications', - type: NT4TypeStr.kString, - properties: {}, - ) - ], - virtualValues: {'/Elastic/RobotNotifications': jsonEncode(data)}, - serverTime: 5000000, - ); - MockNT4Subscription mockSub = MockNT4Subscription(); - - List listeners = []; - when(mockSub.listen(any)).thenAnswer( - (realInvocation) { - listeners.add(realInvocation.positionalArguments[0]); - mockSub.updateValue(jsonEncode(data), 0); - }, - ); - - when(mockSub.updateValue(any, any)).thenAnswer( - (invoc) { - for (var value in listeners) { - value.call( - invoc.positionalArguments[0], invoc.positionalArguments[1]); - } - }, - ); - - when(connection.subscribeAll(any, any)).thenAnswer( - (realInvocation) { - return mockSub; - }, - ); - - final notificationWidget = - find.widgetWithText(ElegantNotification, data['title']); - - await widgetTester.pumpWidget( - MaterialApp( - home: DashboardPage( - ntConnection: connection, - preferences: preferences, - version: '0.0.0.0', - ), + testWidgets('Robot Notifications', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + final Map data = { + 'title': 'Robot Notification Title', + 'description': 'Robot Notification Description', + 'level': 'INFO', + 'displayTime': 350, + 'width': 300.0, + 'height': 300.0, + }; + + MockNTConnection connection = createMockOnlineNT4( + virtualTopics: [ + NT4Topic( + name: '/Elastic/RobotNotifications', + type: NT4TypeStr.kString, + properties: {}, + ) + ], + virtualValues: {'/Elastic/RobotNotifications': jsonEncode(data)}, + serverTime: 5000000, + ); + MockNT4Subscription mockSub = MockNT4Subscription(); + + List listeners = []; + when(mockSub.listen(any)).thenAnswer( + (realInvocation) { + listeners.add(realInvocation.positionalArguments[0]); + mockSub.updateValue(jsonEncode(data), 0); + }, + ); + + when(mockSub.updateValue(any, any)).thenAnswer( + (invoc) { + for (var value in listeners) { + value.call( + invoc.positionalArguments[0], invoc.positionalArguments[1]); + } + }, + ); + + when(connection.subscribeAll(any, any)).thenAnswer( + (realInvocation) { + return mockSub; + }, + ); + + final notificationWidget = + find.widgetWithText(ElegantNotification, data['title']); + + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: connection, + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), - ); - expect(notificationWidget, findsNothing); + ), + ); + expect(notificationWidget, findsNothing); + + await widgetTester.pumpAndSettle(); + connection + .subscribeAll('/Elastic/RobotNotifications', 0.2) + .updateValue(jsonEncode(data), 1); + + await widgetTester.pump(); + + expect(notificationWidget, findsOneWidget); + + await widgetTester.pumpAndSettle(); - await widgetTester.pumpAndSettle(); - connection - .subscribeAll('/Elastic/RobotNotifications', 0.2) - .updateValue(jsonEncode(data), 1); + expect(notificationWidget, findsNothing); - await widgetTester.pump(); + connection + .subscribeAll('/Elastic/RobotNotifications', 0.2) + .updateValue(jsonEncode(data), 1); + }); + + testWidgets('Update Notification', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; - expect(notificationWidget, findsOneWidget); + await widgetTester.pumpWidget( + MaterialApp( + home: DashboardPage( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + version: '0.0.0.0', + updateChecker: createMockUpdateChecker( + updateAvailable: true, latestVersion: '2025.0.1'), + ), + ), + ); - await widgetTester.pumpAndSettle(); + await widgetTester.pumpAndSettle(); - expect(notificationWidget, findsNothing); + final notificationWidget = + find.widgetWithText(ElegantNotification, 'Version 2025.0.1 Available'); + final notificationIcon = find.byIcon(Icons.update); - connection - .subscribeAll('/Elastic/RobotNotifications', 0.2) - .updateValue(jsonEncode(data), 1); - }, - ); + expect(notificationWidget, findsOneWidget); + expect(notificationIcon, findsOneWidget); + }); } diff --git a/test/test_util.dart b/test/test_util.dart index 08dfcbd4..3e55f466 100644 --- a/test/test_util.dart +++ b/test/test_util.dart @@ -8,6 +8,7 @@ import 'package:mockito/mockito.dart'; import 'package:elastic_dashboard/services/ds_interop.dart'; import 'package:elastic_dashboard/services/nt4_client.dart'; import 'package:elastic_dashboard/services/nt_connection.dart'; +import 'package:elastic_dashboard/services/update_checker.dart'; import 'test_util.mocks.dart'; @GenerateNiceMocks([ @@ -36,8 +37,8 @@ MockNTConnection createMockOfflineNT4() { when(mockNT4Connection.connectionStatus()) .thenAnswer((_) => Stream.value(false)); - when(mockNT4Connection.dsConnectionStatus()) - .thenAnswer((_) => Stream.value(false)); + when(mockNT4Connection.dsConnected).thenReturn(ValueNotifier(false)); + when(mockNT4Connection.isDSConnected).thenReturn(false); when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); @@ -108,8 +109,8 @@ MockNTConnection createMockOnlineNT4({ when(mockNT4Connection.connectionStatus()) .thenAnswer((_) => Stream.value(true)); - when(mockNT4Connection.dsConnectionStatus()) - .thenAnswer((_) => Stream.value(true)); + when(mockNT4Connection.dsConnected).thenReturn(ValueNotifier(true)); + when(mockNT4Connection.isDSConnected).thenReturn(true); when(mockNT4Connection.latencyStream()).thenAnswer((_) => Stream.value(0)); @@ -238,6 +239,25 @@ MockNTConnection createMockOnlineNT4({ return mockNT4Connection; } +@GenerateNiceMocks([ + MockSpec(), +]) +MockUpdateChecker createMockUpdateChecker( + {bool updateAvailable = false, String latestVersion = '0.0.0.0'}) { + MockUpdateChecker updateChecker = MockUpdateChecker(); + + when(updateChecker.isUpdateAvailable()).thenAnswer( + (_) => Future.value( + UpdateCheckerResponse( + updateAvailable: updateAvailable, + error: false, + latestVersion: latestVersion), + ), + ); + + return updateChecker; +} + void ignoreOverflowErrors( FlutterErrorDetails details, { bool forceReport = false, diff --git a/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart b/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart index b5ac84ca..7a84186c 100644 --- a/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart +++ b/test/widgets/nt_widgets/multi-topic/camera_stream_test.dart @@ -69,6 +69,47 @@ void main() { expect(cameraStreamModel.getUrlWithParameters('0.0.0.0'), '0.0.0.0?'); }); + test('Camera stream from json (with invalid resolution)', () { + NTWidgetModel cameraStreamModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Camera Stream', + {...cameraStreamJson}..update('resolution', (_) => [101.0, 100.0]), + ); + + expect(cameraStreamModel.type, 'Camera Stream'); + expect(cameraStreamModel.runtimeType, CameraStreamModel); + + if (cameraStreamModel is! CameraStreamModel) { + return; + } + expect(cameraStreamModel.resolution, const Size(102.0, 100.0)); + + expect(cameraStreamModel.getUrlWithParameters('0.0.0.0'), + '0.0.0.0?resolution=102x100&fps=60&compression=50'); + }); + + test('Camera stream from json (with negative resolution)', () { + NTWidgetModel cameraStreamModel = NTWidgetBuilder.buildNTModelFromJson( + ntConnection, + preferences, + 'Camera Stream', + {...cameraStreamJson}..update('resolution', (_) => [-1, 100.0]), + ); + + expect(cameraStreamModel.type, 'Camera Stream'); + expect(cameraStreamModel.runtimeType, CameraStreamModel); + + if (cameraStreamModel is! CameraStreamModel) { + return; + } + + expect(cameraStreamModel.resolution, isNull); + + expect(cameraStreamModel.getUrlWithParameters('0.0.0.0'), + '0.0.0.0?fps=60&compression=50'); + }); + test('Camera stream to json', () { CameraStreamModel cameraStreamModel = CameraStreamModel( ntConnection: ntConnection, diff --git a/test/widgets/settings_dialog_test.dart b/test/widgets/settings_dialog_test.dart index efd9a497..25a5e74c 100644 --- a/test/widgets/settings_dialog_test.dart +++ b/test/widgets/settings_dialog_test.dart @@ -41,6 +41,8 @@ class FakeSettingsMethods extends Mock { void changeDefaultGraphPeriod(); void changeThemeVariant(); + + void openAssetsFolder(); } void main() { @@ -49,7 +51,15 @@ void main() { late SharedPreferences preferences; late FakeSettingsMethods fakeSettings; - setUpAll(() async { + final networkSettings = find.widgetWithText(Tab, 'Network'); + final appearanceSettings = find.widgetWithText(Tab, 'Appearance'); + final devSettings = find.widgetWithText(Tab, 'Developer'); + + setUpAll(() { + fakeSettings = FakeSettingsMethods(); + }); + + setUp(() async { SharedPreferences.setMockInitialValues({ PrefKeys.ipAddress: '127.0.0.1', PrefKeys.teamNumber: 353, @@ -68,10 +78,6 @@ void main() { preferences = await SharedPreferences.getInstance(); - fakeSettings = FakeSettingsMethods(); - }); - - setUp(() { reset(fakeSettings); }); @@ -90,20 +96,52 @@ void main() { await widgetTester.pumpAndSettle(); expect(find.text('Settings'), findsOneWidget); + + expect(networkSettings, findsOneWidget); + expect(appearanceSettings, findsOneWidget); + expect(devSettings, findsOneWidget); + expect(find.text('Team Number'), findsOneWidget); - expect(find.text('Team Color'), findsOneWidget); expect(find.text('IP Address Mode'), findsOneWidget); expect(find.text('IP Address'), findsOneWidget); + expect(find.text('Default Period'), findsOneWidget); + expect(find.text('Default Graph Period'), findsOneWidget); + + expect(find.text('Team Color'), findsNothing); + expect(find.text('Theme Variant'), findsNothing); + expect(find.text('Show Grid'), findsNothing); + expect(find.text('Grid Size'), findsNothing); + expect(find.text('Corner Radius'), findsNothing); + expect(find.text('Resize to Driver Station Height'), findsNothing); + expect(find.text('Remember Window Position'), findsNothing); + expect(find.text('Lock Layout'), findsNothing); + + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + + expect(find.text('Team Number'), findsNothing); + expect(find.text('IP Address Mode'), findsNothing); + expect(find.text('IP Address'), findsNothing); + expect(find.text('Default Period'), findsNothing); + expect(find.text('Default Graph Period'), findsNothing); + + expect(find.text('Team Color'), findsOneWidget); expect(find.text('Show Grid'), findsWidgets); expect(find.text('Grid Size'), findsWidgets); expect(find.text('Corner Radius'), findsOneWidget); expect(find.text('Resize to Driver Station Height'), findsOneWidget); expect(find.text('Remember Window Position'), findsOneWidget); expect(find.text('Lock Layout'), findsOneWidget); - expect(find.text('Default Period'), findsOneWidget); - expect(find.text('Default Graph Period'), findsOneWidget); expect(find.text('Theme Variant'), findsOneWidget); + expect(devSettings, findsOneWidget); + + await widgetTester.tap(devSettings); + await widgetTester.pumpAndSettle(); + + expect(find.text('Open Assets Folder'), findsOneWidget); + final closeButton = find.widgetWithText(TextButton, 'Close'); expect(closeButton, findsOneWidget); @@ -146,6 +184,148 @@ void main() { verify(fakeSettings.changeTeamNumber()).called(greaterThanOrEqualTo(1)); }); + testWidgets('Change IP address mode', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onIPAddressModeChanged: (mode) { + fakeSettings.changeIPAddressMode(); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final ipAddressMode = find.byType(DialogDropdownChooser); + + expect(ipAddressMode, findsOneWidget); + + expect(find.text('Driver Station'), findsOneWidget); + + await widgetTester.tap(ipAddressMode); + await widgetTester.pumpAndSettle(); + + expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); + + await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Driver Station'), findsNothing); + expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); + + await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); + await widgetTester.pumpAndSettle(); + + expect(find.text('Driver Station'), findsOneWidget); + + await widgetTester.tap(find.text('Driver Station')); + await widgetTester.pumpAndSettle(); + + verify(fakeSettings.changeIPAddressMode()).called(2); + }); + + testWidgets('Change IP address', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onIPAddressChanged: (data) async { + fakeSettings.changeIPAddress(); + + await preferences.setString(PrefKeys.ipAddress, data!); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final ipAddressField = find.widgetWithText(DialogTextInput, 'IP Address'); + + expect(ipAddressField, findsOneWidget); + + await widgetTester.enterText(ipAddressField, '10.3.53.2'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pump(); + + expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); + verify(fakeSettings.changeIPAddress()).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('Change default period', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onDefaultPeriodChanged: (period) async { + fakeSettings.changeDefaultPeriod(); + + await preferences.setDouble( + PrefKeys.defaultPeriod, double.parse(period!)); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final periodField = find.widgetWithText(DialogTextInput, 'Default Period'); + + expect(periodField, findsOneWidget); + + await widgetTester.enterText(periodField, '0.05'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + expect(preferences.getDouble(PrefKeys.defaultPeriod), 0.05); + verify(fakeSettings.changeDefaultPeriod()).called(greaterThanOrEqualTo(1)); + }); + + testWidgets('Change default graph period', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + await widgetTester.pumpWidget(MaterialApp( + home: Scaffold( + body: SettingsDialog( + ntConnection: createMockOfflineNT4(), + preferences: preferences, + onDefaultGraphPeriodChanged: (period) async { + fakeSettings.changeDefaultGraphPeriod(); + + await preferences.setDouble( + PrefKeys.defaultGraphPeriod, double.parse(period!)); + }, + ), + ), + )); + + await widgetTester.pumpAndSettle(); + + final periodField = + find.widgetWithText(DialogTextInput, 'Default Graph Period'); + + expect(periodField, findsOneWidget); + + await widgetTester.enterText(periodField, '0.05'); + await widgetTester.testTextInput.receiveAction(TextInputAction.done); + await widgetTester.pumpAndSettle(); + + expect(preferences.getDouble(PrefKeys.defaultGraphPeriod), 0.05); + verify(fakeSettings.changeDefaultGraphPeriod()) + .called(greaterThanOrEqualTo(1)); + }); + testWidgets('Change team color', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; @@ -165,6 +345,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final teamColorBox = find.byType(DialogColorPicker); expect(teamColorBox, findsOneWidget); @@ -222,6 +406,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final themeVariantDropdown = find.widgetWithText(DialogDropdownChooser, 'Chroma'); @@ -274,6 +462,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final gridSwitch = find.widgetWithText(DialogToggleSwitch, 'Show Grid'); expect(gridSwitch, findsOneWidget); @@ -316,6 +508,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final gridSizeField = find.widgetWithText(DialogTextInput, 'Grid Size'); expect(gridSizeField, findsOneWidget); @@ -349,6 +545,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final cornerRadiusField = find.widgetWithText(DialogTextInput, 'Corner Radius'); @@ -381,6 +581,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final autoResizeSwitch = find.widgetWithText( DialogToggleSwitch, 'Resize to Driver Station Height'); @@ -424,6 +628,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final windowSwitch = find.widgetWithText(DialogToggleSwitch, 'Remember Window Position'); @@ -467,6 +675,10 @@ void main() { await widgetTester.pumpAndSettle(); + expect(appearanceSettings, findsOneWidget); + await widgetTester.tap(appearanceSettings); + await widgetTester.pumpAndSettle(); + final lockLayoutSwitch = find.widgetWithText(DialogToggleSwitch, 'Lock Layout'); @@ -491,95 +703,16 @@ void main() { verify(fakeSettings.changeLockLayout()).called(2); }); - testWidgets('Change IP address mode', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsDialog( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - onIPAddressModeChanged: (mode) { - fakeSettings.changeIPAddressMode(); - }, - ), - ), - )); - - await widgetTester.pumpAndSettle(); - - final ipAddressMode = find.byType(DialogDropdownChooser); - - expect(ipAddressMode, findsOneWidget); - - expect(find.text('Driver Station'), findsOneWidget); - - await widgetTester.tap(ipAddressMode); - await widgetTester.pumpAndSettle(); - - expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); - - await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); - await widgetTester.pumpAndSettle(); - - expect(find.text('Driver Station'), findsNothing); - expect(find.text('Team Number (10.TE.AM.2)'), findsOneWidget); - - await widgetTester.tap(find.text('Team Number (10.TE.AM.2)')); - await widgetTester.pumpAndSettle(); - - expect(find.text('Driver Station'), findsOneWidget); - - await widgetTester.tap(find.text('Driver Station')); - await widgetTester.pumpAndSettle(); - - verify(fakeSettings.changeIPAddressMode()).called(2); - }); - - testWidgets('Change IP address', (widgetTester) async { + testWidgets('Open assets', (widgetTester) async { FlutterError.onError = ignoreOverflowErrors; await widgetTester.pumpWidget(MaterialApp( home: Scaffold( body: SettingsDialog( - ntConnection: createMockOfflineNT4(), preferences: preferences, - onIPAddressChanged: (data) async { - fakeSettings.changeIPAddress(); - - await preferences.setString(PrefKeys.ipAddress, data!); - }, - ), - ), - )); - - await widgetTester.pumpAndSettle(); - - final ipAddressField = find.widgetWithText(DialogTextInput, 'IP Address'); - - expect(ipAddressField, findsOneWidget); - - await widgetTester.enterText(ipAddressField, '10.3.53.2'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pump(); - - expect(preferences.getString(PrefKeys.ipAddress), '10.3.53.2'); - verify(fakeSettings.changeIPAddress()).called(greaterThanOrEqualTo(1)); - }); - - testWidgets('Change default period', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsDialog( ntConnection: createMockOfflineNT4(), - preferences: preferences, - onDefaultPeriodChanged: (period) async { - fakeSettings.changeDefaultPeriod(); - - await preferences.setDouble( - PrefKeys.defaultPeriod, double.parse(period!)); + onOpenAssetsFolderPressed: () { + fakeSettings.openAssetsFolder(); }, ), ), @@ -587,49 +720,16 @@ void main() { await widgetTester.pumpAndSettle(); - final periodField = find.widgetWithText(DialogTextInput, 'Default Period'); - - expect(periodField, findsOneWidget); - - await widgetTester.enterText(periodField, '0.05'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); - - expect(preferences.getDouble(PrefKeys.defaultPeriod), 0.05); - verify(fakeSettings.changeDefaultPeriod()).called(greaterThanOrEqualTo(1)); - }); - - testWidgets('Change default graph period', (widgetTester) async { - FlutterError.onError = ignoreOverflowErrors; - - await widgetTester.pumpWidget(MaterialApp( - home: Scaffold( - body: SettingsDialog( - ntConnection: createMockOfflineNT4(), - preferences: preferences, - onDefaultGraphPeriodChanged: (period) async { - fakeSettings.changeDefaultGraphPeriod(); - - await preferences.setDouble( - PrefKeys.defaultGraphPeriod, double.parse(period!)); - }, - ), - ), - )); - + expect(devSettings, findsOneWidget); + await widgetTester.tap(devSettings); await widgetTester.pumpAndSettle(); - final periodField = - find.widgetWithText(DialogTextInput, 'Default Graph Period'); + final openAssetsButton = find.text('Open Assets Folder'); - expect(periodField, findsOneWidget); + expect(openAssetsButton, findsOneWidget); - await widgetTester.enterText(periodField, '0.05'); - await widgetTester.testTextInput.receiveAction(TextInputAction.done); - await widgetTester.pumpAndSettle(); + await widgetTester.tap(openAssetsButton); - expect(preferences.getDouble(PrefKeys.defaultGraphPeriod), 0.05); - verify(fakeSettings.changeDefaultGraphPeriod()) - .called(greaterThanOrEqualTo(1)); + verify(fakeSettings.openAssetsFolder()).called(1); }); }