fix(library): make missing-file cleanup responsive for downloaded tracks

This commit is contained in:
zarzet committed 2026-10-02 16:21:19 +07:00
1 parent ae679214a9
commit 2b022cdda3
2 files changed
+272 -8

No files matched your search

@@ -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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
}
Future<void> _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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
),
],
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(
+222
View File
@@ -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<int> Function() _cleanup;
@override
LocalLibraryState build() => _initial;
@override
Future<int> 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<int> Function() _cleanup;
@override
DownloadHistoryState build() => DownloadHistoryState(totalCount: _count);
@override
Future<int> cleanupOrphanedDownloads() => _cleanup();
}
Finder get _cleanupRow => find.byWidgetPredicate(
(widget) => widget is SettingsItem && widget.title == 'Cleanup Missing Files',
);
Future<void> _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<int>();
final downloadsDone = Completer<int>();
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<SettingsItem>(_cleanupRow).onTap, isNotNull);
await tester.tap(find.text('Cleanup Missing Files'));
await tester.pump();
expect(localCalls, 1);
expect(tester.widget<SettingsItem>(_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<SettingsItem>(_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<SettingsItem>(_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<SettingsItem>(_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<SettingsItem>(_cleanupRow).onTap!;
library._beginScan();
await tester.pump();
expect(tester.widget<SettingsItem>(_cleanupRow).onTap, isNull);
originalTap();
await tester.pump();
expect(localCalls, 0);
expect(downloadCalls, 0);
expect(tester.takeException(), isNull);
});
}
}