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..d12763ba 100644 --- a/lib/pages/dashboard_page.dart +++ b/lib/pages/dashboard_page.dart @@ -44,6 +44,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 +53,7 @@ class DashboardPage extends StatefulWidget { required this.ntConnection, required this.preferences, required this.version, + required this.updateChecker, this.onColorChanged, this.onThemeVariantChanged, }); @@ -62,7 +64,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 +73,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 +83,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')) { @@ -385,7 +388,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,20 +435,20 @@ 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) { @@ -1580,35 +1587,65 @@ class _DashboardPageState extends State with WindowListener { : null, child: const Text('Add Widget'), ), - if ((preferences.getBool(PrefKeys.layoutLocked) ?? - Defaults.layoutLocked)) ...[ - const VerticalDivider(), - // Unlock Layout - Tooltip( - message: 'Unlock Layout', - child: MenuItemButton( - style: menuButtonStyle.copyWith( - minimumSize: - const WidgetStatePropertyAll(Size(36.0, double.infinity)), - maximumSize: - const WidgetStatePropertyAll(Size(36.0, double.infinity)), - ), - onPressed: () { - _unlockLayout(); - setState(() {}); - }, - child: const Icon(Icons.lock_outline), + ], + ); + + final List trailing = [ + if ((preferences.getBool(PrefKeys.layoutLocked) ?? + Defaults.layoutLocked)) ...[ + const VerticalDivider(), + // Unlock Layout + Tooltip( + message: 'Unlock Layout', + child: MenuItemButton( + style: menuButtonStyle.copyWith( + minimumSize: + const WidgetStatePropertyAll(Size(36.0, double.infinity)), + maximumSize: + const WidgetStatePropertyAll(Size(36.0, double.infinity)), ), + onPressed: () { + _unlockLayout(); + setState(() {}); + }, + child: const Icon(Icons.lock_outline), ), - ], + ), ], - ); + 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), + ), + ), + ], + ]; + + if (trailing.isNotEmpty) { + trailing.add(const VerticalDivider()); + } return Scaffold( appBar: CustomAppBar( titleText: appTitle, onWindowClose: onWindowClose, - menuBar: menuBar, + leading: menuBar, + trailing: trailing, ), body: Focus( autofocus: true, 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/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/test/pages/dashboard_page_test.dart b/test/pages/dashboard_page_test.dart index 7546e75c..129c29ca 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(), ), ), ); @@ -273,6 +278,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -336,6 +342,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -391,6 +398,7 @@ void main() { ntConnection: createMockOnlineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -537,6 +545,7 @@ void main() { ntConnection: mockNT4Connection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -626,6 +635,7 @@ void main() { ntConnection: ntConnection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -672,6 +682,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -704,6 +715,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -733,6 +745,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -763,6 +776,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -789,6 +803,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -831,6 +846,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -867,6 +883,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -925,6 +942,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -981,6 +999,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1041,6 +1060,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1091,6 +1111,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1143,6 +1164,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1175,6 +1197,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1199,6 +1222,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1231,6 +1255,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1267,6 +1292,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1309,6 +1335,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1338,6 +1365,7 @@ void main() { ntConnection: createMockOfflineNT4(), preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1376,6 +1404,7 @@ void main() { ntConnection: ntConnection, preferences: preferences, version: '0.0.0.0', + updateChecker: createMockUpdateChecker(), ), ), ); @@ -1445,85 +1474,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.pumpAndSettle(); + connection + .subscribeAll('/Elastic/RobotNotifications', 0.2) + .updateValue(jsonEncode(data), 1); + + await widgetTester.pump(); - await widgetTester.pump(); + expect(notificationWidget, findsOneWidget); - expect(notificationWidget, findsOneWidget); + await widgetTester.pumpAndSettle(); - await widgetTester.pumpAndSettle(); + expect(notificationWidget, findsNothing); - expect(notificationWidget, findsNothing); + connection + .subscribeAll('/Elastic/RobotNotifications', 0.2) + .updateValue(jsonEncode(data), 1); + }); - connection - .subscribeAll('/Elastic/RobotNotifications', 0.2) - .updateValue(jsonEncode(data), 1); - }, - ); + testWidgets('Update Notification', (widgetTester) async { + FlutterError.onError = ignoreOverflowErrors; + + 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(); + + final notificationWidget = + find.widgetWithText(ElegantNotification, 'Version 2025.0.1 Available'); + final notificationIcon = find.byIcon(Icons.update); + + expect(notificationWidget, findsOneWidget); + expect(notificationIcon, findsOneWidget); + }); } diff --git a/test/test_util.dart b/test/test_util.dart index 08dfcbd4..c2c6789a 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([ @@ -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,