From 69a9e0cb4067b0d7d195ad470e8ff8d0ab61595e Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 12 Feb 2026 01:06:17 +0700 Subject: [PATCH] feat: add library scan notifications - progress, complete, failed, cancelled notifications for local library scan - new notification channel for library scan - Android only --- lib/providers/local_library_provider.dart | 100 ++++++++++ lib/services/notification_service.dart | 224 ++++++++++++++++++++-- 2 files changed, 307 insertions(+), 17 deletions(-) diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 58028ddd..befe9171 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -6,6 +6,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -117,6 +118,7 @@ class LocalLibraryState { class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; final HistoryDatabase _historyDb = HistoryDatabase.instance; + final NotificationService _notificationService = NotificationService(); static const _progressPollingInterval = Duration(milliseconds: 800); Timer? _progressTimer; bool _isLoaded = false; @@ -255,6 +257,12 @@ class LocalLibraryNotifier extends Notifier { scanErrorCount: 0, scanWasCancelled: false, ); + await _showScanProgressNotification( + progress: 0, + scannedFiles: 0, + totalFiles: 0, + currentFile: null, + ); try { final appSupportDir = await getApplicationSupportDirectory(); @@ -299,6 +307,7 @@ class LocalLibraryNotifier extends Notifier { : await PlatformBridge.scanLibraryFolder(folderPath); if (_scanCancelRequested) { state = state.copyWith(isScanning: false, scanWasCancelled: true); + await _showScanCancelledNotification(); return; } @@ -344,6 +353,11 @@ class LocalLibraryNotifier extends Notifier { 'Full scan complete: ${items.length} tracks found, ' '$skippedDownloads already in downloads', ); + await _showScanCompleteNotification( + totalTracks: items.length, + excludedDownloadedCount: skippedDownloads, + errorCount: state.scanErrorCount, + ); } else { // Incremental scan path - only scans new/modified files final existingFiles = await _db.getFileModTimes(); @@ -377,6 +391,7 @@ class LocalLibraryNotifier extends Notifier { if (_scanCancelRequested) { state = state.copyWith(isScanning: false, scanWasCancelled: true); + await _showScanCancelledNotification(); return; } @@ -468,10 +483,16 @@ class LocalLibraryNotifier extends Notifier { '(${scannedList.length} new/updated, $skippedCount unchanged, ' '${deletedPaths.length} removed, $skippedDownloads already in downloads)', ); + await _showScanCompleteNotification( + totalTracks: items.length, + excludedDownloadedCount: skippedDownloads, + errorCount: state.scanErrorCount, + ); } } catch (e, stack) { _log.e('Library scan failed: $e', e, stack); state = state.copyWith(isScanning: false, scanWasCancelled: false); + await _showScanFailedNotification(e.toString()); } finally { _stopProgressPolling(); } @@ -510,6 +531,12 @@ class LocalLibraryNotifier extends Notifier { scannedFiles: scannedFiles, scanErrorCount: errorCount, ); + await _showScanProgressNotification( + progress: normalizedProgress, + scannedFiles: scannedFiles, + totalFiles: totalFiles, + currentFile: currentFile, + ); } if (progress['is_complete'] == true) { @@ -542,6 +569,79 @@ class LocalLibraryNotifier extends Notifier { await PlatformBridge.cancelLibraryScan(); state = state.copyWith(isScanning: false, scanWasCancelled: true); _stopProgressPolling(); + await _showScanCancelledNotification(); + } + + Future _showScanProgressNotification({ + required double progress, + required int scannedFiles, + required int totalFiles, + required String? currentFile, + }) async { + if (!Platform.isAndroid) return; + try { + await _notificationService.showLibraryScanProgress( + progress: progress, + scannedFiles: scannedFiles, + totalFiles: totalFiles, + currentFile: _shortenFileForNotification(currentFile), + ); + } catch (e) { + _log.w('Failed to show scan progress notification: $e'); + } + } + + Future _showScanCompleteNotification({ + required int totalTracks, + required int excludedDownloadedCount, + required int errorCount, + }) async { + if (!Platform.isAndroid) return; + try { + await _notificationService.showLibraryScanComplete( + totalTracks: totalTracks, + excludedDownloadedCount: excludedDownloadedCount, + errorCount: errorCount, + ); + } catch (e) { + _log.w('Failed to show scan complete notification: $e'); + } + } + + Future _showScanFailedNotification(String message) async { + if (!Platform.isAndroid) return; + try { + await _notificationService.showLibraryScanFailed(message); + } catch (e) { + _log.w('Failed to show scan failure notification: $e'); + } + } + + Future _showScanCancelledNotification() async { + if (!Platform.isAndroid) return; + try { + await _notificationService.showLibraryScanCancelled(); + } catch (e) { + _log.w('Failed to show scan cancelled notification: $e'); + } + } + + String? _shortenFileForNotification(String? path) { + final raw = path?.trim() ?? ''; + if (raw.isEmpty) return null; + + var decoded = raw; + try { + decoded = Uri.decodeFull(raw); + } catch (_) {} + + final slashIdx = decoded.lastIndexOf('/'); + final backslashIdx = decoded.lastIndexOf('\\'); + final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx; + if (cut >= 0 && cut < decoded.length - 1) { + return decoded.substring(cut + 1); + } + return decoded; } Future cleanupMissingFiles() async { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 430c779c..fc3976eb 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -6,19 +6,27 @@ class NotificationService { factory NotificationService() => _instance; NotificationService._internal(); - final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); + final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); bool _isInitialized = false; static const int downloadProgressId = 1; static const int updateDownloadId = 2; + static const int libraryScanId = 3; static const String channelId = 'download_progress'; static const String channelName = 'Download Progress'; static const String channelDescription = 'Shows download progress for tracks'; + static const String libraryChannelId = 'library_scan'; + static const String libraryChannelName = 'Library Scan'; + static const String libraryChannelDescription = + 'Shows local library scan progress'; Future initialize() async { if (_isInitialized) return; - const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); + const androidSettings = AndroidInitializationSettings( + '@mipmap/ic_launcher', + ); const iosSettings = DarwinInitializationSettings( requestAlertPermission: true, requestBadgePermission: true, @@ -33,19 +41,32 @@ class NotificationService { await _notifications.initialize(settings: initSettings); if (Platform.isAndroid) { - await _notifications - .resolvePlatformSpecificImplementation() - ?.createNotificationChannel( - const AndroidNotificationChannel( - channelId, - channelName, - description: channelDescription, - importance: Importance.low, - showBadge: false, - playSound: false, - enableVibration: false, - ), - ); + final androidImpl = _notifications + .resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin + >(); + await androidImpl?.createNotificationChannel( + const AndroidNotificationChannel( + channelId, + channelName, + description: channelDescription, + importance: Importance.low, + showBadge: false, + playSound: false, + enableVibration: false, + ), + ); + await androidImpl?.createNotificationChannel( + const AndroidNotificationChannel( + libraryChannelId, + libraryChannelName, + description: libraryChannelDescription, + importance: Importance.low, + showBadge: false, + playSound: false, + enableVibration: false, + ), + ); } _isInitialized = true; @@ -60,7 +81,7 @@ class NotificationService { if (!_isInitialized) await initialize(); final percentage = total > 0 ? (progress * 100 ~/ total) : 0; - + final androidDetails = AndroidNotificationDetails( channelId, channelName, @@ -234,6 +255,175 @@ class NotificationService { await _notifications.cancel(id: downloadProgressId); } + Future showLibraryScanProgress({ + required double progress, + required int scannedFiles, + required int totalFiles, + String? currentFile, + }) async { + if (!_isInitialized) await initialize(); + + final clampedProgress = progress.clamp(0.0, 100.0); + final percentage = clampedProgress.round(); + final progressBody = totalFiles > 0 + ? '$scannedFiles/$totalFiles files • $percentage%' + : '$scannedFiles files scanned • $percentage%'; + final body = (currentFile != null && currentFile.isNotEmpty) + ? '$progressBody\n$currentFile' + : progressBody; + + final androidDetails = AndroidNotificationDetails( + libraryChannelId, + libraryChannelName, + channelDescription: libraryChannelDescription, + importance: Importance.low, + priority: Priority.low, + showProgress: true, + maxProgress: 100, + progress: percentage, + ongoing: true, + autoCancel: false, + playSound: false, + enableVibration: false, + onlyAlertOnce: true, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: false, + presentBadge: false, + presentSound: false, + ); + + final details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + id: libraryScanId, + title: 'Scanning local library', + body: body, + notificationDetails: details, + ); + } + + Future showLibraryScanComplete({ + required int totalTracks, + int excludedDownloadedCount = 0, + int errorCount = 0, + }) async { + if (!_isInitialized) await initialize(); + + final extras = []; + if (excludedDownloadedCount > 0) { + extras.add('$excludedDownloadedCount excluded'); + } + if (errorCount > 0) { + extras.add('$errorCount errors'); + } + final suffix = extras.isEmpty ? '' : ' (${extras.join(', ')})'; + + const androidDetails = AndroidNotificationDetails( + libraryChannelId, + libraryChannelName, + channelDescription: libraryChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + playSound: false, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + id: libraryScanId, + title: 'Library scan complete', + body: '$totalTracks tracks indexed$suffix', + notificationDetails: details, + ); + } + + Future showLibraryScanFailed(String message) async { + if (!_isInitialized) await initialize(); + + const androidDetails = AndroidNotificationDetails( + libraryChannelId, + libraryChannelName, + channelDescription: libraryChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + playSound: false, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + id: libraryScanId, + title: 'Library scan failed', + body: message, + notificationDetails: details, + ); + } + + Future showLibraryScanCancelled() async { + if (!_isInitialized) await initialize(); + + const androidDetails = AndroidNotificationDetails( + libraryChannelId, + libraryChannelName, + channelDescription: libraryChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + autoCancel: true, + playSound: false, + icon: '@mipmap/ic_launcher', + ); + + const iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBadge: false, + presentSound: false, + ); + + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _notifications.show( + id: libraryScanId, + title: 'Library scan cancelled', + body: 'Scan stopped before completion.', + notificationDetails: details, + ); + } + + Future cancelLibraryScanNotification() async { + await _notifications.cancel(id: libraryScanId); + } + Future showUpdateDownloadProgress({ required String version, required int received, @@ -244,7 +434,7 @@ class NotificationService { final percentage = total > 0 ? (received * 100 ~/ total) : 0; final receivedMB = (received / 1024 / 1024).toStringAsFixed(1); final totalMB = (total / 1024 / 1024).toStringAsFixed(1); - + final androidDetails = AndroidNotificationDetails( channelId, channelName,