mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-02 14:19:45 +02:00
feat(library): pause and resume library scans
The scan progress tile offers Pause and Resume. A paused scan releases its foreground service and wake lock, shows a paused notice, and resumes from the next unscanned file.
This commit is contained in:
1 parent
fe36f08918
commit
50f21d9c82
7 files changed
+356
-18
No files matched your search
@@ -2846,6 +2846,10 @@
|
||||
"@libraryScanFinalizing": {
|
||||
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||
},
|
||||
"libraryScanPaused": "Scan paused",
|
||||
"@libraryScanPaused": {
|
||||
"description": "Status shown while a library scan is paused by the user"
|
||||
},
|
||||
"libraryScanProgress": "{progress}% of {total} files",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
@@ -5396,6 +5400,19 @@
|
||||
"@notifLibraryScanStopped": {
|
||||
"description": "Notification body when library scan is cancelled"
|
||||
},
|
||||
"notifLibraryScanPaused": "Library scan paused",
|
||||
"@notifLibraryScanPaused": {
|
||||
"description": "Notification title when the user pauses a library scan"
|
||||
},
|
||||
"notifLibraryScanPausedBody": "{count} files scanned. Resume in Local Library settings to continue from here.",
|
||||
"@notifLibraryScanPausedBody": {
|
||||
"description": "Notification body for a paused library scan - files scanned so far",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifDownloadingUpdate": "Downloading SpotiFLAC Mobile v{version}",
|
||||
"@notifDownloadingUpdate": {
|
||||
"description": "Notification title while downloading an app update",
|
||||
|
||||
@@ -2803,6 +2803,10 @@
|
||||
"@libraryScanFinalizing": {
|
||||
"description": "Status shown after file scanning finishes but library persistence is still running"
|
||||
},
|
||||
"libraryScanPaused": "Pindai dijeda",
|
||||
"@libraryScanPaused": {
|
||||
"description": "Status shown while a library scan is paused by the user"
|
||||
},
|
||||
"libraryScanProgress": "{progress}% dari {total} file",
|
||||
"@libraryScanProgress": {
|
||||
"description": "Scan progress display",
|
||||
@@ -5302,6 +5306,19 @@
|
||||
"@notifLibraryScanStopped": {
|
||||
"description": "Notification body when library scan is cancelled"
|
||||
},
|
||||
"notifLibraryScanPaused": "Pindai koleksi dijeda",
|
||||
"@notifLibraryScanPaused": {
|
||||
"description": "Notification title when the user pauses a library scan"
|
||||
},
|
||||
"notifLibraryScanPausedBody": "{count} file sudah dipindai. Ketuk Lanjutkan di pengaturan Koleksi Lokal untuk meneruskan dari sini.",
|
||||
"@notifLibraryScanPausedBody": {
|
||||
"description": "Notification body for a paused library scan - files scanned so far",
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifDownloadingUpdate": "Mendownload SpotiFLAC Mobile v{version}",
|
||||
"@notifDownloadingUpdate": {
|
||||
"description": "Notification title while downloading an app update",
|
||||
|
||||
@@ -21,6 +21,7 @@ final _prefs = SharedPreferences.getInstance();
|
||||
class LocalLibraryState {
|
||||
final bool isScanning;
|
||||
final bool scanIsFinalizing;
|
||||
final bool scanIsPaused;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
@@ -39,6 +40,7 @@ class LocalLibraryState {
|
||||
LocalLibraryState({
|
||||
this.isScanning = false,
|
||||
this.scanIsFinalizing = false,
|
||||
this.scanIsPaused = false,
|
||||
this.scanProgress = 0,
|
||||
this.scanCurrentFile,
|
||||
this.scanTotalFiles = 0,
|
||||
@@ -76,6 +78,7 @@ class LocalLibraryState {
|
||||
LocalLibraryState copyWith({
|
||||
bool? isScanning,
|
||||
bool? scanIsFinalizing,
|
||||
bool? scanIsPaused,
|
||||
double? scanProgress,
|
||||
String? scanCurrentFile,
|
||||
int? scanTotalFiles,
|
||||
@@ -96,6 +99,7 @@ class LocalLibraryState {
|
||||
return LocalLibraryState(
|
||||
isScanning: isScanning ?? this.isScanning,
|
||||
scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing,
|
||||
scanIsPaused: scanIsPaused ?? this.scanIsPaused,
|
||||
scanProgress: scanProgress ?? this.scanProgress,
|
||||
scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile,
|
||||
scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles,
|
||||
@@ -144,6 +148,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
bool _hasLoadedFromDatabase = false;
|
||||
Future<void>? _loadFuture;
|
||||
bool _scanCancelRequested = false;
|
||||
bool _scanPauseRequested = false;
|
||||
bool _scanInProgress = false;
|
||||
StreamSubscription<void>? _storageEventsSubscription;
|
||||
Timer? _storageEventDebounce;
|
||||
@@ -539,6 +544,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
var ingested = false;
|
||||
try {
|
||||
if (_scanCancelRequested) return null;
|
||||
await _endPauseForFinalization();
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
@@ -613,6 +619,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
_scanInProgress = true;
|
||||
_scanCancelRequested = false;
|
||||
_scanPauseRequested = false;
|
||||
// A pause left behind by an interrupted Dart session would hold the new
|
||||
// native scan forever; clear it before the pause control becomes visible.
|
||||
await _releaseNativeScanPause();
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId);
|
||||
@@ -625,6 +635,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
state = state.copyWith(
|
||||
isScanning: true,
|
||||
scanIsFinalizing: false,
|
||||
scanIsPaused: false,
|
||||
scanProgress: 0,
|
||||
scanCurrentFile: null,
|
||||
scanTotalFiles: 0,
|
||||
@@ -821,6 +832,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
return;
|
||||
}
|
||||
|
||||
await _endPauseForFinalization();
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: true,
|
||||
scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99,
|
||||
@@ -955,6 +967,10 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_log.i('Stopped iOS security-scoped access');
|
||||
}
|
||||
_stopProgressPolling();
|
||||
if (_scanPauseRequested) {
|
||||
_scanPauseRequested = false;
|
||||
await _releaseNativeScanPause();
|
||||
}
|
||||
_scanInProgress = false;
|
||||
try {
|
||||
final prefs = await _prefs;
|
||||
@@ -965,7 +981,7 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear active library scan marker: $e');
|
||||
}
|
||||
state = state.copyWith(clearScanningSourceId: true);
|
||||
state = state.copyWith(clearScanningSourceId: true, scanIsPaused: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1021,11 +1037,14 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
);
|
||||
}
|
||||
|
||||
if (_shouldShowScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
totalFiles: totalFiles,
|
||||
isComplete: false,
|
||||
)) {
|
||||
// Files already in flight still finish after a pause; count them, but
|
||||
// keep the paused notice instead of reposting progress.
|
||||
if (!state.scanIsPaused &&
|
||||
_shouldShowScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
totalFiles: totalFiles,
|
||||
isComplete: false,
|
||||
)) {
|
||||
await _showScanProgressNotification(
|
||||
progress: normalizedProgress,
|
||||
scannedFiles: scannedFiles,
|
||||
@@ -1073,12 +1092,97 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
|
||||
_log.i('Cancelling library scan');
|
||||
_scanCancelRequested = true;
|
||||
// Native cancel also releases a paused scan.
|
||||
_scanPauseRequested = false;
|
||||
await PlatformBridge.cancelLibraryScan();
|
||||
state = state.copyWith(scanIsFinalizing: false, scanWasCancelled: true);
|
||||
state = state.copyWith(
|
||||
scanIsFinalizing: false,
|
||||
scanIsPaused: false,
|
||||
scanWasCancelled: true,
|
||||
);
|
||||
_stopProgressPolling();
|
||||
await _showScanCancelledNotification();
|
||||
}
|
||||
|
||||
/// Holds the running scan in place. Files already processed stay in the
|
||||
/// native scan's memory, so [resumeScan] continues with the next file.
|
||||
Future<void> pauseScan() async {
|
||||
if (!state.isScanning ||
|
||||
state.scanIsFinalizing ||
|
||||
state.scanIsPaused ||
|
||||
_scanCancelRequested) {
|
||||
return;
|
||||
}
|
||||
_log.i('Pausing library scan at ${state.scannedFiles} files');
|
||||
_scanPauseRequested = true;
|
||||
state = state.copyWith(scanIsPaused: true);
|
||||
try {
|
||||
await PlatformBridge.pauseLibraryScan();
|
||||
} catch (e) {
|
||||
_log.w('Failed to pause library scan: $e');
|
||||
_scanPauseRequested = false;
|
||||
state = state.copyWith(scanIsPaused: false);
|
||||
return;
|
||||
}
|
||||
// A paused scan does no work; release the foreground service and wake
|
||||
// lock (iOS background task) until the user resumes.
|
||||
await _notificationService.endBackgroundWork(
|
||||
NotificationService.libraryScanWorkKind,
|
||||
);
|
||||
await _showScanPausedNotification(state.scannedFiles);
|
||||
}
|
||||
|
||||
Future<void> resumeScan() async {
|
||||
if (!state.isScanning || !state.scanIsPaused || !_scanPauseRequested) {
|
||||
return;
|
||||
}
|
||||
_log.i('Resuming library scan from ${state.scannedFiles} files');
|
||||
// Reacquire background work first so resumed reads survive screen-off.
|
||||
await _notificationService.beginLibraryScanWork();
|
||||
await _cancelScanPausedNotification();
|
||||
_scanPauseRequested = false;
|
||||
_resetScanNotificationTracking();
|
||||
state = state.copyWith(scanIsPaused: false);
|
||||
await _releaseNativeScanPause();
|
||||
}
|
||||
|
||||
Future<void> _releaseNativeScanPause() async {
|
||||
try {
|
||||
await PlatformBridge.resumeLibraryScan();
|
||||
} catch (e) {
|
||||
_log.w('Failed to release library scan pause: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// The native scan can finish while paused when the pause lands after its
|
||||
/// last file. Ingestion then runs normally, so restore background work.
|
||||
Future<void> _endPauseForFinalization() async {
|
||||
if (!_scanPauseRequested) return;
|
||||
_scanPauseRequested = false;
|
||||
state = state.copyWith(scanIsPaused: false);
|
||||
await _notificationService.beginLibraryScanWork();
|
||||
await _cancelScanPausedNotification();
|
||||
await _releaseNativeScanPause();
|
||||
}
|
||||
|
||||
Future<void> _showScanPausedNotification(int scannedFiles) async {
|
||||
try {
|
||||
await _notificationService.showLibraryScanPaused(
|
||||
scannedFiles: scannedFiles,
|
||||
);
|
||||
} catch (e) {
|
||||
_log.w('Failed to show scan paused notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelScanPausedNotification() async {
|
||||
try {
|
||||
await _notificationService.cancelLibraryScanNotification();
|
||||
} catch (e) {
|
||||
_log.w('Failed to clear scan paused notification: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showScanProgressNotification({
|
||||
required double progress,
|
||||
required int scannedFiles,
|
||||
|
||||
@@ -204,6 +204,14 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
await ref.read(localLibraryProvider.notifier).cancelScan();
|
||||
}
|
||||
|
||||
Future<void> _pauseScan() async {
|
||||
await ref.read(localLibraryProvider.notifier).pauseScan();
|
||||
}
|
||||
|
||||
Future<void> _resumeScan() async {
|
||||
await ref.read(localLibraryProvider.notifier).resumeScan();
|
||||
}
|
||||
|
||||
Future<void> _clearLibrary() async {
|
||||
final confirmed = await showAppDialog<bool>(
|
||||
context: context,
|
||||
@@ -557,6 +565,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
excludedDownloadedCount: libraryState.excludedDownloadedCount,
|
||||
isScanning: libraryState.isScanning,
|
||||
scanIsFinalizing: libraryState.scanIsFinalizing,
|
||||
scanIsPaused: libraryState.scanIsPaused,
|
||||
scanProgress: libraryState.scanProgress,
|
||||
scanCurrentFile: libraryState.scanCurrentFile,
|
||||
scanTotalFiles: libraryState.scanTotalFiles,
|
||||
@@ -627,6 +636,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
return (
|
||||
active: active,
|
||||
finalizing: active && state.scanIsFinalizing,
|
||||
paused: active && state.scanIsPaused,
|
||||
scanned: active ? state.scannedFiles : 0,
|
||||
total: active ? state.scanTotalFiles : 0,
|
||||
progress: active ? state.scanProgress : 0.0,
|
||||
@@ -637,6 +647,7 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
source: source,
|
||||
isScanning: scan.active,
|
||||
isFinalizing: scan.finalizing,
|
||||
isPaused: scan.paused,
|
||||
scannedFiles: scan.scanned,
|
||||
totalFiles: scan.total,
|
||||
progress: scan.progress,
|
||||
@@ -774,10 +785,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
|
||||
if (libraryState.isScanning)
|
||||
_ScanProgressTile(
|
||||
isFinalizing: libraryState.scanIsFinalizing,
|
||||
isPaused: libraryState.scanIsPaused,
|
||||
progress: libraryState.scanProgress,
|
||||
currentFile: libraryState.scanCurrentFile,
|
||||
scannedFiles: libraryState.scannedFiles,
|
||||
totalFiles: libraryState.scanTotalFiles,
|
||||
onPause: _pauseScan,
|
||||
onResume: _resumeScan,
|
||||
onCancel: _cancelScan,
|
||||
)
|
||||
else ...[
|
||||
@@ -858,6 +872,7 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
|
||||
final LocalLibrarySource source;
|
||||
final bool isScanning;
|
||||
final bool isFinalizing;
|
||||
final bool isPaused;
|
||||
final int scannedFiles;
|
||||
final int totalFiles;
|
||||
final double progress;
|
||||
@@ -871,6 +886,7 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
|
||||
required this.source,
|
||||
required this.isScanning,
|
||||
required this.isFinalizing,
|
||||
required this.isPaused,
|
||||
required this.scannedFiles,
|
||||
required this.totalFiles,
|
||||
required this.progress,
|
||||
@@ -960,6 +976,8 @@ class _LibrarySourceSettingsItem extends StatelessWidget {
|
||||
: isScanning
|
||||
? isFinalizing
|
||||
? context.l10n.libraryScanFinalizing
|
||||
: isPaused
|
||||
? context.l10n.libraryScanPaused
|
||||
: totalFiles > 0
|
||||
? context.l10n.librarySourceScanCount(
|
||||
scannedFiles,
|
||||
@@ -1066,6 +1084,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
final int excludedDownloadedCount;
|
||||
final bool isScanning;
|
||||
final bool scanIsFinalizing;
|
||||
final bool scanIsPaused;
|
||||
final double scanProgress;
|
||||
final String? scanCurrentFile;
|
||||
final int scanTotalFiles;
|
||||
@@ -1077,6 +1096,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
required this.excludedDownloadedCount,
|
||||
required this.isScanning,
|
||||
required this.scanIsFinalizing,
|
||||
required this.scanIsPaused,
|
||||
required this.scanProgress,
|
||||
this.scanCurrentFile,
|
||||
required this.scanTotalFiles,
|
||||
@@ -1103,6 +1123,7 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
final showIndeterminateProgress =
|
||||
isScanning &&
|
||||
!scanIsPaused &&
|
||||
(scanIsFinalizing ||
|
||||
scanTotalFiles <= 0 ||
|
||||
(scannedFiles <= 0 && scanProgress <= 0));
|
||||
@@ -1168,7 +1189,11 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(
|
||||
isScanning ? Icons.sync : Icons.music_note,
|
||||
isScanning
|
||||
? scanIsPaused
|
||||
? Icons.pause_rounded
|
||||
: Icons.sync
|
||||
: Icons.music_note,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 32,
|
||||
),
|
||||
@@ -1190,14 +1215,22 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
child: scanIsPaused
|
||||
? Icon(
|
||||
Icons.pause_rounded,
|
||||
size: 12,
|
||||
color: colorScheme.onPrimary,
|
||||
)
|
||||
: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
context.l10n.libraryScanning,
|
||||
scanIsPaused
|
||||
? context.l10n.libraryScanPaused
|
||||
: context.l10n.libraryScanning,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimary,
|
||||
fontSize: 12,
|
||||
@@ -1256,7 +1289,9 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
LinearProgressIndicator(
|
||||
value: showIndeterminateProgress
|
||||
? null
|
||||
: scanProgress / 100,
|
||||
: scanTotalFiles > 0
|
||||
? scanProgress / 100
|
||||
: 0,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -1270,6 +1305,8 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
scanProgress.toStringAsFixed(0),
|
||||
scanTotalFiles,
|
||||
)
|
||||
: scanIsPaused
|
||||
? context.l10n.libraryScanPaused
|
||||
: context.l10n.libraryScanning,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
@@ -1331,26 +1368,37 @@ class _LibraryHeroCard extends StatelessWidget {
|
||||
|
||||
class _ScanProgressTile extends StatelessWidget {
|
||||
final bool isFinalizing;
|
||||
final bool isPaused;
|
||||
final double progress;
|
||||
final String? currentFile;
|
||||
final int scannedFiles;
|
||||
final int totalFiles;
|
||||
final VoidCallback onPause;
|
||||
final VoidCallback onResume;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _ScanProgressTile({
|
||||
required this.isFinalizing,
|
||||
required this.isPaused,
|
||||
required this.progress,
|
||||
this.currentFile,
|
||||
required this.scannedFiles,
|
||||
required this.totalFiles,
|
||||
required this.onPause,
|
||||
required this.onResume,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
// A paused bar stays still at its last position instead of animating.
|
||||
final showIndeterminateProgress =
|
||||
isFinalizing || totalFiles <= 0 || (scannedFiles <= 0 && progress <= 0);
|
||||
!isPaused &&
|
||||
(isFinalizing ||
|
||||
totalFiles <= 0 ||
|
||||
(scannedFiles <= 0 && progress <= 0));
|
||||
final progressValue = totalFiles > 0 ? progress / 100 : 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
@@ -1359,14 +1407,19 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.scanner, color: colorScheme.primary),
|
||||
Icon(
|
||||
isPaused ? Icons.pause_circle_outline : Icons.scanner,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.libraryScanning,
|
||||
isPaused
|
||||
? context.l10n.libraryScanPaused
|
||||
: context.l10n.libraryScanning,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -1387,6 +1440,15 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isFinalizing)
|
||||
TextButton(
|
||||
onPressed: isPaused ? onResume : onPause,
|
||||
child: Text(
|
||||
isPaused
|
||||
? context.l10n.actionResume
|
||||
: context.l10n.actionPause,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: Text(context.l10n.actionCancel),
|
||||
@@ -1395,7 +1457,7 @@ class _ScanProgressTile extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(
|
||||
value: showIndeterminateProgress ? null : progress / 100,
|
||||
value: showIndeterminateProgress ? null : progressValue,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
color: colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
|
||||
@@ -539,6 +539,25 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showLibraryScanPaused({required int scannedFiles}) async {
|
||||
if (!_isInitialized) await initialize();
|
||||
|
||||
await _showSafely(
|
||||
id: libraryScanId,
|
||||
title: _l10n?.notifLibraryScanPaused ?? 'Library scan paused',
|
||||
body:
|
||||
_l10n?.notifLibraryScanPausedBody(scannedFiles) ??
|
||||
'$scannedFiles files scanned. Resume in Local Library settings to '
|
||||
'continue from here.',
|
||||
details: _details(library: true),
|
||||
);
|
||||
}
|
||||
|
||||
/// Clears a paused-scan notice once the foreground service shows progress.
|
||||
Future<void> cancelLibraryScanNotification() async {
|
||||
await _notifications.cancel(id: libraryScanId);
|
||||
}
|
||||
|
||||
Future<void> showUpdateDownloadProgress({
|
||||
required String version,
|
||||
required int received,
|
||||
|
||||
@@ -2346,6 +2346,15 @@ class PlatformBridge {
|
||||
await _channel.invokeMethod('cancelLibraryScan');
|
||||
}
|
||||
|
||||
/// Holds the running native scan at its next file; it keeps its position.
|
||||
static Future<void> pauseLibraryScan() async {
|
||||
await _channel.invokeMethod('pauseLibraryScan');
|
||||
}
|
||||
|
||||
static Future<void> resumeLibraryScan() async {
|
||||
await _channel.invokeMethod('resumeLibraryScan');
|
||||
}
|
||||
|
||||
static Object? _decodeJsonResult(dynamic result) {
|
||||
if (result is String) {
|
||||
if (result.isEmpty) return null;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
|
||||
class _ScanningLibrary extends LocalLibraryNotifier {
|
||||
_ScanningLibrary({this.finalizing = false});
|
||||
|
||||
final bool finalizing;
|
||||
|
||||
@override
|
||||
LocalLibraryState build() => LocalLibraryState(
|
||||
isScanning: true,
|
||||
scanIsFinalizing: finalizing,
|
||||
scannedFiles: 100,
|
||||
scanTotalFiles: 400,
|
||||
scanProgress: 25,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
const backend = MethodChannel('com.zarz.spotiflac/backend');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
late List<String> calls;
|
||||
|
||||
setUp(() {
|
||||
calls = <String>[];
|
||||
messenger.setMockMethodCallHandler(backend, (call) async {
|
||||
calls.add(call.method);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
tearDown(() => messenger.setMockMethodCallHandler(backend, null));
|
||||
|
||||
ProviderContainer containerFor(_ScanningLibrary library) {
|
||||
final container = ProviderContainer(
|
||||
overrides: [localLibraryProvider.overrideWith(() => library)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
return container;
|
||||
}
|
||||
|
||||
test('pause holds the scan in place and resume continues it', () async {
|
||||
final container = containerFor(_ScanningLibrary());
|
||||
final notifier = container.read(localLibraryProvider.notifier);
|
||||
|
||||
await notifier.pauseScan();
|
||||
var state = container.read(localLibraryProvider);
|
||||
expect(state.isScanning, isTrue);
|
||||
expect(state.scanIsPaused, isTrue);
|
||||
expect(state.scannedFiles, 100);
|
||||
|
||||
// Repeated pauses do not reach native code again.
|
||||
await notifier.pauseScan();
|
||||
expect(calls, ['pauseLibraryScan']);
|
||||
|
||||
await notifier.resumeScan();
|
||||
state = container.read(localLibraryProvider);
|
||||
expect(state.isScanning, isTrue);
|
||||
expect(state.scanIsPaused, isFalse);
|
||||
expect(state.scannedFiles, 100);
|
||||
expect(calls, ['pauseLibraryScan', 'resumeLibraryScan']);
|
||||
|
||||
await notifier.resumeScan();
|
||||
expect(calls, ['pauseLibraryScan', 'resumeLibraryScan']);
|
||||
});
|
||||
|
||||
test('cancel releases a paused scan without resuming it first', () async {
|
||||
final container = containerFor(_ScanningLibrary());
|
||||
final notifier = container.read(localLibraryProvider.notifier);
|
||||
|
||||
await notifier.pauseScan();
|
||||
await notifier.cancelScan();
|
||||
|
||||
final state = container.read(localLibraryProvider);
|
||||
expect(state.scanIsPaused, isFalse);
|
||||
expect(state.scanWasCancelled, isTrue);
|
||||
expect(calls, ['pauseLibraryScan', 'cancelLibraryScan']);
|
||||
|
||||
await notifier.resumeScan();
|
||||
expect(calls, ['pauseLibraryScan', 'cancelLibraryScan']);
|
||||
});
|
||||
|
||||
test('finalizing scans cannot be paused', () async {
|
||||
final container = containerFor(_ScanningLibrary(finalizing: true));
|
||||
final notifier = container.read(localLibraryProvider.notifier);
|
||||
|
||||
await notifier.pauseScan();
|
||||
|
||||
expect(container.read(localLibraryProvider).scanIsPaused, isFalse);
|
||||
expect(calls, isEmpty);
|
||||
});
|
||||
|
||||
test('a failed native pause leaves the scan running', () async {
|
||||
messenger.setMockMethodCallHandler(backend, (call) async {
|
||||
calls.add(call.method);
|
||||
throw PlatformException(code: 'pause-unavailable');
|
||||
});
|
||||
final container = containerFor(_ScanningLibrary());
|
||||
final notifier = container.read(localLibraryProvider.notifier);
|
||||
|
||||
await notifier.pauseScan();
|
||||
|
||||
expect(container.read(localLibraryProvider).scanIsPaused, isFalse);
|
||||
await notifier.resumeScan();
|
||||
expect(calls, ['pauseLibraryScan']);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user