You've already forked immich
							
							
				mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 00:18:28 +02:00 
			
		
		
		
	feat(mobile): Various minor performance improvements (#1176)
* Improve scroll performance by introducing repaint boundaries and moving more calculations to providers. * Add error handing for malformed dates. * Remove unused method * Use compute in different places to improve app performance during heavy tasks * Fix test * Refactor `List<RenderAssetGridElement>` to separate `RenderList` class and make `fromAssetGroups` a static method of this class. * Fix loading indicator bug * Use provider directly * `RenderList` refactoring * `AssetNotifier` refactoring * Move `combine` to static private method * Extract compute methods in cache services to static private methods. * Use `tryParse` instead of `parse` with try/catch for dates. * Fix bug in caching mechanism. * Fixed state not being used to trigger conditional rendering * styling * Corrected state Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -57,6 +57,7 @@ void main() async { | ||||
|   if (kReleaseMode && Platform.isAndroid) { | ||||
|     try { | ||||
|       await FlutterDisplayMode.setHighRefreshRate(); | ||||
|       debugPrint("Enabled high refresh mode"); | ||||
|     } catch (e) { | ||||
|       debugPrint("Error setting high refresh rate: $e"); | ||||
|     } | ||||
|   | ||||
| @@ -83,6 +83,12 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|  | ||||
|     return SingleChildScrollView( | ||||
|       child: Card( | ||||
|         shape: const RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.only( | ||||
|             topLeft: Radius.circular(15), | ||||
|             topRight: Radius.circular(15), | ||||
|           ), | ||||
|         ), | ||||
|         margin: const EdgeInsets.all(0), | ||||
|         child: Container( | ||||
|           margin: const EdgeInsets.symmetric(horizontal: 8.0), | ||||
|   | ||||
| @@ -1,14 +0,0 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/shared/providers/asset.provider.dart'; | ||||
|  | ||||
| final renderListProvider = StateProvider((ref) { | ||||
|   var assetGroups = ref.watch(assetGroupByDateTimeProvider); | ||||
|  | ||||
|   var settings = ref.watch(appSettingsServiceProvider); | ||||
|   final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow); | ||||
|  | ||||
|   return assetGroupsToRenderList(assetGroups, assetsPerRow); | ||||
| }); | ||||
| @@ -7,9 +7,18 @@ import 'package:immich_mobile/shared/services/json_cache.dart'; | ||||
| class AssetCacheService extends JsonCache<List<Asset>> { | ||||
|   AssetCacheService() : super("asset_cache"); | ||||
|  | ||||
|   static Future<List<Map<String, dynamic>>> _computeSerialize( | ||||
|       List<Asset> assets) async { | ||||
|     return assets.map((e) => e.toJson()).toList(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void put(List<Asset> data) { | ||||
|     putRawData(data.map((e) => e.toJson()).toList()); | ||||
|   void put(List<Asset> data) async { | ||||
|     putRawData(await compute(_computeSerialize, data)); | ||||
|   } | ||||
|  | ||||
|   static Future<List<Asset>> _computeEncode(List<dynamic> data) async { | ||||
|     return data.map((e) => Asset.fromJson(e)).whereNotNull().toList(); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
| @@ -17,8 +26,7 @@ class AssetCacheService extends JsonCache<List<Asset>> { | ||||
|     try { | ||||
|       final mapList = await readRawData() as List<dynamic>; | ||||
|  | ||||
|       final responseData = | ||||
|           mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList(); | ||||
|       final responseData = await compute(_computeEncode, mapList); | ||||
|  | ||||
|       return responseData; | ||||
|     } catch (e) { | ||||
|   | ||||
| @@ -1,5 +1,7 @@ | ||||
| import 'dart:math'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
|  | ||||
| @@ -33,85 +35,122 @@ class RenderAssetGridElement { | ||||
|   }); | ||||
| } | ||||
|  | ||||
| List<RenderAssetGridElement> assetsToRenderList( | ||||
|   List<Asset> assets, | ||||
|   int assetsPerRow, | ||||
| ) { | ||||
|   List<RenderAssetGridElement> elements = []; | ||||
| class _AssetGroupsToRenderListComputeParameters { | ||||
|   final String monthFormat; | ||||
|   final String dayFormat; | ||||
|   final String dayFormatYear; | ||||
|   final Map<String, List<Asset>> groups; | ||||
|   final int perRow; | ||||
|  | ||||
|   int cursor = 0; | ||||
|   while (cursor < assets.length) { | ||||
|     int rowElements = min(assets.length - cursor, assetsPerRow); | ||||
|     final date = assets[cursor].createdAt; | ||||
|  | ||||
|     final rowElement = RenderAssetGridElement( | ||||
|       RenderAssetGridElementType.assetRow, | ||||
|       date: date, | ||||
|       assetRow: RenderAssetGridRow( | ||||
|         assets.sublist(cursor, cursor + rowElements), | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     elements.add(rowElement); | ||||
|     cursor += rowElements; | ||||
|   } | ||||
|  | ||||
|   return elements; | ||||
|   _AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat, | ||||
|       this.dayFormatYear, this.groups, this.perRow); | ||||
| } | ||||
|  | ||||
| List<RenderAssetGridElement> assetGroupsToRenderList( | ||||
|   Map<String, List<Asset>> assetGroups, | ||||
|   int assetsPerRow, | ||||
| ) { | ||||
|   List<RenderAssetGridElement> elements = []; | ||||
|   DateTime? lastDate; | ||||
| class RenderList { | ||||
|   final List<RenderAssetGridElement> elements; | ||||
|  | ||||
|   assetGroups.forEach((groupName, assets) { | ||||
|     try { | ||||
|       final date = DateTime.parse(groupName); | ||||
|   RenderList(this.elements); | ||||
|  | ||||
|   static Future<RenderList> _processAssetGroupData( | ||||
|       _AssetGroupsToRenderListComputeParameters data) async { | ||||
|     final monthFormat = DateFormat(data.monthFormat); | ||||
|     final dayFormatSameYear = DateFormat(data.dayFormat); | ||||
|     final dayFormatOtherYear = DateFormat(data.dayFormatYear); | ||||
|     final groups = data.groups; | ||||
|     final perRow = data.perRow; | ||||
|  | ||||
|     List<RenderAssetGridElement> elements = []; | ||||
|     DateTime? lastDate; | ||||
|  | ||||
|     groups.forEach((groupName, assets) { | ||||
|       try { | ||||
|         final date = DateTime.parse(groupName); | ||||
|  | ||||
|         if (lastDate == null || lastDate!.month != date.month) { | ||||
|           // Month title | ||||
|  | ||||
|           var monthTitleText = groupName; | ||||
|  | ||||
|           var groupDate = DateTime.tryParse(groupName); | ||||
|           if (groupDate != null) { | ||||
|             monthTitleText = monthFormat.format(groupDate); | ||||
|           } else { | ||||
|             log.severe("Failed to format date for day title: $groupName"); | ||||
|           } | ||||
|  | ||||
|           elements.add( | ||||
|             RenderAssetGridElement( | ||||
|               RenderAssetGridElementType.monthTitle, | ||||
|               title: monthTitleText, | ||||
|               date: date, | ||||
|             ), | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         // Add group title | ||||
|         var currentYear = DateTime.now().year; | ||||
|         var groupYear = DateTime.parse(groupName).year; | ||||
|         var formatDate = | ||||
|             currentYear == groupYear ? dayFormatSameYear : dayFormatOtherYear; | ||||
|  | ||||
|         var dateText = groupName; | ||||
|  | ||||
|         var groupDate = DateTime.tryParse(groupName); | ||||
|         if (groupDate != null) { | ||||
|           dateText = formatDate.format(groupDate); | ||||
|         } else { | ||||
|           log.severe("Failed to format date for day title: $groupName"); | ||||
|         } | ||||
|  | ||||
|       if (lastDate == null || lastDate!.month != date.month) { | ||||
|         elements.add( | ||||
|           RenderAssetGridElement( | ||||
|             RenderAssetGridElementType.monthTitle, | ||||
|             title: groupName, | ||||
|             RenderAssetGridElementType.dayTitle, | ||||
|             title: dateText, | ||||
|             date: date, | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       // Add group title | ||||
|       elements.add( | ||||
|         RenderAssetGridElement( | ||||
|           RenderAssetGridElementType.dayTitle, | ||||
|           title: groupName, | ||||
|           date: date, | ||||
|           relatedAssetList: assets, | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       // Add rows | ||||
|       int cursor = 0; | ||||
|       while (cursor < assets.length) { | ||||
|         int rowElements = min(assets.length - cursor, assetsPerRow); | ||||
|  | ||||
|         final rowElement = RenderAssetGridElement( | ||||
|           RenderAssetGridElementType.assetRow, | ||||
|           date: date, | ||||
|           assetRow: RenderAssetGridRow( | ||||
|             assets.sublist(cursor, cursor + rowElements), | ||||
|             relatedAssetList: assets, | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|         elements.add(rowElement); | ||||
|         cursor += rowElements; | ||||
|         // Add rows | ||||
|         int cursor = 0; | ||||
|         while (cursor < assets.length) { | ||||
|           int rowElements = min(assets.length - cursor, perRow); | ||||
|  | ||||
|           final rowElement = RenderAssetGridElement( | ||||
|             RenderAssetGridElementType.assetRow, | ||||
|             date: date, | ||||
|             assetRow: RenderAssetGridRow( | ||||
|               assets.sublist(cursor, cursor + rowElements), | ||||
|             ), | ||||
|           ); | ||||
|  | ||||
|           elements.add(rowElement); | ||||
|           cursor += rowElements; | ||||
|         } | ||||
|  | ||||
|         lastDate = date; | ||||
|       } catch (e, stackTrace) { | ||||
|         log.severe(e, stackTrace); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|       lastDate = date; | ||||
|     } catch (e, stackTrace) { | ||||
|       log.severe(e, stackTrace); | ||||
|     } | ||||
|   }); | ||||
|     return RenderList(elements); | ||||
|   } | ||||
|  | ||||
|   return elements; | ||||
|   static Future<RenderList> fromAssetGroups( | ||||
|     Map<String, List<Asset>> assetGroups, | ||||
|     int assetsPerRow, | ||||
|   ) async { | ||||
|     // Compute only allows for one parameter. Therefore we pass all parameters in a map | ||||
|     return compute( | ||||
|       _processAssetGroupData, | ||||
|       _AssetGroupsToRenderListComputeParameters( | ||||
|         "monthly_title_text_date_format".tr(), | ||||
|         "daily_title_text_date".tr(), | ||||
|         "daily_title_text_date_year".tr(), | ||||
|         assetGroups, | ||||
|         assetsPerRow, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,14 +5,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| class DailyTitleText extends ConsumerWidget { | ||||
|   const DailyTitleText({ | ||||
|     Key? key, | ||||
|     required this.isoDate, | ||||
|     required this.text, | ||||
|     required this.multiselectEnabled, | ||||
|     required this.onSelect, | ||||
|     required this.onDeselect, | ||||
|     required this.selected, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   final String isoDate; | ||||
|   final String text; | ||||
|   final bool multiselectEnabled; | ||||
|   final Function onSelect; | ||||
|   final Function onDeselect; | ||||
| @@ -20,13 +20,7 @@ class DailyTitleText extends ConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var currentYear = DateTime.now().year; | ||||
|     var groupYear = DateTime.parse(isoDate).year; | ||||
|     var formatDateTemplate = currentYear == groupYear | ||||
|         ? "daily_title_text_date".tr() | ||||
|         : "daily_title_text_date_year".tr(); | ||||
|     var dateText = | ||||
|         DateFormat(formatDateTemplate).format(DateTime.parse(isoDate)); | ||||
|  | ||||
|  | ||||
|     void handleTitleIconClick() { | ||||
|       if (selected) { | ||||
| @@ -46,7 +40,7 @@ class DailyTitleText extends ConsumerWidget { | ||||
|       child: Row( | ||||
|         children: [ | ||||
|           Text( | ||||
|             dateText, | ||||
|             text, | ||||
|             style: const TextStyle( | ||||
|               fontSize: 14, | ||||
|               fontWeight: FontWeight.bold, | ||||
|   | ||||
| @@ -24,22 +24,10 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   bool _scrolling = false; | ||||
|   final Set<String> _selectedAssets = HashSet(); | ||||
|  | ||||
|   List<Asset> get _assets { | ||||
|     return widget.renderList | ||||
|         .map((e) { | ||||
|           if (e.type == RenderAssetGridElementType.assetRow) { | ||||
|             return e.assetRow!.assets; | ||||
|           } else { | ||||
|             return List<Asset>.empty(); | ||||
|           } | ||||
|         }) | ||||
|         .flattened | ||||
|         .toList(); | ||||
|   } | ||||
|  | ||||
|   Set<Asset> _getSelectedAssets() { | ||||
|     return _selectedAssets | ||||
|         .map((e) => _assets.firstWhereOrNull((a) => a.id == e)) | ||||
|         .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e)) | ||||
|         .whereNotNull() | ||||
|         .toSet(); | ||||
|   } | ||||
| @@ -95,9 +83,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     } | ||||
|     return ThumbnailImage( | ||||
|       asset: asset, | ||||
|       assetList: _assets, | ||||
|       assetList: widget.allAssets, | ||||
|       multiselectEnabled: widget.selectionActive, | ||||
|       isSelected: _selectedAssets.contains(asset.id), | ||||
|       isSelected: widget.selectionActive && _selectedAssets.contains(asset.id), | ||||
|       onSelect: () => _selectAssets([asset]), | ||||
|       onDeselect: () => _deselectAssets([asset]), | ||||
|       useGrayBoxPlaceholder: true, | ||||
| @@ -137,7 +125,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     List<Asset> assets, | ||||
|   ) { | ||||
|     return DailyTitleText( | ||||
|       isoDate: title, | ||||
|       text: title, | ||||
|       multiselectEnabled: widget.selectionActive, | ||||
|       onSelect: () => _selectAssets(assets), | ||||
|       onDeselect: () => _deselectAssets(assets), | ||||
| @@ -146,14 +134,11 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   } | ||||
|  | ||||
|   Widget _buildMonthTitle(BuildContext context, String title) { | ||||
|     var monthTitleText = DateFormat("monthly_title_text_date_format".tr()) | ||||
|         .format(DateTime.parse(title)); | ||||
|  | ||||
|     return Padding( | ||||
|       key: Key("month-$title"), | ||||
|       padding: const EdgeInsets.only(left: 12.0, top: 32), | ||||
|       child: Text( | ||||
|         monthTitleText, | ||||
|         title, | ||||
|         style: TextStyle( | ||||
|           fontSize: 26, | ||||
|           fontWeight: FontWeight.bold, | ||||
| @@ -164,7 +149,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   } | ||||
|  | ||||
|   Widget _itemBuilder(BuildContext c, int position) { | ||||
|     final item = widget.renderList[position]; | ||||
|     final item = widget.renderList.elements[position]; | ||||
|  | ||||
|     if (item.type == RenderAssetGridElementType.dayTitle) { | ||||
|       return _buildTitle(c, item.title!, item.relatedAssetList!); | ||||
| @@ -178,7 +163,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   } | ||||
|  | ||||
|   Text _labelBuilder(int pos) { | ||||
|     final date = widget.renderList[pos].date; | ||||
|     final date = widget.renderList.elements[pos].date; | ||||
|     return Text( | ||||
|       DateFormat.yMMMd().format(date), | ||||
|       style: const TextStyle( | ||||
| @@ -196,7 +181,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   } | ||||
|  | ||||
|   Widget _buildAssetGrid() { | ||||
|     final useDragScrolling = _assets.length >= 20; | ||||
|     final useDragScrolling = widget.allAssets.length >= 20; | ||||
|  | ||||
|     void dragScrolling(bool active) { | ||||
|       setState(() { | ||||
| @@ -208,7 +193,8 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|       itemBuilder: _itemBuilder, | ||||
|       itemPositionsListener: _itemPositionsListener, | ||||
|       itemScrollController: _itemScrollController, | ||||
|       itemCount: widget.renderList.length, | ||||
|       itemCount: widget.renderList.elements.length, | ||||
|       addRepaintBoundaries: true, | ||||
|     ); | ||||
|  | ||||
|     if (!useDragScrolling) { | ||||
| @@ -250,16 +236,18 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
| } | ||||
|  | ||||
| class ImmichAssetGrid extends StatefulWidget { | ||||
|   final List<RenderAssetGridElement> renderList; | ||||
|   final RenderList renderList; | ||||
|   final int assetsPerRow; | ||||
|   final double margin; | ||||
|   final bool showStorageIndicator; | ||||
|   final ImmichAssetGridSelectionListener? listener; | ||||
|   final bool selectionActive; | ||||
|   final List<Asset> allAssets; | ||||
|  | ||||
|   const ImmichAssetGrid({ | ||||
|     super.key, | ||||
|     required this.renderList, | ||||
|     required this.allAssets, | ||||
|     required this.assetsPerRow, | ||||
|     required this.showStorageIndicator, | ||||
|     this.listener, | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/home_page_render_list_provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; | ||||
| @@ -32,7 +31,6 @@ class HomePage extends HookConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final appSettingService = ref.watch(appSettingsServiceProvider); | ||||
|     var renderList = ref.watch(renderListProvider); | ||||
|     final multiselectEnabled = ref.watch(multiselectProvider.notifier); | ||||
|     final selectionEnabledHook = useState(false); | ||||
|  | ||||
| @@ -212,10 +210,12 @@ class HomePage extends HookConsumerWidget { | ||||
|                 top: selectionEnabledHook.value ? 0 : 60, | ||||
|                 bottom: 0.0, | ||||
|               ), | ||||
|               child: ref.watch(assetProvider).isEmpty | ||||
|               child: ref.watch(assetProvider).renderList == null || | ||||
|                       ref.watch(assetProvider).allAssets.isEmpty | ||||
|                   ? buildLoadingIndicator() | ||||
|                   : ImmichAssetGrid( | ||||
|                       renderList: renderList, | ||||
|                       renderList: ref.watch(assetProvider).renderList!, | ||||
|                       allAssets: ref.watch(assetProvider).allAssets, | ||||
|                       assetsPerRow: appSettingService | ||||
|                           .getSetting(AppSettingsEnum.tilesPerRow), | ||||
|                       showStorageIndicator: appSettingService | ||||
|   | ||||
| @@ -70,11 +70,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) { | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| final searchRenderListProvider = StateProvider((ref) { | ||||
| final searchRenderListProvider = FutureProvider((ref) { | ||||
|   var assetGroups = ref.watch(searchResultGroupByDateTimeProvider); | ||||
|  | ||||
|   var settings = ref.watch(appSettingsServiceProvider); | ||||
|   final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow); | ||||
|  | ||||
|   return assetGroupsToRenderList(assetGroups, assetsPerRow); | ||||
|   return RenderList.fromAssetGroups(assetGroups, assetsPerRow); | ||||
| }); | ||||
|   | ||||
| @@ -111,6 +111,7 @@ class SearchResultPage extends HookConsumerWidget { | ||||
|     buildSearchResult() { | ||||
|       var searchResultPageState = ref.watch(searchResultPageProvider); | ||||
|       var searchResultRenderList = ref.watch(searchRenderListProvider); | ||||
|       var allSearchAssets = ref.watch(searchResultPageProvider).searchResult; | ||||
|  | ||||
|       var settings = ref.watch(appSettingsServiceProvider); | ||||
|       final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow); | ||||
| @@ -126,10 +127,21 @@ class SearchResultPage extends HookConsumerWidget { | ||||
|       } | ||||
|  | ||||
|       if (searchResultPageState.isSuccess) { | ||||
|         return ImmichAssetGrid( | ||||
|           renderList: searchResultRenderList, | ||||
|           assetsPerRow: assetsPerRow, | ||||
|           showStorageIndicator: showStorageIndicator, | ||||
|         return searchResultRenderList.when( | ||||
|           data: (result) { | ||||
|             return ImmichAssetGrid( | ||||
|               allAssets: allSearchAssets, | ||||
|               renderList: result, | ||||
|               assetsPerRow: assetsPerRow, | ||||
|               showStorageIndicator: showStorageIndicator, | ||||
|             ); | ||||
|           }, | ||||
|           error: (err, stack) { | ||||
|             return Text("$err"); | ||||
|           }, | ||||
|           loading: () { | ||||
|             return const CircularProgressIndicator(); | ||||
|           }, | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -21,7 +21,7 @@ class StorageIndicator extends HookConsumerWidget { | ||||
|       appSettingService.setSetting(AppSettingsEnum.storageIndicator, value); | ||||
|       showStorageIndicator.value = value; | ||||
|  | ||||
|       ref.invalidate(assetGroupByDateTimeProvider); | ||||
|       ref.invalidate(assetProvider); | ||||
|     } | ||||
|  | ||||
|     useEffect( | ||||
|   | ||||
| @@ -23,7 +23,7 @@ class TilesPerRow extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     void sliderChangedEnd(double _) { | ||||
|       ref.invalidate(assetGroupByDateTimeProvider); | ||||
|       ref.invalidate(assetProvider); | ||||
|     } | ||||
|  | ||||
|     useEffect( | ||||
|   | ||||
| @@ -1,10 +1,14 @@ | ||||
| import 'dart:collection'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/services/device_info.service.dart'; | ||||
| import 'package:collection/collection.dart'; | ||||
| @@ -14,18 +18,79 @@ import 'package:logging/logging.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
| class AssetsState { | ||||
|   final List<Asset> allAssets; | ||||
|   final RenderList? renderList; | ||||
|  | ||||
|   AssetsState(this.allAssets, {this.renderList}); | ||||
|  | ||||
|   Future<AssetsState> withRenderDataStructure(int groupSize) async { | ||||
|     return AssetsState( | ||||
|       allAssets, | ||||
|       renderList: | ||||
|           await RenderList.fromAssetGroups(await _groupByDate(), groupSize), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   AssetsState withAdditionalAssets(List<Asset> toAdd) { | ||||
|     return AssetsState([...allAssets, ...toAdd]); | ||||
|   } | ||||
|  | ||||
|   _groupByDate() async { | ||||
|     sortCompare(List<Asset> assets) { | ||||
|       assets.sortByCompare<DateTime>( | ||||
|         (e) => e.createdAt, | ||||
|         (a, b) => b.compareTo(a), | ||||
|       ); | ||||
|       return assets.groupListsBy( | ||||
|         (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return await compute(sortCompare, allAssets.toList()); | ||||
|   } | ||||
|  | ||||
|   static fromAssetList(List<Asset> assets) { | ||||
|     return AssetsState(assets); | ||||
|   } | ||||
|  | ||||
|   static empty() { | ||||
|     return AssetsState([]); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _CombineAssetsComputeParameters { | ||||
|   final Iterable<Asset> local; | ||||
|   final Iterable<Asset> remote; | ||||
|   final String deviceId; | ||||
|  | ||||
|   _CombineAssetsComputeParameters(this.local, this.remote, this.deviceId); | ||||
| } | ||||
|  | ||||
| class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|   final AssetService _assetService; | ||||
|   final AssetCacheService _assetCacheService; | ||||
|   final AppSettingsService _settingsService; | ||||
|   final log = Logger('AssetNotifier'); | ||||
|   final DeviceInfoService _deviceInfoService = DeviceInfoService(); | ||||
|   bool _getAllAssetInProgress = false; | ||||
|   bool _deleteInProgress = false; | ||||
|  | ||||
|   AssetNotifier(this._assetService, this._assetCacheService) : super([]); | ||||
|   AssetNotifier( | ||||
|     this._assetService, | ||||
|     this._assetCacheService, | ||||
|     this._settingsService, | ||||
|   ) : super(AssetsState.fromAssetList([])); | ||||
|  | ||||
|   _cacheState() { | ||||
|     _assetCacheService.put(state); | ||||
|   _updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async { | ||||
|     if (cache) { | ||||
|       _assetCacheService.put(newAssetList); | ||||
|     } | ||||
|  | ||||
|     state = | ||||
|         await AssetsState.fromAssetList(newAssetList).withRenderDataStructure( | ||||
|       _settingsService.getSetting(AppSettingsEnum.tilesPerRow), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   getAllAsset() async { | ||||
| @@ -43,17 +108,19 @@ class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
|       final remoteTask = _assetService.getRemoteAssets( | ||||
|         etag: isCacheValid ? box.get(assetEtagKey) : null, | ||||
|       ); | ||||
|       if (isCacheValid && state.isEmpty) { | ||||
|         state = await _assetCacheService.get(); | ||||
|       if (isCacheValid && state.allAssets.isEmpty) { | ||||
|         await _updateAssetsState(await _assetCacheService.get(), cache: false); | ||||
|         log.info( | ||||
|           "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms", | ||||
|           "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms", | ||||
|         ); | ||||
|         stopwatch.reset(); | ||||
|       } | ||||
|  | ||||
|       int remoteBegin = state.indexWhere((a) => a.isRemote); | ||||
|       remoteBegin = remoteBegin == -1 ? state.length : remoteBegin; | ||||
|       final List<Asset> currentLocal = state.slice(0, remoteBegin); | ||||
|       int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote); | ||||
|       remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin; | ||||
|  | ||||
|       final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin); | ||||
|  | ||||
|       final Pair<List<Asset>?, String?> remoteResult = await remoteTask; | ||||
|       List<Asset>? newRemote = remoteResult.first; | ||||
|       List<Asset>? newLocal = await localTask; | ||||
| @@ -64,27 +131,32 @@ class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
|         log.info("state is already up-to-date"); | ||||
|         return; | ||||
|       } | ||||
|       newRemote ??= state.slice(remoteBegin); | ||||
|       newRemote ??= state.allAssets.slice(remoteBegin); | ||||
|       newLocal ??= []; | ||||
|       state = _combineLocalAndRemoteAssets(local: newLocal, remote: newRemote); | ||||
|  | ||||
|       final combinedAssets = await _combineLocalAndRemoteAssets( | ||||
|         local: newLocal, | ||||
|         remote: newRemote, | ||||
|       ); | ||||
|       await _updateAssetsState(combinedAssets); | ||||
|  | ||||
|       log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|  | ||||
|       stopwatch.reset(); | ||||
|       _cacheState(); | ||||
|       box.put(assetEtagKey, remoteResult.second); | ||||
|       log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|     } finally { | ||||
|       _getAllAssetInProgress = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   List<Asset> _combineLocalAndRemoteAssets({ | ||||
|     required Iterable<Asset> local, | ||||
|     required List<Asset> remote, | ||||
|   }) { | ||||
|   static Future<List<Asset>> _computeCombine( | ||||
|     _CombineAssetsComputeParameters data, | ||||
|   ) async { | ||||
|     var local = data.local; | ||||
|     var remote = data.remote; | ||||
|     final deviceId = data.deviceId; | ||||
|  | ||||
|     final List<Asset> assets = []; | ||||
|     if (remote.isNotEmpty && local.isNotEmpty) { | ||||
|       final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|       final Set<String> existingIds = remote | ||||
|           .where((e) => e.deviceId == deviceId) | ||||
|           .map((e) => e.deviceAssetId) | ||||
| @@ -97,31 +169,40 @@ class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
|     return assets; | ||||
|   } | ||||
|  | ||||
|   Future<List<Asset>> _combineLocalAndRemoteAssets({ | ||||
|     required Iterable<Asset> local, | ||||
|     required List<Asset> remote, | ||||
|   }) async { | ||||
|     final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|     return await compute( | ||||
|       _computeCombine, | ||||
|       _CombineAssetsComputeParameters(local, remote, deviceId), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   clearAllAsset() { | ||||
|     state = []; | ||||
|     _cacheState(); | ||||
|     _updateAssetsState([]); | ||||
|   } | ||||
|  | ||||
|   onNewAssetUploaded(AssetResponseDto newAsset) { | ||||
|     final int i = state.indexWhere( | ||||
|     final int i = state.allAssets.indexWhere( | ||||
|       (a) => | ||||
|           a.isRemote || | ||||
|           (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId), | ||||
|     ); | ||||
|  | ||||
|     if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) { | ||||
|       state = [...state, Asset.remote(newAsset)]; | ||||
|     if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) { | ||||
|       _updateAssetsState([...state.allAssets, Asset.remote(newAsset)]); | ||||
|     } else { | ||||
|       // order is important to keep all local-only assets at the beginning! | ||||
|       state = [ | ||||
|         ...state.slice(0, i), | ||||
|         ...state.slice(i + 1), | ||||
|       _updateAssetsState([ | ||||
|         ...state.allAssets.slice(0, i), | ||||
|         ...state.allAssets.slice(i + 1), | ||||
|         Asset.remote(newAsset), | ||||
|       ]; | ||||
|       ]); | ||||
|       // TODO here is a place to unify local/remote assets by replacing the | ||||
|       // local-only asset in the state with a local&remote asset | ||||
|     } | ||||
|     _cacheState(); | ||||
|   } | ||||
|  | ||||
|   deleteAssets(Set<Asset> deleteAssets) async { | ||||
| @@ -133,8 +214,9 @@ class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
|       deleted.addAll(localDeleted); | ||||
|       deleted.addAll(remoteDeleted); | ||||
|       if (deleted.isNotEmpty) { | ||||
|         state = state.where((a) => !deleted.contains(a.id)).toList(); | ||||
|         _cacheState(); | ||||
|         _updateAssetsState( | ||||
|           state.allAssets.where((a) => !deleted.contains(a.id)).toList(), | ||||
|         ); | ||||
|       } | ||||
|     } finally { | ||||
|       _deleteInProgress = false; | ||||
| @@ -180,23 +262,11 @@ class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
|   } | ||||
| } | ||||
|  | ||||
| final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) { | ||||
| final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) { | ||||
|   return AssetNotifier( | ||||
|     ref.watch(assetServiceProvider), | ||||
|     ref.watch(assetCacheServiceProvider), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| final assetGroupByDateTimeProvider = StateProvider((ref) { | ||||
|   final assets = ref.watch(assetProvider).toList(); | ||||
|   // `toList()` ist needed to make a copy as to NOT sort the original list/state | ||||
|  | ||||
|   assets.sortByCompare<DateTime>( | ||||
|     (e) => e.createdAt, | ||||
|     (a, b) => b.compareTo(a), | ||||
|   ); | ||||
|   return assets.groupListsBy( | ||||
|     (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), | ||||
|     ref.watch(appSettingsServiceProvider), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| @@ -204,7 +274,8 @@ final assetGroupByMonthYearProvider = StateProvider((ref) { | ||||
|   // TODO: remove `where` once temporary workaround is no longer needed (to only | ||||
|   // allow remote assets to be added to album). Keep `toList()` as to NOT sort | ||||
|   // the original list/state | ||||
|   final assets = ref.watch(assetProvider).where((e) => e.isRemote).toList(); | ||||
|   final assets = | ||||
|       ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList(); | ||||
|  | ||||
|   assets.sortByCompare<DateTime>( | ||||
|     (e) => e.createdAt, | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
|  | ||||
| abstract class JsonCache<T> { | ||||
| @@ -31,8 +32,13 @@ abstract class JsonCache<T> { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   static Future<String> _computeEncodeJson(dynamic toEncode) async { | ||||
|     return json.encode(toEncode); | ||||
|   } | ||||
|  | ||||
|   Future<void> putRawData(dynamic data) async { | ||||
|     final jsonString = json.encode(data); | ||||
|     final jsonString = await compute(_computeEncodeJson, data); | ||||
|  | ||||
|     final file = await _getCacheFile(); | ||||
|  | ||||
|     if (!await file.exists()) { | ||||
| @@ -42,10 +48,15 @@ abstract class JsonCache<T> { | ||||
|     await file.writeAsString(jsonString); | ||||
|   } | ||||
|  | ||||
|   dynamic readRawData() async { | ||||
|   static Future<dynamic> _computeDecodeJson(String jsonString) async { | ||||
|     return json.decode(jsonString); | ||||
|   } | ||||
|  | ||||
|   Future<dynamic> readRawData() async { | ||||
|     final file = await _getCacheFile(); | ||||
|     final data = await file.readAsString(); | ||||
|     return json.decode(data); | ||||
|  | ||||
|     return await compute(_computeDecodeJson, data); | ||||
|   } | ||||
|  | ||||
|   void put(T data); | ||||
|   | ||||
| @@ -54,55 +54,9 @@ void main() { | ||||
|     }).toList() | ||||
|   }; | ||||
|  | ||||
|   group('Asset only list', () { | ||||
|     test('items < itemsPerRow', () { | ||||
|       final assets = testAssets.sublist(0, 2); | ||||
|       final renderList = assetsToRenderList(assets, 3); | ||||
|  | ||||
|       expect(renderList.length, 1); | ||||
|       expect(renderList[0].assetRow!.assets.length, 2); | ||||
|     }); | ||||
|  | ||||
|     test('items = itemsPerRow', () { | ||||
|       final assets = testAssets.sublist(0, 3); | ||||
|       final renderList = assetsToRenderList(assets, 3); | ||||
|  | ||||
|       expect(renderList.length, 1); | ||||
|       expect(renderList[0].assetRow!.assets.length, 3); | ||||
|     }); | ||||
|  | ||||
|     test('items > itemsPerRow', () { | ||||
|       final assets = testAssets.sublist(0, 20); | ||||
|       final renderList = assetsToRenderList(assets, 3); | ||||
|  | ||||
|       expect(renderList.length, 7); | ||||
|       expect(renderList[6].assetRow!.assets.length, 2); | ||||
|     }); | ||||
|  | ||||
|     test('items > itemsPerRow partition 4', () { | ||||
|       final assets = testAssets.sublist(0, 21); | ||||
|       final renderList = assetsToRenderList(assets, 4); | ||||
|  | ||||
|       expect(renderList.length, 6); | ||||
|       expect(renderList[5].assetRow!.assets.length, 1); | ||||
|     }); | ||||
|  | ||||
|     test('items > itemsPerRow check ids', () { | ||||
|       final assets = testAssets.sublist(0, 21); | ||||
|       final renderList = assetsToRenderList(assets, 3); | ||||
|  | ||||
|       expect(renderList.length, 7); | ||||
|       expect(renderList[6].assetRow!.assets.length, 3); | ||||
|       expect(renderList[0].assetRow!.assets[0].id, '0'); | ||||
|       expect(renderList[1].assetRow!.assets[1].id, '4'); | ||||
|       expect(renderList[3].assetRow!.assets[2].id, '11'); | ||||
|       expect(renderList[6].assetRow!.assets[2].id, '20'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   group('Test grouped', () { | ||||
|     test('test grouped check months', () { | ||||
|       final renderList = assetGroupsToRenderList(groups, 3); | ||||
|     test('test grouped check months', () async { | ||||
|       final renderList = await RenderList.fromAssetGroups(groups, 3); | ||||
|  | ||||
|       // Jan | ||||
|       // Day 1 | ||||
| @@ -115,17 +69,17 @@ void main() { | ||||
|       // Oct | ||||
|       // Day 1 | ||||
|       // 15 Assets => 5 Rows | ||||
|       expect(renderList.length, 18); | ||||
|       expect(renderList[0].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect(renderList[0].date.month, 1); | ||||
|       expect(renderList[7].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect(renderList[7].date.month, 2); | ||||
|       expect(renderList[11].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect(renderList[11].date.month, 10); | ||||
|       expect(renderList.elements.length, 18); | ||||
|       expect(renderList.elements[0].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect(renderList.elements[0].date.month, 1); | ||||
|       expect(renderList.elements[7].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect(renderList.elements[7].date.month, 2); | ||||
|       expect(renderList.elements[11].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect(renderList.elements[11].date.month, 10); | ||||
|     }); | ||||
|  | ||||
|     test('test grouped check types', () { | ||||
|       final renderList = assetGroupsToRenderList(groups, 5); | ||||
|     test('test grouped check types', () async { | ||||
|       final renderList = await RenderList.fromAssetGroups(groups, 5); | ||||
|  | ||||
|       // Jan | ||||
|       // Day 1 | ||||
| @@ -155,10 +109,10 @@ void main() { | ||||
|         RenderAssetGridElementType.assetRow | ||||
|       ]; | ||||
|  | ||||
|       expect(renderList.length, types.length); | ||||
|       expect(renderList.elements.length, types.length); | ||||
|  | ||||
|       for (int i = 0; i < renderList.length; i++) { | ||||
|         expect(renderList[i].type, types[i]); | ||||
|       for (int i = 0; i < renderList.elements.length; i++) { | ||||
|         expect(renderList.elements[i].type, types[i]); | ||||
|       } | ||||
|     }); | ||||
|   }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user