fix(downloads): improve sync rules navigation

close #1001
This commit is contained in:
edde746
2026-05-10 23:18:09 +02:00
parent 7d679c9d78
commit f185ebd554
7 changed files with 422 additions and 34 deletions
+13 -2
View File
@@ -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<DownloadsScreen> createState() => DownloadsScreenState();
}
class DownloadsScreenState extends State<DownloadsScreen> with TickerProviderStateMixin, TabNavigationMixin {
class DownloadsScreenState extends State<DownloadsScreen>
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<DownloadsScreen> 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<DownloadsScreen> with TickerProviderSta
});
getTabChipFocusNode(newIndex).requestFocus();
}
: null,
: () => _actionBarKey.currentState?.requestFocusOnFirst(),
onNavigateDown: _focusCurrentTab,
onBack: onTabBarBack,
);
+177 -20
View File
@@ -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 <Connection>[];
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<String, MediaItem> 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<String, MediaItem> get metadata => widget.metadata;
DownloadProvider get downloadProvider => widget.downloadProvider;
MultiServerProvider get multiServerProvider => widget.multiServerProvider;
List<Connection> 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<void> _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),
);
}
}
+1
View File
@@ -903,6 +903,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
downloadProvider: downloadProvider,
globalKey: ruleKey,
currentCount: syncRule.episodeCount,
displayTitle: metadata.displayTitle,
);
if (updated && context.mounted) {
showSuccessSnackBar(context, t.downloads.syncRuleUpdated);
+24 -2
View File
@@ -258,7 +258,12 @@ Future<DownloadResult?> showCollectionDownloadOptionsAndQueue(
downloadProvider: downloadProvider,
);
Future<int?> _showEpisodeCountDialog(BuildContext context, {String? title, String? hintText}) async {
Future<int?> _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<int?> _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<bool> 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<void> 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<void> manageSyncRule(
downloadProvider: downloadProvider,
globalKey: globalKey,
currentCount: rule.episodeCount,
displayTitle: displayTitle ?? rule.ratingKey,
);
}
if (updated && context.mounted) {
+6 -2
View File
@@ -1444,8 +1444,12 @@ class MediaContextMenuState extends State<MediaContextMenu> {
_ => '',
};
Future<void> _handleManageSyncRule(BuildContext context) =>
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _itemSyncRuleKey(context));
Future<void> _handleManageSyncRule(BuildContext context) => manageSyncRule(
context,
downloadProvider: context.read<DownloadProvider>(),
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
@@ -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<List<Connection>> 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<DownloadsScreenState>();
await tester.pumpWidget(
InputModeTracker(
child: MultiProvider(
providers: [
Provider<ConnectionRegistry>.value(value: _FakeConnectionRegistry(db)),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<MultiServerProvider>.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);
});
}
@@ -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<void> pumpScreen(WidgetTester tester) async {
Future<void> 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<ConnectionRegistry>.value(value: connectionRegistry),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<MultiServerProvider>.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<ConnectionRegistry>.value(value: connectionRegistry),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider!),
],
child: const MaterialApp(home: SyncRulesScreen()),
InputModeTracker(
child: ValueListenableBuilder<bool>(
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);
});
}