@@ -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,
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user