Files
SpotiFLAC-Mobile/lib/screens/settings/cache_management_page.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

748 lines
26 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:spotiflac_android/widgets/app_action_button.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
class CacheManagementPage extends ConsumerStatefulWidget {
const CacheManagementPage({super.key});
@override
ConsumerState<CacheManagementPage> createState() =>
_CacheManagementPageState();
}
class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
// Keep in sync with ExploreNotifier keys.
static const String _exploreCacheKey = 'explore_home_feed_cache';
static const String _exploreCacheTsKey = 'explore_home_feed_ts';
_CacheOverview? _overview;
bool _isLoading = true;
String? _busyAction;
@override
void initState() {
super.initState();
_refreshOverview();
}
bool get _isBusy => _busyAction != null;
Future<void> _refreshOverview() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final overview = await _buildOverview();
if (!mounted) return;
setState(() {
_overview = overview;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
}
}
Future<_CacheOverview> _buildOverview() async {
final appCacheDirFuture = getApplicationCacheDirectory();
final tempDirFuture = getTemporaryDirectory();
final appSupportDirFuture = getApplicationSupportDirectory();
final coverStatsFuture = CoverCacheManager.getStats();
final prefsFuture = SharedPreferences.getInstance();
final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
final appCacheDir = await appCacheDirFuture;
final tempDir = await tempDirFuture;
final appCachePath = p.normalize(appCacheDir.path);
final tempPath = p.normalize(tempDir.path);
final tempIsSameAsAppCache = appCachePath == tempPath;
final appCacheStatsFuture = _scanDirectory(Directory(appCachePath));
final tempStatsFuture = tempIsSameAsAppCache
? Future<_DirectoryStats?>.value(null)
: _scanDirectory(Directory(tempPath));
final appSupportDir = await appSupportDirFuture;
final libraryCoverStatsFuture = _scanDirectory(
Directory('${appSupportDir.path}/library_covers'),
);
final audioAnalysisStatsFuture = _scanDirectory(
Directory('${appSupportDir.path}/audio_analysis_cache'),
);
final prefs = await prefsFuture;
final explorePayload = prefs.getString(_exploreCacheKey);
final exploreTs = prefs.getInt(_exploreCacheTsKey);
var exploreBytes = 0;
if (explorePayload != null && explorePayload.isNotEmpty) {
exploreBytes += utf8.encode(explorePayload).length;
}
if (exploreTs != null) {
exploreBytes += 8;
}
final hasExploreCache = exploreBytes > 0;
final appCacheStats = await appCacheStatsFuture;
final tempStats = await tempStatsFuture;
final coverStats = await coverStatsFuture;
final libraryCoverStats = await libraryCoverStatsFuture;
final audioAnalysisStats = await audioAnalysisStatsFuture;
final trackCacheEntries = await trackCacheEntriesFuture;
return _CacheOverview(
appCachePath: appCachePath,
appCacheStats: appCacheStats,
tempPath: tempIsSameAsAppCache ? null : tempPath,
tempStats: tempStats,
tempIsSameAsAppCache: tempIsSameAsAppCache,
coverStats: coverStats,
libraryCoverStats: libraryCoverStats,
audioAnalysisStats: audioAnalysisStats,
exploreCacheBytes: exploreBytes,
hasExploreCache: hasExploreCache,
trackCacheEntries: trackCacheEntries,
);
}
Future<_DirectoryStats> _scanDirectory(Directory directory) async {
if (!await directory.exists()) {
return const _DirectoryStats(fileCount: 0, totalSizeBytes: 0);
}
var fileCount = 0;
var totalSize = 0;
try {
await for (final entity in directory.list(
recursive: true,
followLinks: false,
)) {
if (entity is File) {
fileCount++;
totalSize += await entity.length();
}
}
} catch (_) {}
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
Future<int> _getTrackCacheSizeSafe() async {
try {
return await PlatformBridge.getTrackCacheSize();
} catch (_) {
return 0;
}
}
Future<void> _clearDirectoryContents(String path) async {
final directory = Directory(path);
if (!await directory.exists()) return;
try {
final entities = <FileSystemEntity>[];
await for (final entity in directory.list(followLinks: false)) {
entities.add(entity);
}
const deleteChunkSize = 24;
for (var i = 0; i < entities.length; i += deleteChunkSize) {
final end = (i + deleteChunkSize < entities.length)
? i + deleteChunkSize
: entities.length;
final chunk = entities.sublist(i, end);
await Future.wait(
chunk.map((entity) async {
try {
await entity.delete(recursive: true);
} catch (_) {}
}),
);
}
} catch (_) {}
try {
await directory.create(recursive: true);
} catch (_) {}
}
Future<void> _clearAppCache() async {
await DownloadedEmbeddedCoverResolver.clearPersistentCache();
final cacheDir = await getApplicationCacheDirectory();
await _clearDirectoryContents(cacheDir.path);
}
Future<void> _clearTempCache() async {
final tempDir = await getTemporaryDirectory();
await _clearDirectoryContents(tempDir.path);
}
Future<void> _clearCoverCache() async {
await CoverCacheManager.clearCache();
}
Future<void> _clearLibraryCoverCache() async {
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
await _clearDirectoryContents(libraryCoverDir.path);
}
Future<void> _clearAudioAnalysisCache() async {
final appSupportDir = await getApplicationSupportDirectory();
final analysisDir = Directory('${appSupportDir.path}/audio_analysis_cache');
await _clearDirectoryContents(analysisDir.path);
}
Future<void> _clearExploreCache() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_exploreCacheKey);
await prefs.remove(_exploreCacheTsKey);
}
Future<void> _clearTrackCache() async {
await PlatformBridge.clearTrackCache();
}
Future<void> _clearAllCaches() async {
final currentOverview = _overview;
await _clearAppCache();
if (currentOverview != null && !currentOverview.tempIsSameAsAppCache) {
await _clearTempCache();
}
await _clearCoverCache();
await _clearLibraryCoverCache();
await _clearAudioAnalysisCache();
await _clearExploreCache();
await _clearTrackCache();
}
Future<bool> _confirmClear(String target) async {
final confirm = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.cacheClearConfirmTitle),
content: Text(context.l10n.cacheClearConfirmMessage(target)),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<bool> _confirmClearAll() async {
final confirm = await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.cacheClearAllConfirmTitle),
content: Text(context.l10n.cacheClearAllConfirmMessage),
actions: [
AppDialogAction(
isDefault: true,
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
isDestructive: true,
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.dialogClear),
),
],
),
);
return confirm == true;
}
Future<void> _runAction(
String actionKey,
Future<void> Function() action, {
String? successMessage,
}) async {
if (_isBusy || !mounted) return;
setState(() => _busyAction = actionKey);
try {
await action();
if (!mounted) return;
if (successMessage != null && successMessage.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(successMessage)));
}
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarError(context.friendlyError(e))),
),
);
} finally {
if (mounted) {
setState(() => _busyAction = null);
await _refreshOverview();
}
}
}
Future<void> _confirmAndRunAction({
required String actionKey,
required String targetLabel,
required Future<void> Function() action,
}) async {
final confirmed = await _confirmClear(targetLabel);
if (!confirmed) return;
if (!mounted) return;
await _runAction(
actionKey,
action,
successMessage: context.l10n.cacheClearSuccess(targetLabel),
);
}
Future<void> _cleanupUnusedData() async {
await _runAction('cleanup_unused', () async {
final orphanedDownloads = await ref
.read(downloadHistoryProvider.notifier)
.cleanupOrphanedDownloads();
final iosBookmark = ref.read(settingsProvider).localLibraryBookmark;
final missingLibraryEntries = await ref
.read(localLibraryProvider.notifier)
.cleanupMissingFiles(
iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null,
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.cacheCleanupResult(
orphanedDownloads,
missingLibraryEntries,
),
),
),
);
});
}
String _formatDirectorySize(_DirectoryStats stats) {
if (stats.fileCount == 0 || stats.totalSizeBytes == 0) {
return context.l10n.cacheNoData;
}
return context.l10n.cacheSizeWithFiles(
formatBytes(stats.totalSizeBytes),
stats.fileCount,
);
}
String _buildSubtitle(String description, String sizeInfo) {
return '$description\n$sizeInfo';
}
Widget _buildClearTrailing(String actionKey, VoidCallback onPressed) {
if (_busyAction == actionKey) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
return TextButton(
onPressed: _isBusy ? null : onPressed,
child: Text(context.l10n.dialogClear),
);
}
Widget _overviewSurface({required Widget child}) {
final scheme = Theme.of(context).colorScheme;
final mornye = context.isMornye;
final content = Padding(padding: const EdgeInsets.all(16), child: child);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: DecoratedBox(
decoration: BoxDecoration(
color: mornye
? scheme.surfaceContainer
: scheme.primaryContainer.withValues(alpha: 0.28),
borderRadius: BorderRadius.circular(mornye ? 28 : 18),
),
child: content,
),
);
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final mornye = context.isMornye;
final overview = _overview;
return Scaffold(
body: CustomScrollView(
slivers: [
AppSliverHeader.page(
title: context.l10n.cacheTitle,
actions: [
IconButton(
tooltip: context.l10n.cacheRefresh,
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
),
],
),
if (_isLoading || overview == null)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else ...[
SliverToBoxAdapter(
child: _overviewSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.cacheSummaryTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: mornye
? colorScheme.onSurface
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 6),
Text(
context.l10n.cacheEstimatedTotal(
formatBytes(overview.totalKnownDiskCacheBytes),
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: mornye
? colorScheme.onSurface
: colorScheme.onPrimaryContainer,
),
),
const SizedBox(height: 2),
Text(
context.l10n.cacheSummarySubtitle,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: mornye
? colorScheme.onSurfaceVariant
: colorScheme.onPrimaryContainer.withValues(
alpha: 0.85,
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
AppActionButton(
isDestructive: true,
style: FilledButton.styleFrom(
backgroundColor: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
),
onPressed: _isBusy
? null
: () async {
final l10n = context.l10n;
final confirmed = await _confirmClearAll();
if (!confirmed) return;
if (!mounted) return;
await _runAction(
'clear_all',
_clearAllCaches,
successMessage: l10n.cacheClearSuccess(
l10n.cacheClearAll,
),
);
},
icon: const Icon(Icons.delete_sweep_outlined),
label: Text(context.l10n.cacheClearAll),
),
AppActionButton(
outlined: true,
glass: false,
onPressed: _isBusy ? null : _refreshOverview,
icon: const Icon(Icons.refresh),
label: Text(context.l10n.cacheRefreshStats),
),
],
),
],
),
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionStorage,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.folder_outlined,
title: context.l10n.cacheAppDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheAppDirectoryDesc,
_formatDirectorySize(overview.appCacheStats),
),
trailing: _buildClearTrailing(
'clear_app_cache',
() => _confirmAndRunAction(
actionKey: 'clear_app_cache',
targetLabel: context.l10n.cacheAppDirectory,
action: _clearAppCache,
),
),
),
if (!overview.tempIsSameAsAppCache &&
overview.tempStats != null)
SettingsItem(
icon: Icons.timer_outlined,
title: context.l10n.cacheTempDirectory,
subtitle: _buildSubtitle(
context.l10n.cacheTempDirectoryDesc,
_formatDirectorySize(overview.tempStats!),
),
trailing: _buildClearTrailing(
'clear_temp_cache',
() => _confirmAndRunAction(
actionKey: 'clear_temp_cache',
targetLabel: context.l10n.cacheTempDirectory,
action: _clearTempCache,
),
),
),
SettingsItem(
icon: Icons.image_outlined,
title: context.l10n.cacheCoverImage,
subtitle: _buildSubtitle(
context.l10n.cacheCoverImageDesc,
overview.coverStats.fileCount > 0 &&
overview.coverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
formatBytes(overview.coverStats.totalSizeBytes),
overview.coverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_cover_cache',
targetLabel: context.l10n.cacheCoverImage,
action: _clearCoverCache,
),
),
),
SettingsItem(
icon: Icons.library_music_outlined,
title: context.l10n.cacheLibraryCover,
subtitle: _buildSubtitle(
context.l10n.cacheLibraryCoverDesc,
overview.libraryCoverStats.fileCount > 0 &&
overview.libraryCoverStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
formatBytes(
overview.libraryCoverStats.totalSizeBytes,
),
overview.libraryCoverStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_library_cover_cache',
() => _confirmAndRunAction(
actionKey: 'clear_library_cover_cache',
targetLabel: context.l10n.cacheLibraryCover,
action: _clearLibraryCoverCache,
),
),
),
SettingsItem(
icon: Icons.graphic_eq_outlined,
title: context.l10n.cacheAudioAnalysis,
subtitle: _buildSubtitle(
context.l10n.cacheAudioAnalysisDesc,
overview.audioAnalysisStats.fileCount > 0 &&
overview.audioAnalysisStats.totalSizeBytes > 0
? context.l10n.cacheSizeWithFiles(
formatBytes(
overview.audioAnalysisStats.totalSizeBytes,
),
overview.audioAnalysisStats.fileCount,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_audio_analysis_cache',
() => _confirmAndRunAction(
actionKey: 'clear_audio_analysis_cache',
targetLabel: context.l10n.cacheAudioAnalysis,
action: _clearAudioAnalysisCache,
),
),
),
SettingsItem(
icon: Icons.explore_outlined,
title: context.l10n.cacheExploreFeed,
subtitle: _buildSubtitle(
context.l10n.cacheExploreFeedDesc,
overview.hasExploreCache
? context.l10n.cacheSizeOnly(
formatBytes(overview.exploreCacheBytes),
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_explore_cache',
() => _confirmAndRunAction(
actionKey: 'clear_explore_cache',
targetLabel: context.l10n.cacheExploreFeed,
action: _clearExploreCache,
),
),
),
SettingsItem(
icon: Icons.memory_outlined,
title: context.l10n.cacheTrackLookup,
subtitle: _buildSubtitle(
context.l10n.cacheTrackLookupDesc,
overview.trackCacheEntries > 0
? context.l10n.cacheEntries(
overview.trackCacheEntries,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
'clear_track_cache',
() => _confirmAndRunAction(
actionKey: 'clear_track_cache',
targetLabel: context.l10n.cacheTrackLookup,
action: _clearTrackCache,
),
),
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.cacheSectionMaintenance,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cacheCleanupUnused,
subtitle:
'${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
trailing: _buildClearTrailing(
'cleanup_unused',
_cleanupUnusedData,
),
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
],
),
);
}
}
class _CacheOverview {
final String appCachePath;
final _DirectoryStats appCacheStats;
final String? tempPath;
final _DirectoryStats? tempStats;
final bool tempIsSameAsAppCache;
final CacheStats coverStats;
final _DirectoryStats libraryCoverStats;
final _DirectoryStats audioAnalysisStats;
final int exploreCacheBytes;
final bool hasExploreCache;
final int trackCacheEntries;
const _CacheOverview({
required this.appCachePath,
required this.appCacheStats,
this.tempPath,
this.tempStats,
required this.tempIsSameAsAppCache,
required this.coverStats,
required this.libraryCoverStats,
required this.audioAnalysisStats,
required this.exploreCacheBytes,
required this.hasExploreCache,
required this.trackCacheEntries,
});
int get totalKnownDiskCacheBytes {
return appCacheStats.totalSizeBytes +
(tempStats?.totalSizeBytes ?? 0) +
coverStats.totalSizeBytes +
libraryCoverStats.totalSizeBytes +
exploreCacheBytes;
}
}
class _DirectoryStats {
final int fileCount;
final int totalSizeBytes;
const _DirectoryStats({
required this.fileCount,
required this.totalSizeBytes,
});
}