diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index a17e119e..265b55c2 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -9,6 +9,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -30,6 +31,7 @@ class LibrarySettingsPage extends ConsumerStatefulWidget { class _LibrarySettingsPageState extends ConsumerState { int _androidSdkVersion = 0; bool _hasStoragePermission = false; + bool _isCleaningMissingFiles = false; String _getDisplayPath(String path) { if (!path.startsWith('content://')) return path; @@ -248,15 +250,36 @@ class _LibrarySettingsPageState extends ConsumerState { } Future _cleanupMissingFiles() async { - final removed = await ref - .read(localLibraryProvider.notifier) - .cleanupMissingFiles(); - if (mounted) { + if (_isCleaningMissingFiles || ref.read(localLibraryProvider).isScanning) { + return; + } + final library = ref.read(localLibraryProvider.notifier); + final downloads = ref.read(downloadHistoryProvider.notifier); + setState(() => _isCleaningMissingFiles = true); + try { + final missingLibraryEntries = await library.cleanupMissingFiles(); + final orphanedDownloads = await downloads.cleanupOrphanedDownloads(); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n.libraryRemovedMissingFiles(removed)), + content: Text( + context.l10n.libraryRemovedMissingFiles( + missingLibraryEntries + orphanedDownloads, + ), + ), ), ); + } catch (error) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.l10n.snackbarError(context.friendlyError(error)), + ), + ), + ); + } finally { + if (mounted) setState(() => _isCleaningMissingFiles = false); } } @@ -780,6 +803,17 @@ class _LibrarySettingsPageState extends ConsumerState { child: Consumer( builder: (context, ref, _) { final libraryState = ref.watch(localLibraryProvider); + final hasDownloads = ref.watch( + downloadHistoryProvider.select( + (state) => state.totalCount > 0, + ), + ); + final canCleanup = + !_isCleaningMissingFiles && + !libraryState.isScanning && + (libraryState.totalCount > 0 || + hasDownloads || + librarySources.isNotEmpty); return SettingsGroup( children: [ if (libraryState.isScanning) @@ -821,15 +855,23 @@ class _LibrarySettingsPageState extends ConsumerState { ), ], Opacity( - opacity: libraryState.totalCount > 0 ? 1.0 : 0.5, + opacity: canCleanup || _isCleaningMissingFiles + ? 1.0 + : 0.5, child: SettingsItem( icon: Icons.cleaning_services_outlined, title: context.l10n.libraryCleanupMissingFiles, subtitle: context.l10n.libraryCleanupMissingFilesSubtitle, - onTap: libraryState.totalCount > 0 - ? _cleanupMissingFiles + trailing: _isCleaningMissingFiles + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) : null, + onTap: canCleanup ? _cleanupMissingFiles : null, ), ), Opacity( diff --git a/test/library_settings_cleanup_test.dart b/test/library_settings_cleanup_test.dart new file mode 100644 index 00000000..ba079f84 --- /dev/null +++ b/test/library_settings_cleanup_test.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/local_library_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/screens/settings/library_settings_page.dart'; +import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/theme/app_theme.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(localLibraryEnabled: true); +} + +class _Library extends LocalLibraryNotifier { + _Library(this._initial, this._cleanup); + + final LocalLibraryState _initial; + final Future Function() _cleanup; + + @override + LocalLibraryState build() => _initial; + + @override + Future cleanupMissingFiles({String? iosBookmark}) => _cleanup(); + + void _beginScan() => state = state.copyWith(isScanning: true); +} + +class _History extends DownloadHistoryNotifier { + _History(this._count, this._cleanup); + + final int _count; + final Future Function() _cleanup; + + @override + DownloadHistoryState build() => DownloadHistoryState(totalCount: _count); + + @override + Future cleanupOrphanedDownloads() => _cleanup(); +} + +Finder get _cleanupRow => find.byWidgetPredicate( + (widget) => widget is SettingsItem && widget.title == 'Cleanup Missing Files', +); + +Future _mount( + WidgetTester tester, { + required bool mornye, + required _Library library, + required _History history, +}) async { + SharedPreferences.setMockInitialValues({}); + tester.view.physicalSize = const Size(430, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + localLibraryProvider.overrideWith(() => library), + downloadHistoryProvider.overrideWith(() => history), + ], + child: MaterialApp( + theme: mornye ? MornyeTheme.build(Brightness.dark) : AppTheme.light(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const LibrarySettingsPage(), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('Cleanup Missing Files'), 300); + await tester.pumpAndSettle(); +} + +void main() { + for (final mornye in [false, true]) { + testWidgets('cleanup works for download-only Library ($mornye)', ( + tester, + ) async { + final localDone = Completer(); + final downloadsDone = Completer(); + var localCalls = 0; + var downloadCalls = 0; + await _mount( + tester, + mornye: mornye, + library: _Library(LocalLibraryState(), () { + localCalls++; + return localDone.future; + }), + history: _History(8, () { + downloadCalls++; + return downloadsDone.future; + }), + ); + expect(tester.widget(_cleanupRow).onTap, isNotNull); + await tester.tap(find.text('Cleanup Missing Files')); + await tester.pump(); + expect(localCalls, 1); + expect(tester.widget(_cleanupRow).onTap, isNull); + expect( + find.descendant( + of: _cleanupRow, + matching: find.byType(CircularProgressIndicator), + ), + findsOneWidget, + ); + await tester.tap(find.text('Cleanup Missing Files')); + await tester.pump(); + expect(localCalls, 1); + localDone.complete(0); + await tester.pump(); + expect(downloadCalls, 1); + downloadsDone.complete(2); + await tester.pumpAndSettle(); + expect(find.text('Removed 2 missing files from library'), findsOneWidget); + expect(tester.widget(_cleanupRow).onTap, isNotNull); + expect(tester.takeException(), isNull); + }); + + testWidgets('cleanup reports errors and can retry ($mornye)', ( + tester, + ) async { + var calls = 0; + var downloadCalls = 0; + await _mount( + tester, + mornye: mornye, + library: _Library(LocalLibraryState(totalCount: 1), () async { + calls++; + if (calls == 1) throw StateError('cleanup failed'); + return 1; + }), + history: _History(1, () async { + downloadCalls++; + return 2; + }), + ); + await tester.tap(find.text('Cleanup Missing Files')); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsOneWidget); + expect(find.textContaining('Error:'), findsOneWidget); + expect(downloadCalls, 0); + expect(tester.widget(_cleanupRow).onTap, isNotNull); + await tester.tap(find.text('Cleanup Missing Files')); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + expect(find.text('Removed 3 missing files from library'), findsOneWidget); + expect(calls, 2); + expect(downloadCalls, 1); + expect(tester.takeException(), isNull); + }); + + testWidgets('cleanup stays available for hidden source rows ($mornye)', ( + tester, + ) async { + await _mount( + tester, + mornye: mornye, + library: _Library( + LocalLibraryState( + sources: const [ + LocalLibrarySource( + id: 'sd-card', + path: 'content://storage/tree/sd-card', + displayName: 'Music', + available: false, + trackCount: 5, + isRemovable: true, + ), + ], + ), + () async => 0, + ), + history: _History(0, () async => 0), + ); + expect(tester.widget(_cleanupRow).onTap, isNotNull); + await tester.tap(find.text('Cleanup Missing Files')); + await tester.pumpAndSettle(); + expect(find.text('Removed 0 missing files from library'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('cleanup cannot race an active scan ($mornye)', (tester) async { + var localCalls = 0; + var downloadCalls = 0; + final library = _Library(LocalLibraryState(totalCount: 1), () async { + localCalls++; + return 0; + }); + await _mount( + tester, + mornye: mornye, + library: library, + history: _History(1, () async { + downloadCalls++; + return 0; + }), + ); + final originalTap = tester.widget(_cleanupRow).onTap!; + library._beginScan(); + await tester.pump(); + expect(tester.widget(_cleanupRow).onTap, isNull); + originalTap(); + await tester.pump(); + expect(localCalls, 0); + expect(downloadCalls, 0); + expect(tester.takeException(), isNull); + }); + } +}