Better caching for mobile (#521)
* Use custom caches in all modules * Cache Settings * Fix wrong key * Create custom cache repository based on hive * Show cache usage in settings * Show cache sizes * Change settings ranges and default value * Handle cache clear by operating system * Resolve review comments
This commit is contained in:
parent
e527685ebf
commit
25e68cf826
17 changed files with 593 additions and 44 deletions
|
@ -149,5 +149,18 @@
|
|||
"setting_notifications_notify_immediately": "immediately",
|
||||
"setting_notifications_notify_minutes": "{} minutes",
|
||||
"setting_notifications_notify_hours": "{} hours",
|
||||
"setting_notifications_notify_never": "never"
|
||||
"setting_notifications_notify_never": "never",
|
||||
"cache_settings_title": "Caching Settings",
|
||||
"cache_settings_subtitle": "Control the caching behaviour of the Immich mobile application",
|
||||
"cache_settings_thumbnail_size": "Thumbnail cache size ({} assets)",
|
||||
"cache_settings_image_cache_size": "Image cache size ({} assets)",
|
||||
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
|
||||
"cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
|
||||
"cache_settings_clear_cache_button": "Clear cache",
|
||||
"cache_settings_statistics_title": "Cache usage",
|
||||
"cache_settings_statistics_assets": "{} assets ({})",
|
||||
"cache_settings_statistics_thumbnail": "Thumbnails",
|
||||
"cache_settings_statistics_album": "Library thumbnails",
|
||||
"cache_settings_statistics_shared": "Shared album thumbnails",
|
||||
"cache_settings_statistics_full": "Full images"
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
@ -8,6 +9,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
|
|||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
|
@ -15,17 +17,18 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
const AlbumViewerThumbnail({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.cacheManager,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||
var deviceId = ref.watch(authenticationProvider).deviceId;
|
||||
|
@ -123,7 +126,8 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
|
|||
return Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
cacheManager: cacheManager,
|
||||
cacheKey: asset.id,
|
||||
width: 300,
|
||||
height: 300,
|
||||
memCacheHeight: 200,
|
||||
|
|
|
@ -5,6 +5,8 @@ import 'package:hive_flutter/hive_flutter.dart';
|
|||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class SelectionThumbnailImage extends HookConsumerWidget {
|
||||
|
@ -15,15 +17,14 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl =
|
||||
'${box.get(serverEndpointKey)}/asset/thumbnail/${asset.id}';
|
||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||
var selectedAsset =
|
||||
ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
|
||||
var newAssetsForAlbum =
|
||||
ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
|
||||
var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
Widget _buildSelectionIcon(AssetResponseDto asset) {
|
||||
var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
|
||||
|
@ -113,7 +114,8 @@ class SelectionThumbnailImage extends HookConsumerWidget {
|
|||
Container(
|
||||
decoration: BoxDecoration(border: drawBorderColor()),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
cacheKey: asset.id,
|
||||
width: 150,
|
||||
height: 150,
|
||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
|
||||
|
|
|
@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/hive_box.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
|
@ -15,8 +16,7 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
var box = Hive.box(userInfoBox);
|
||||
|
||||
return GestureDetector(
|
||||
|
@ -26,7 +26,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
|
|||
child: Stack(
|
||||
children: [
|
||||
CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
cacheKey: asset.id,
|
||||
width: 500,
|
||||
height: 500,
|
||||
memCacheHeight: 500,
|
||||
|
|
|
@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
|
|||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
|
@ -191,6 +192,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final bool showStorageIndicator =
|
||||
appSettingService.getSetting(AppSettingsEnum.storageIndicator);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
if (albumInfo.assets.isNotEmpty) {
|
||||
return SliverPadding(
|
||||
|
@ -205,6 +207,7 @@ class AlbumViewerPage extends HookConsumerWidget {
|
|||
delegate: SliverChildBuilderDelegate(
|
||||
(BuildContext context, int index) {
|
||||
return AlbumViewerThumbnail(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
asset: albumInfo.assets[index],
|
||||
assetList: albumInfo.assets,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
|
||||
enum _RemoteImageStatus { empty, thumbnail, preview, full }
|
||||
|
@ -63,11 +64,13 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||
widget.onLoadingCompleted();
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider _authorizedImageProvider(String url) {
|
||||
CachedNetworkImageProvider _authorizedImageProvider(
|
||||
String url, String cacheKey, BaseCacheManager? cacheManager) {
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"Authorization": widget.authToken},
|
||||
cacheKey: url,
|
||||
cacheKey: cacheKey,
|
||||
cacheManager: cacheManager,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -101,8 +104,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||
}
|
||||
|
||||
void _loadImages() {
|
||||
CachedNetworkImageProvider thumbnailProvider =
|
||||
_authorizedImageProvider(widget.thumbnailUrl);
|
||||
CachedNetworkImageProvider thumbnailProvider = _authorizedImageProvider(
|
||||
widget.thumbnailUrl,
|
||||
widget.cacheKey,
|
||||
widget.thumbnailCacheManager,
|
||||
);
|
||||
_imageProvider = thumbnailProvider;
|
||||
|
||||
thumbnailProvider.resolve(const ImageConfiguration()).addListener(
|
||||
|
@ -115,8 +121,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||
);
|
||||
|
||||
if (widget.previewUrl != null) {
|
||||
CachedNetworkImageProvider previewProvider =
|
||||
_authorizedImageProvider(widget.previewUrl!);
|
||||
CachedNetworkImageProvider previewProvider = _authorizedImageProvider(
|
||||
widget.previewUrl!,
|
||||
"${widget.cacheKey}_previewStage",
|
||||
widget.previewCacheManager,
|
||||
);
|
||||
previewProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.preview, previewProvider);
|
||||
|
@ -124,8 +133,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
|
|||
);
|
||||
}
|
||||
|
||||
CachedNetworkImageProvider fullProvider =
|
||||
_authorizedImageProvider(widget.imageUrl);
|
||||
CachedNetworkImageProvider fullProvider = _authorizedImageProvider(
|
||||
widget.imageUrl,
|
||||
"${widget.cacheKey}_fullStage",
|
||||
widget.fullCacheManager,
|
||||
);
|
||||
fullProvider.resolve(const ImageConfiguration()).addListener(
|
||||
ImageStreamListener((ImageInfo imageInfo, _) {
|
||||
_performStateTransition(_RemoteImageStatus.full, fullProvider);
|
||||
|
@ -153,6 +165,10 @@ class RemotePhotoView extends StatefulWidget {
|
|||
this.previewUrl,
|
||||
required this.onLoadingCompleted,
|
||||
required this.onLoadingStart,
|
||||
this.thumbnailCacheManager,
|
||||
this.previewCacheManager,
|
||||
this.fullCacheManager,
|
||||
required this.cacheKey,
|
||||
}) : super(key: key);
|
||||
|
||||
final String thumbnailUrl;
|
||||
|
@ -161,6 +177,10 @@ class RemotePhotoView extends StatefulWidget {
|
|||
final String? previewUrl;
|
||||
final Function onLoadingCompleted;
|
||||
final Function onLoadingStart;
|
||||
final BaseCacheManager? thumbnailCacheManager;
|
||||
final BaseCacheManager? previewCacheManager;
|
||||
final BaseCacheManager? fullCacheManager;
|
||||
final String cacheKey;
|
||||
|
||||
final void Function() onSwipeDown;
|
||||
final void Function() onSwipeUp;
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
|
|||
import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
|
||||
import 'package:immich_mobile/modules/home/services/asset.service.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
|
@ -40,6 +41,7 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final downloadAssetStatus =
|
||||
ref.watch(imageViewerStateProvider).downloadAssetStatus;
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
getAssetExif() async {
|
||||
assetDetail =
|
||||
|
@ -73,6 +75,7 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||
tag: heroTag,
|
||||
child: RemotePhotoView(
|
||||
thumbnailUrl: getThumbnailUrl(asset),
|
||||
cacheKey: asset.id,
|
||||
imageUrl: getImageUrl(asset),
|
||||
previewUrl: threeStageLoading
|
||||
? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
|
||||
|
@ -84,6 +87,12 @@ class ImageViewerPage extends HookConsumerWidget {
|
|||
onSwipeUp: () => showInfo(),
|
||||
onLoadingCompleted: onLoadingCompleted,
|
||||
onLoadingStart: onLoadingStart,
|
||||
thumbnailCacheManager:
|
||||
cacheService.getCache(CacheType.thumbnail),
|
||||
previewCacheManager:
|
||||
cacheService.getCache(CacheType.imageViewerPreview),
|
||||
fullCacheManager:
|
||||
cacheService.getCache(CacheType.imageViewerFull),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/thumbnail_image.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
@ -9,11 +10,13 @@ class ImageGrid extends ConsumerWidget {
|
|||
final List<AssetResponseDto> sortedAssetGroup;
|
||||
final int tilesPerRow;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
ImageGrid({
|
||||
Key? key,
|
||||
required this.assetGroup,
|
||||
required this.sortedAssetGroup,
|
||||
this.cacheManager,
|
||||
this.tilesPerRow = 4,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
@ -36,6 +39,7 @@ class ImageGrid extends ConsumerWidget {
|
|||
child: Stack(
|
||||
children: [
|
||||
ThumbnailImage(
|
||||
cacheManager: cacheManager,
|
||||
asset: assetGroup[index],
|
||||
assetList: sortedAssetGroup,
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
@ -16,18 +17,18 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||
final AssetResponseDto asset;
|
||||
final List<AssetResponseDto> assetList;
|
||||
final bool showStorageIndicator;
|
||||
final BaseCacheManager? cacheManager;
|
||||
|
||||
const ThumbnailImage(
|
||||
{Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.showStorageIndicator = true})
|
||||
: super(key: key);
|
||||
const ThumbnailImage({
|
||||
Key? key,
|
||||
required this.asset,
|
||||
required this.assetList,
|
||||
this.cacheManager,
|
||||
this.showStorageIndicator = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final cacheKey = useState(1);
|
||||
|
||||
var box = Hive.box(userInfoBox);
|
||||
var thumbnailRequestUrl = getThumbnailUrl(asset);
|
||||
var selectedAsset = ref.watch(homePageStateProvider).selectedItems;
|
||||
|
@ -94,7 +95,8 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||
: const Border(),
|
||||
),
|
||||
child: CachedNetworkImage(
|
||||
cacheKey: "${asset.id}-${cacheKey.value}",
|
||||
cacheKey: asset.id,
|
||||
cacheManager: cacheManager,
|
||||
width: 300,
|
||||
height: 300,
|
||||
memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 250 : 400,
|
||||
|
@ -128,17 +130,18 @@ class ThumbnailImage extends HookConsumerWidget {
|
|||
child: _buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator) Positioned(
|
||||
right: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
(deviceId != asset.deviceId)
|
||||
? Icons.cloud_done_outlined
|
||||
: Icons.photo_library_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
)
|
||||
if (showStorageIndicator)
|
||||
Positioned(
|
||||
right: 10,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
(deviceId != asset.deviceId)
|
||||
? Icons.cloud_done_outlined
|
||||
: Icons.photo_library_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/settings/services/app_settings.service.dar
|
|||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
|
@ -24,6 +25,7 @@ class HomePage extends HookConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
final cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
ScrollController scrollController = useScrollController();
|
||||
var assetGroupByDateTime = ref.watch(assetGroupByDateTimeProvider);
|
||||
|
@ -89,6 +91,7 @@ class HomePage extends HookConsumerWidget {
|
|||
|
||||
imageGridGroup.add(
|
||||
ImageGrid(
|
||||
cacheManager: cacheService.getCache(CacheType.thumbnail),
|
||||
assetGroup: immichAssetList,
|
||||
sortedAssetGroup: sortedAssetList,
|
||||
tilesPerRow:
|
||||
|
|
|
@ -7,7 +7,10 @@ enum AppSettingsEnum<T> {
|
|||
tilesPerRow<int>("tilesPerRow", 4),
|
||||
uploadErrorNotificationGracePeriod<int>(
|
||||
"uploadErrorNotificationGracePeriod", 2),
|
||||
storageIndicator<bool>("storageIndicator", true);
|
||||
storageIndicator<bool>("storageIndicator", true),
|
||||
thumbnailCacheSize<int>("thumbnailCacheSize", 10000),
|
||||
imageCacheSize<int>("imageCacheSize", 350),
|
||||
albumThumbnailCacheSize<int>("albumThumbnailCacheSize", 200);
|
||||
|
||||
const AppSettingsEnum(this.hiveKey, this.defaultValue);
|
||||
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings_slider_pref.dart';
|
||||
import 'package:immich_mobile/shared/services/cache.service.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
|
||||
class CacheSettings extends HookConsumerWidget {
|
||||
const CacheSettings({
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final CacheService cacheService = ref.watch(cacheServiceProvider);
|
||||
|
||||
final clearCacheState = useState(false);
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await cacheService.emptyAllCaches();
|
||||
clearCacheState.value = true;
|
||||
}
|
||||
|
||||
Widget cacheStatisticsRow(String name, CacheType type) {
|
||||
final cacheSize = useState(0);
|
||||
final cacheAssets = useState(0);
|
||||
|
||||
if (!clearCacheState.value) {
|
||||
final repo = cacheService.getCacheRepo(type);
|
||||
|
||||
repo.open().then((_) {
|
||||
cacheSize.value = repo.getCacheSize();
|
||||
cacheAssets.value = repo.getNumberOfCachedObjects();
|
||||
});
|
||||
} else {
|
||||
cacheSize.value = 0;
|
||||
cacheAssets.value = 0;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(left: 20, bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
"cache_settings_statistics_assets",
|
||||
style: TextStyle(color: Colors.grey),
|
||||
).tr(
|
||||
args: ["${cacheAssets.value}", formatBytes(cacheSize.value)],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ExpansionTile(
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
textColor: Theme.of(context).primaryColor,
|
||||
title: const Text(
|
||||
'cache_settings_title',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
subtitle: const Text(
|
||||
'cache_settings_subtitle',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
),
|
||||
).tr(),
|
||||
children: [
|
||||
const CacheSettingsSliderPref(
|
||||
setting: AppSettingsEnum.thumbnailCacheSize,
|
||||
translationKey: "cache_settings_thumbnail_size",
|
||||
min: 1000,
|
||||
max: 20000,
|
||||
divisions: 19,
|
||||
),
|
||||
const CacheSettingsSliderPref(
|
||||
setting: AppSettingsEnum.imageCacheSize,
|
||||
translationKey: "cache_settings_image_cache_size",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
divisions: 20,
|
||||
),
|
||||
const CacheSettingsSliderPref(
|
||||
setting: AppSettingsEnum.albumThumbnailCacheSize,
|
||||
translationKey: "cache_settings_album_thumbnails",
|
||||
min: 0,
|
||||
max: 1000,
|
||||
divisions: 20,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
"cache_settings_statistics_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
cacheStatisticsRow(
|
||||
"cache_settings_statistics_thumbnail".tr(), CacheType.thumbnail),
|
||||
cacheStatisticsRow(
|
||||
"cache_settings_statistics_album".tr(), CacheType.albumThumbnail),
|
||||
cacheStatisticsRow("cache_settings_statistics_shared".tr(),
|
||||
CacheType.sharedAlbumThumbnail),
|
||||
cacheStatisticsRow(
|
||||
"cache_settings_statistics_full".tr(), CacheType.imageViewerFull),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
"cache_settings_clear_cache_button_title",
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
Container(
|
||||
alignment: Alignment.center,
|
||||
child: TextButton(
|
||||
onPressed: clearCache,
|
||||
child: Text(
|
||||
"cache_settings_clear_cache_button",
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
|
||||
class CacheSettingsSliderPref extends HookConsumerWidget {
|
||||
final AppSettingsEnum<int> setting;
|
||||
final String translationKey;
|
||||
final int min;
|
||||
final int max;
|
||||
final int divisions;
|
||||
|
||||
const CacheSettingsSliderPref({
|
||||
Key? key,
|
||||
required this.setting,
|
||||
required this.translationKey,
|
||||
required this.min,
|
||||
required this.max,
|
||||
required this.divisions,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final appSettingService = ref.watch(appSettingsServiceProvider);
|
||||
|
||||
final itemsValue = useState(appSettingService.getSetting<int>(setting));
|
||||
|
||||
void sliderChanged(double value) {
|
||||
itemsValue.value = value.toInt();
|
||||
}
|
||||
|
||||
void sliderChangedEnd(double value) {
|
||||
appSettingService.setSetting(setting, value.toInt());
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(
|
||||
translationKey,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(args: ["${itemsValue.value.toInt()}"]),
|
||||
),
|
||||
Slider(
|
||||
onChangeEnd: sliderChangedEnd,
|
||||
onChanged: sliderChanged,
|
||||
value: itemsValue.value.toDouble(),
|
||||
min: min.toDouble(),
|
||||
max: max.toDouble(),
|
||||
divisions: divisions,
|
||||
label: "${itemsValue.value.toInt()}",
|
||||
activeColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/cache_settings/cache_settings.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
|
||||
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
|
||||
|
@ -41,6 +42,7 @@ class SettingsPage extends HookConsumerWidget {
|
|||
const ImageViewerQualitySetting(),
|
||||
const ThemeSetting(),
|
||||
const AssetListSettings(),
|
||||
const CacheSettings(),
|
||||
if (Platform.isAndroid) const NotificationSetting(),
|
||||
],
|
||||
).toList(),
|
||||
|
|
|
@ -1,21 +1,79 @@
|
|||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/immich_cache_info_repository.dart';
|
||||
|
||||
enum CacheType {
|
||||
// Shared cache for asset thumbnails in various modules
|
||||
thumbnail,
|
||||
|
||||
imageViewerPreview,
|
||||
imageViewerFull,
|
||||
albumThumbnail,
|
||||
sharedAlbumThumbnail;
|
||||
}
|
||||
|
||||
final cacheServiceProvider = Provider((_) => CacheService());
|
||||
final cacheServiceProvider = Provider(
|
||||
(ref) => CacheService(ref.watch(appSettingsServiceProvider)),
|
||||
);
|
||||
|
||||
class CacheService {
|
||||
final AppSettingsService _settingsService;
|
||||
final _cacheRepositoryInstances = <CacheType, ImmichCacheRepository>{};
|
||||
|
||||
CacheService(this._settingsService);
|
||||
|
||||
BaseCacheManager getCache(CacheType type) {
|
||||
return _getDefaultCache(type.name);
|
||||
return _getDefaultCache(
|
||||
type.name,
|
||||
_getCacheSize(type) + 1,
|
||||
getCacheRepo(type),
|
||||
);
|
||||
}
|
||||
|
||||
BaseCacheManager _getDefaultCache(String cacheName) {
|
||||
return CacheManager(Config(cacheName));
|
||||
ImmichCacheRepository getCacheRepo(CacheType type) {
|
||||
if (!_cacheRepositoryInstances.containsKey(type)) {
|
||||
final repo = ImmichCacheInfoRepository(
|
||||
"cache_${type.name}",
|
||||
"cacheKeys_${type.name}",
|
||||
);
|
||||
_cacheRepositoryInstances[type] = repo;
|
||||
}
|
||||
|
||||
return _cacheRepositoryInstances[type]!;
|
||||
}
|
||||
|
||||
Future<void> emptyAllCaches() async {
|
||||
for (var type in CacheType.values) {
|
||||
await getCache(type).emptyCache();
|
||||
}
|
||||
}
|
||||
|
||||
int _getCacheSize(CacheType type) {
|
||||
switch (type) {
|
||||
case CacheType.thumbnail:
|
||||
return _settingsService.getSetting(AppSettingsEnum.thumbnailCacheSize);
|
||||
case CacheType.imageViewerPreview:
|
||||
case CacheType.imageViewerFull:
|
||||
return _settingsService.getSetting(AppSettingsEnum.imageCacheSize);
|
||||
case CacheType.sharedAlbumThumbnail:
|
||||
case CacheType.albumThumbnail:
|
||||
return _settingsService
|
||||
.getSetting(AppSettingsEnum.albumThumbnailCacheSize);
|
||||
default:
|
||||
return 200;
|
||||
}
|
||||
}
|
||||
|
||||
BaseCacheManager _getDefaultCache(
|
||||
String cacheName, int size, CacheInfoRepository repo) {
|
||||
return CacheManager(
|
||||
Config(
|
||||
cacheName,
|
||||
maxNrOfCacheObjects: size,
|
||||
repo: repo,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
15
mobile/lib/utils/bytes_units.dart
Normal file
15
mobile/lib/utils/bytes_units.dart
Normal file
|
@ -0,0 +1,15 @@
|
|||
|
||||
String formatBytes(int bytes) {
|
||||
if (bytes < 1000) {
|
||||
return "$bytes B";
|
||||
} else if (bytes < 1000000) {
|
||||
final kb = (bytes / 1000).toStringAsFixed(1);
|
||||
return "$kb kB";
|
||||
} else if (bytes < 1000000000) {
|
||||
final mb = (bytes / 1000000).toStringAsFixed(1);
|
||||
return "$mb MB";
|
||||
} else {
|
||||
final gb = (bytes / 1000000000).toStringAsFixed(1);
|
||||
return "$gb GB";
|
||||
}
|
||||
}
|
204
mobile/lib/utils/immich_cache_info_repository.dart
Normal file
204
mobile/lib/utils/immich_cache_info_repository.dart
Normal file
|
@ -0,0 +1,204 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_cache_manager/src/storage/cache_object.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
// Implementation of a CacheInfoRepository based on Hive
|
||||
abstract class ImmichCacheRepository extends CacheInfoRepository {
|
||||
int getNumberOfCachedObjects();
|
||||
int getCacheSize();
|
||||
}
|
||||
|
||||
class ImmichCacheInfoRepository extends ImmichCacheRepository {
|
||||
final String hiveBoxName;
|
||||
final String keyLookupHiveBoxName;
|
||||
|
||||
// To circumvent some of the limitations of a non-relational key-value database,
|
||||
// we use two hive boxes per cache.
|
||||
// [cacheObjectLookupBox] maps ids to cache objects.
|
||||
// [keyLookupHiveBox] maps keys to ids.
|
||||
// The lookup of a cache object by key therefore involves two steps:
|
||||
// id = keyLookupHiveBox[key]
|
||||
// object = cacheObjectLookupBox[id]
|
||||
late Box<Map<dynamic, dynamic>> cacheObjectLookupBox;
|
||||
late Box<int> keyLookupHiveBox;
|
||||
|
||||
ImmichCacheInfoRepository(this.hiveBoxName, this.keyLookupHiveBoxName);
|
||||
|
||||
@override
|
||||
Future<bool> close() async {
|
||||
await cacheObjectLookupBox.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> delete(int id) async {
|
||||
if (cacheObjectLookupBox.containsKey(id)) {
|
||||
await cacheObjectLookupBox.delete(id);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> deleteAll(Iterable<int> ids) async {
|
||||
int deleted = 0;
|
||||
for (var id in ids) {
|
||||
if (cacheObjectLookupBox.containsKey(id)) {
|
||||
deleted++;
|
||||
await cacheObjectLookupBox.delete(id);
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteDataFile() async {
|
||||
await cacheObjectLookupBox.clear();
|
||||
await keyLookupHiveBox.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> exists() async {
|
||||
return cacheObjectLookupBox.isNotEmpty && keyLookupHiveBox.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CacheObject?> get(String key) async {
|
||||
if (!keyLookupHiveBox.containsKey(key)) {
|
||||
return null;
|
||||
}
|
||||
int id = keyLookupHiveBox.get(key)!;
|
||||
if (!cacheObjectLookupBox.containsKey(id)) {
|
||||
keyLookupHiveBox.delete(key);
|
||||
return null;
|
||||
}
|
||||
return _deserialize(cacheObjectLookupBox.get(id)!);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CacheObject>> getAllObjects() async {
|
||||
return cacheObjectLookupBox.values.map(_deserialize).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CacheObject>> getObjectsOverCapacity(int capacity) async {
|
||||
if (cacheObjectLookupBox.length <= capacity) {
|
||||
return List.empty();
|
||||
}
|
||||
var values = cacheObjectLookupBox.values.map(_deserialize).toList();
|
||||
values.sort((CacheObject a, CacheObject b) {
|
||||
final aTouched = a.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
|
||||
final bTouched = b.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
|
||||
|
||||
return aTouched.compareTo(bTouched);
|
||||
});
|
||||
return values.skip(capacity).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<CacheObject>> getOldObjects(Duration maxAge) async {
|
||||
return cacheObjectLookupBox.values
|
||||
.map(_deserialize)
|
||||
.where((CacheObject element) {
|
||||
DateTime touched =
|
||||
element.touched ?? DateTime.fromMicrosecondsSinceEpoch(0);
|
||||
return touched.isBefore(DateTime.now().subtract(maxAge));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CacheObject> insert(CacheObject cacheObject,
|
||||
{bool setTouchedToNow = true}) async {
|
||||
int newId = keyLookupHiveBox.length == 0
|
||||
? 0
|
||||
: keyLookupHiveBox.values.reduce(max) + 1;
|
||||
cacheObject = cacheObject.copyWith(id: newId);
|
||||
|
||||
keyLookupHiveBox.put(cacheObject.key, newId);
|
||||
cacheObjectLookupBox.put(newId, cacheObject.toMap());
|
||||
|
||||
return cacheObject;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> open() async {
|
||||
cacheObjectLookupBox = await Hive.openBox(hiveBoxName);
|
||||
keyLookupHiveBox = await Hive.openBox(keyLookupHiveBoxName);
|
||||
|
||||
// The cache might have cleared by the operating system.
|
||||
// This could create inconsistencies between the file system cache and database.
|
||||
// To check whether the cache was cleared, a file within the cache directory
|
||||
// is created for each database. If the file is absent, the cache was cleared and therefore
|
||||
// the database has to be cleared as well.
|
||||
if (!await _checkAndCreateAnchorFile()) {
|
||||
await cacheObjectLookupBox.clear();
|
||||
await keyLookupHiveBox.clear();
|
||||
}
|
||||
|
||||
return cacheObjectLookupBox.isOpen;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> update(CacheObject cacheObject,
|
||||
{bool setTouchedToNow = true}) async {
|
||||
if (cacheObject.id != null) {
|
||||
cacheObjectLookupBox.put(cacheObject.id, cacheObject.toMap());
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Future updateOrInsert(CacheObject cacheObject) {
|
||||
if (cacheObject.id == null) {
|
||||
return insert(cacheObject);
|
||||
} else {
|
||||
return update(cacheObject);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
int getNumberOfCachedObjects() {
|
||||
return cacheObjectLookupBox.length;
|
||||
}
|
||||
|
||||
@override
|
||||
int getCacheSize() {
|
||||
final cacheElementsWithSize =
|
||||
cacheObjectLookupBox.values.map(_deserialize).map((e) => e.length ?? 0);
|
||||
|
||||
if (cacheElementsWithSize.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return cacheElementsWithSize.reduce((value, element) => value + element);
|
||||
}
|
||||
|
||||
CacheObject _deserialize(Map serData) {
|
||||
Map<String, dynamic> converted = {};
|
||||
|
||||
serData.forEach((key, value) {
|
||||
converted[key.toString()] = value;
|
||||
});
|
||||
|
||||
return CacheObject.fromMap(converted);
|
||||
}
|
||||
|
||||
Future<bool> _checkAndCreateAnchorFile() async {
|
||||
final tmpDir = await getTemporaryDirectory();
|
||||
final cacheFile = File(p.join(tmpDir.path, "$hiveBoxName.tmp"));
|
||||
|
||||
if (await cacheFile.exists()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await cacheFile.create();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue