From f185ebd55406a73ce3b5cff48fe9d5dec9abe9a1 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 10 May 2026 22:56:23 +0200 Subject: [PATCH] fix(downloads): improve sync rules navigation close #1001 --- lib/screens/downloads/downloads_screen.dart | 15 +- lib/screens/downloads/sync_rules_screen.dart | 197 ++++++++++++++++-- lib/screens/media_detail_screen.dart | 1 + lib/utils/download_utils.dart | 26 ++- lib/widgets/media_context_menu.dart | 8 +- .../downloads_screen_focus_test.dart | 93 +++++++++ .../downloads/sync_rules_screen_test.dart | 116 ++++++++++- 7 files changed, 422 insertions(+), 34 deletions(-) create mode 100644 test/screens/downloads/downloads_screen_focus_test.dart diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 2e1a6067..bbbf5669 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -9,6 +9,7 @@ import '../../services/settings_service.dart'; import '../../widgets/settings_builder.dart'; import '../../utils/global_key_utils.dart'; import '../../mixins/tab_navigation_mixin.dart'; +import '../../mixins/refreshable.dart'; import '../../utils/grid_size_calculator.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/desktop_app_bar.dart'; @@ -28,7 +29,8 @@ class DownloadsScreen extends StatefulWidget { State createState() => DownloadsScreenState(); } -class DownloadsScreenState extends State with TickerProviderStateMixin, TabNavigationMixin { +class DownloadsScreenState extends State + with TickerProviderStateMixin, TabNavigationMixin, FocusableTab { // Focus nodes for tab chips final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue'); final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows'); @@ -61,6 +63,15 @@ class DownloadsScreenState extends State with TickerProviderSta } } + @override + void focusActiveTabIfReady() { + suppressAutoFocus = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + getTabChipFocusNode(tabController.index).requestFocus(); + }); + } + /// Focus the first item in the currently active tab void _focusCurrentTab() { // Re-enable auto-focus since user is navigating into tab content @@ -111,7 +122,7 @@ class DownloadsScreenState extends State with TickerProviderSta }); getTabChipFocusNode(newIndex).requestFocus(); } - : null, + : () => _actionBarKey.currentState?.requestFocusOnFirst(), onNavigateDown: _focusCurrentTab, onBack: onTabBarBack, ); diff --git a/lib/screens/downloads/sync_rules_screen.dart b/lib/screens/downloads/sync_rules_screen.dart index e8264dec..63017745 100644 --- a/lib/screens/downloads/sync_rules_screen.dart +++ b/lib/screens/downloads/sync_rules_screen.dart @@ -4,6 +4,8 @@ import 'package:provider/provider.dart'; import '../../connection/connection.dart'; import '../../connection/connection_registry.dart'; import '../../database/app_database.dart'; +import '../../focus/focusable_wrapper.dart'; +import '../../focus/input_mode_tracker.dart'; import '../../media/media_item.dart'; import '../../providers/download_provider.dart'; import '../../providers/multi_server_provider.dart'; @@ -11,7 +13,6 @@ import '../../services/sync_rule_executor.dart'; import '../../utils/content_utils.dart'; import '../../utils/download_utils.dart'; import '../../widgets/focused_scroll_scaffold.dart'; -import '../../widgets/focusable_list_tile.dart'; import '../libraries/state_messages.dart'; import '../../i18n/strings.g.dart'; @@ -31,6 +32,7 @@ class SyncRulesScreen extends StatelessWidget { initialData: const [], builder: (context, snapshot) { final connections = snapshot.data ?? const []; + final autofocusFirstRule = InputModeTracker.isKeyboardMode(context); return FocusedScrollScaffold( title: Text(t.downloads.activeSyncRules), slivers: [ @@ -49,7 +51,7 @@ class SyncRulesScreen extends StatelessWidget { downloadProvider: downloadProvider, multiServerProvider: multiServerProvider, connections: connections, - autofocus: index == 0, + autofocus: autofocusFirstRule && index == 0, ); }, childCount: syncRules.length), ), @@ -69,7 +71,7 @@ class _RuleServerInfo { const _RuleServerInfo({required this.label, required this.isKnown}); } -class _SyncRuleTile extends StatelessWidget { +class _SyncRuleTile extends StatefulWidget { final SyncRuleItem rule; final Map metadata; final DownloadProvider downloadProvider; @@ -86,6 +88,27 @@ class _SyncRuleTile extends StatelessWidget { this.autofocus = false, }); + @override + State<_SyncRuleTile> createState() => _SyncRuleTileState(); +} + +class _SyncRuleTileState extends State<_SyncRuleTile> { + final _rowFocusNode = FocusNode(debugLabel: 'sync_rule_row'); + final _switchFocusNode = FocusNode(debugLabel: 'sync_rule_switch'); + + SyncRuleItem get rule => widget.rule; + Map get metadata => widget.metadata; + DownloadProvider get downloadProvider => widget.downloadProvider; + MultiServerProvider get multiServerProvider => widget.multiServerProvider; + List get connections => widget.connections; + + @override + void dispose() { + _rowFocusNode.dispose(); + _switchFocusNode.dispose(); + super.dispose(); + } + IconData _leadingIcon() { switch (rule.targetType) { case ContentTypes.playlist: @@ -164,38 +187,172 @@ class _SyncRuleTile extends StatelessWidget { downloadProvider: downloadProvider, globalKey: rule.globalKey, currentCount: rule.episodeCount, + displayTitle: _title(), ); } } - @override - Widget build(BuildContext context) { + String _title() { final publicGlobalKey = '${rule.serverId}:${rule.ratingKey}'; final meta = metadata[rule.globalKey] ?? metadata[publicGlobalKey]; - final title = meta?.title ?? rule.ratingKey; + return meta?.title ?? rule.ratingKey; + } + + Future _removeRule(BuildContext context) async { + await removeSyncRuleAndSnack( + context, + downloadProvider: downloadProvider, + globalKey: rule.globalKey, + displayTitle: _title(), + ); + } + + @override + Widget build(BuildContext context) { + final title = _title(); final serverInfo = _serverLabelForRule(); final serverLine = t.downloads.syncRuleServerContext( server: serverInfo.label, status: _serverStatusForRule(serverInfo), ); - return FocusableListTile( - autofocus: autofocus, - leading: Icon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), - title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + return _SwipeRevealDeleteAction( + onDelete: () => _removeRule(context), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: FocusableWrapper( + autofocus: widget.autofocus, + focusNode: _rowFocusNode, + disableScale: true, + useBackgroundFocus: true, + borderRadius: 12, + onSelect: () => _onTap(context), + onNavigateRight: () => _switchFocusNode.requestFocus(), + child: ListTile( + dense: true, + visualDensity: const VisualDensity(vertical: -3), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))), + leading: Icon(_leadingIcon(), color: rule.enabled ? Colors.teal : null, size: 20), + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(_subtitle(), maxLines: 1, overflow: TextOverflow.ellipsis), + Text(serverLine, maxLines: 1, overflow: TextOverflow.ellipsis), + ], + ), + trailing: FocusableWrapper( + focusNode: _switchFocusNode, + disableScale: true, + useBackgroundFocus: true, + descendantsAreFocusable: false, + borderRadius: 20, + onSelect: () => downloadProvider.setSyncRuleEnabled(rule.globalKey, !rule.enabled), + onNavigateLeft: () => _rowFocusNode.requestFocus(), + child: Switch( + value: rule.enabled, + onChanged: (value) => downloadProvider.setSyncRuleEnabled(rule.globalKey, value), + ), + ), + onTap: () => _onTap(context), + ), + ), + ), + ); + } +} + +class _SwipeRevealDeleteAction extends StatefulWidget { + final Widget child; + final VoidCallback onDelete; + + const _SwipeRevealDeleteAction({required this.child, required this.onDelete}); + + @override + State<_SwipeRevealDeleteAction> createState() => _SwipeRevealDeleteActionState(); +} + +class _SwipeRevealDeleteActionState extends State<_SwipeRevealDeleteAction> { + static const double _deleteWidth = 88; + double _dragExtent = 0; + + void _handleDragUpdate(DragUpdateDetails details) { + setState(() { + _dragExtent = (_dragExtent - details.delta.dx).clamp(0, _deleteWidth); + }); + } + + void _handleDragEnd(DragEndDetails details) { + final shouldOpen = + _dragExtent > _deleteWidth / 2 || details.primaryVelocity != null && details.primaryVelocity! < -500; + setState(() => _dragExtent = shouldOpen ? _deleteWidth : 0); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final theme = Theme.of(context); + return ClipRect( + child: Stack( children: [ - Text(_subtitle(), maxLines: 1, overflow: TextOverflow.ellipsis), - Text(serverLine, maxLines: 1, overflow: TextOverflow.ellipsis), + if (_dragExtent > 0) + Positioned.fill( + right: 8, + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: _deleteWidth, + child: ExcludeFocus( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Material( + color: colorScheme.error, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: InkWell( + key: const ValueKey('sync_rule_swipe_delete'), + onTap: widget.onDelete, + child: Tooltip( + message: t.downloads.removeSyncRule, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Symbols.delete_rounded, color: colorScheme.onError, size: 20), + const SizedBox(height: 2), + Text( + t.common.delete, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onError, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: AnimatedContainer( + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + transform: Matrix4.translationValues(-_dragExtent, 0, 0), + child: DecoratedBox( + decoration: BoxDecoration(color: theme.scaffoldBackgroundColor), + child: widget.child, + ), + ), + ), ], ), - trailing: Switch( - value: rule.enabled, - onChanged: (value) => downloadProvider.setSyncRuleEnabled(rule.globalKey, value), - ), - onTap: () => _onTap(context), ); } } diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 903923c4..c1def1bc 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -903,6 +903,7 @@ class _MediaDetailScreenState extends State downloadProvider: downloadProvider, globalKey: ruleKey, currentCount: syncRule.episodeCount, + displayTitle: metadata.displayTitle, ); if (updated && context.mounted) { showSuccessSnackBar(context, t.downloads.syncRuleUpdated); diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index fb4f1200..5c713f91 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -258,7 +258,12 @@ Future showCollectionDownloadOptionsAndQueue( downloadProvider: downloadProvider, ); -Future _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async { +Future _showEpisodeCountDialog( + BuildContext context, { + String? title, + String? hintText, + bool allowZero = false, +}) async { final result = await showTextInputDialog( context, title: title ?? t.downloads.howManyEpisodes, @@ -269,7 +274,7 @@ Future _showEpisodeCountDialog(BuildContext context, {String? title, Strin inputFormatters: [FilteringTextInputFormatter.digitsOnly], validator: (text) { final n = int.tryParse(text); - if (n == null || n <= 0) return ''; + if (n == null || n < 0 || (!allowZero && n == 0)) return ''; return null; }, ); @@ -283,14 +288,29 @@ Future editSyncRuleCount( required DownloadProvider downloadProvider, required String globalKey, required int currentCount, + String? displayTitle, }) async { final count = await _showEpisodeCountDialog( context, title: t.downloads.editEpisodeCount, hintText: currentCount.toString(), + allowZero: true, ); if (count == null || !context.mounted) return false; + if (count == 0) { + final removed = await confirmAndRemoveSyncRule( + context, + downloadProvider: downloadProvider, + globalKey: globalKey, + displayTitle: displayTitle ?? globalKey, + ); + if (removed && context.mounted) { + showSuccessSnackBar(context, t.downloads.syncRuleRemoved); + } + return false; + } + await downloadProvider.updateSyncRuleCount(globalKey, count); return true; } @@ -351,6 +371,7 @@ Future manageSyncRule( BuildContext context, { required DownloadProvider downloadProvider, required String globalKey, + String? displayTitle, }) async { final rule = downloadProvider.getSyncRule(globalKey); if (rule == null) return; @@ -369,6 +390,7 @@ Future manageSyncRule( downloadProvider: downloadProvider, globalKey: globalKey, currentCount: rule.episodeCount, + displayTitle: displayTitle ?? rule.ratingKey, ); } if (updated && context.mounted) { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 7e930c1b..6d4a27a5 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1444,8 +1444,12 @@ class MediaContextMenuState extends State { _ => '', }; - Future _handleManageSyncRule(BuildContext context) => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _itemSyncRuleKey(context)); + Future _handleManageSyncRule(BuildContext context) => manageSyncRule( + context, + downloadProvider: context.read(), + globalKey: _itemSyncRuleKey(context), + displayTitle: _itemDisplayTitle(), + ); /// Fire-and-forget: if a sync rule exists for the target list, run it now so /// newly-added items download immediately instead of waiting for the next diff --git a/test/screens/downloads/downloads_screen_focus_test.dart b/test/screens/downloads/downloads_screen_focus_test.dart new file mode 100644 index 00000000..27a19d7c --- /dev/null +++ b/test/screens/downloads/downloads_screen_focus_test.dart @@ -0,0 +1,93 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/connection/connection.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/providers/download_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/downloads/downloads_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:provider/provider.dart'; + +import '../../test_helpers/prefs.dart'; + +class _FakeConnectionRegistry extends ConnectionRegistry { + _FakeConnectionRegistry(super.db); + + @override + Stream> watchConnections() => Stream.value(const []); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late AppDatabase db; + late DownloadProvider downloadProvider; + late MultiServerProvider multiServerProvider; + late MultiServerManager serverManager; + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + + final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); + downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + + serverManager = MultiServerManager(); + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + }); + + tearDown(() async { + downloadProvider.dispose(); + multiServerProvider.dispose(); + await db.close(); + }); + + testWidgets('right from Movies focuses and opens Sync Rules action', (tester) async { + final screenKey = GlobalKey(); + + await tester.pumpWidget( + InputModeTracker( + child: MultiProvider( + providers: [ + Provider.value(value: _FakeConnectionRegistry(db)), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: multiServerProvider), + ], + child: MaterialApp( + theme: ThemeData(platform: TargetPlatform.macOS), + home: DownloadsScreen(key: screenKey), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final state = screenKey.currentState!; + state.tabController.index = 2; + state.getTabChipFocusNode(2).requestFocus(); + await tester.pumpAndSettle(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('Sync rules'), findsOneWidget); + }); +} diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index 16772bc2..a6ca4291 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -1,11 +1,13 @@ import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; @@ -123,7 +125,7 @@ void main() { ); } - Future pumpScreen(WidgetTester tester) async { + Future pumpScreen(WidgetTester tester, {bool keyboardMode = false}) async { downloadProvider.debugSeedState( metadata: { 'plex-srv:show-1': _show('plex-srv', 'show-1', 'Plex Show'), @@ -133,19 +135,40 @@ void main() { }, ); + Widget buildScreen() => MultiProvider( + providers: [ + Provider.value(value: connectionRegistry), + ChangeNotifierProvider.value(value: downloadProvider), + ChangeNotifierProvider.value(value: multiServerProvider!), + ], + child: const MaterialApp(home: SyncRulesScreen()), + ); + + if (!keyboardMode) { + await tester.pumpWidget(buildScreen()); + await tester.pump(); + return; + } + + final showScreen = ValueNotifier(false); + addTearDown(showScreen.dispose); await tester.pumpWidget( - MultiProvider( - providers: [ - Provider.value(value: connectionRegistry), - ChangeNotifierProvider.value(value: downloadProvider), - ChangeNotifierProvider.value(value: multiServerProvider!), - ], - child: const MaterialApp(home: SyncRulesScreen()), + InputModeTracker( + child: ValueListenableBuilder( + valueListenable: showScreen, + builder: (context, show, _) => show ? buildScreen() : const MaterialApp(home: SizedBox.shrink()), + ), ), ); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); + showScreen.value = true; + await tester.pumpAndSettle(); } + String? primaryFocusLabel() => FocusManager.instance.primaryFocus?.debugLabel; + testWidgets('shows server context and active-profile availability for device sync rules', (tester) async { connections.add( PlexAccountConnection( @@ -192,4 +215,81 @@ void main() { expect(find.text('Unknown Show'), findsOneWidget); expect(find.text('Server: unknown-srv • Unknown server'), findsOneWidget); }); + + testWidgets('removes orphaned sync rules from the sync rules screen', (tester) async { + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + await insertRule('orphan-srv', '76672'); + + await pumpScreen(tester); + + expect(find.text('76672'), findsOneWidget); + + await tester.drag(find.text('76672'), const Offset(-140, 0)); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('sync_rule_swipe_delete'))); + await tester.pumpAndSettle(); + + expect(find.text('Stop syncing "76672"? Downloaded episodes will be kept.'), findsOneWidget); + + await tester.tap(find.widgetWithText(FilledButton, 'Remove sync rule')); + await tester.pumpAndSettle(); + + expect(downloadProvider.syncRules, isEmpty); + expect(find.text('76672'), findsNothing); + expect(find.text('No sync rules'), findsOneWidget); + }); + + testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async { + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + await insertRule('orphan-srv', '76672'); + FocusManager.instance.primaryFocus?.unfocus(); + + await pumpScreen(tester); + await tester.pumpAndSettle(); + + expect(primaryFocusLabel(), isNot('sync_rule_row')); + }); + + testWidgets('keyboard navigation reaches and toggles the sync rule switch', (tester) async { + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + await insertRule('orphan-srv', '76672'); + + await pumpScreen(tester, keyboardMode: true); + + expect(primaryFocusLabel(), 'sync_rule_row'); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + expect(primaryFocusLabel(), 'sync_rule_switch'); + + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(downloadProvider.syncRules.values.single.enabled, isFalse); + }); + + testWidgets('setting sync rule count to zero removes the rule', (tester) async { + multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + await insertRule('orphan-srv', '76672'); + + await pumpScreen(tester, keyboardMode: true); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextField), '0'); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(find.text('Stop syncing "76672"? Downloaded episodes will be kept.'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pumpAndSettle(); + + expect(downloadProvider.syncRules, isEmpty); + expect(find.text('No sync rules'), findsOneWidget); + }); }