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:
Matthias Rupp 2023-01-18 16:59:23 +01:00 committed by GitHub
parent 92972ac776
commit 7a1ae8691e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 312 additions and 242 deletions

View File

@ -57,6 +57,7 @@ void main() async {
if (kReleaseMode && Platform.isAndroid) { if (kReleaseMode && Platform.isAndroid) {
try { try {
await FlutterDisplayMode.setHighRefreshRate(); await FlutterDisplayMode.setHighRefreshRate();
debugPrint("Enabled high refresh mode");
} catch (e) { } catch (e) {
debugPrint("Error setting high refresh rate: $e"); debugPrint("Error setting high refresh rate: $e");
} }

View File

@ -83,6 +83,12 @@ class ExifBottomSheet extends HookConsumerWidget {
return SingleChildScrollView( return SingleChildScrollView(
child: Card( child: Card(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
),
),
margin: const EdgeInsets.all(0), margin: const EdgeInsets.all(0),
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0), margin: const EdgeInsets.symmetric(horizontal: 8.0),

View File

@ -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);
});

View File

@ -7,9 +7,18 @@ import 'package:immich_mobile/shared/services/json_cache.dart';
class AssetCacheService extends JsonCache<List<Asset>> { class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache"); AssetCacheService() : super("asset_cache");
static Future<List<Map<String, dynamic>>> _computeSerialize(
List<Asset> assets) async {
return assets.map((e) => e.toJson()).toList();
}
@override @override
void put(List<Asset> data) { void put(List<Asset> data) async {
putRawData(data.map((e) => e.toJson()).toList()); putRawData(await compute(_computeSerialize, data));
}
static Future<List<Asset>> _computeEncode(List<dynamic> data) async {
return data.map((e) => Asset.fromJson(e)).whereNotNull().toList();
} }
@override @override
@ -17,8 +26,7 @@ class AssetCacheService extends JsonCache<List<Asset>> {
try { try {
final mapList = await readRawData() as List<dynamic>; final mapList = await readRawData() as List<dynamic>;
final responseData = final responseData = await compute(_computeEncode, mapList);
mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList();
return responseData; return responseData;
} catch (e) { } catch (e) {

View File

@ -1,5 +1,7 @@
import 'dart:math'; 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:immich_mobile/shared/models/asset.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -33,85 +35,122 @@ class RenderAssetGridElement {
}); });
} }
List<RenderAssetGridElement> assetsToRenderList( class _AssetGroupsToRenderListComputeParameters {
List<Asset> assets, final String monthFormat;
int assetsPerRow, final String dayFormat;
) { final String dayFormatYear;
List<RenderAssetGridElement> elements = []; final Map<String, List<Asset>> groups;
final int perRow;
int cursor = 0; _AssetGroupsToRenderListComputeParameters(this.monthFormat, this.dayFormat,
while (cursor < assets.length) { this.dayFormatYear, this.groups, this.perRow);
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;
} }
List<RenderAssetGridElement> assetGroupsToRenderList( class RenderList {
Map<String, List<Asset>> assetGroups, final List<RenderAssetGridElement> elements;
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
assetGroups.forEach((groupName, assets) { RenderList(this.elements);
try {
final date = DateTime.parse(groupName); 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( elements.add(
RenderAssetGridElement( RenderAssetGridElement(
RenderAssetGridElementType.monthTitle, RenderAssetGridElementType.dayTitle,
title: groupName, title: dateText,
date: date, date: date,
), relatedAssetList: assets,
);
}
// 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),
), ),
); );
elements.add(rowElement); // Add rows
cursor += rowElements; 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; return RenderList(elements);
} catch (e, stackTrace) { }
log.severe(e, stackTrace);
}
});
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,
),
);
}
} }

View File

@ -5,14 +5,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
class DailyTitleText extends ConsumerWidget { class DailyTitleText extends ConsumerWidget {
const DailyTitleText({ const DailyTitleText({
Key? key, Key? key,
required this.isoDate, required this.text,
required this.multiselectEnabled, required this.multiselectEnabled,
required this.onSelect, required this.onSelect,
required this.onDeselect, required this.onDeselect,
required this.selected, required this.selected,
}) : super(key: key); }) : super(key: key);
final String isoDate; final String text;
final bool multiselectEnabled; final bool multiselectEnabled;
final Function onSelect; final Function onSelect;
final Function onDeselect; final Function onDeselect;
@ -20,13 +20,7 @@ class DailyTitleText extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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() { void handleTitleIconClick() {
if (selected) { if (selected) {
@ -46,7 +40,7 @@ class DailyTitleText extends ConsumerWidget {
child: Row( child: Row(
children: [ children: [
Text( Text(
dateText, text,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,

View File

@ -24,22 +24,10 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false; bool _scrolling = false;
final Set<String> _selectedAssets = HashSet(); 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() { Set<Asset> _getSelectedAssets() {
return _selectedAssets return _selectedAssets
.map((e) => _assets.firstWhereOrNull((a) => a.id == e)) .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
.whereNotNull() .whereNotNull()
.toSet(); .toSet();
} }
@ -95,9 +83,9 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
return ThumbnailImage( return ThumbnailImage(
asset: asset, asset: asset,
assetList: _assets, assetList: widget.allAssets,
multiselectEnabled: widget.selectionActive, multiselectEnabled: widget.selectionActive,
isSelected: _selectedAssets.contains(asset.id), isSelected: widget.selectionActive && _selectedAssets.contains(asset.id),
onSelect: () => _selectAssets([asset]), onSelect: () => _selectAssets([asset]),
onDeselect: () => _deselectAssets([asset]), onDeselect: () => _deselectAssets([asset]),
useGrayBoxPlaceholder: true, useGrayBoxPlaceholder: true,
@ -137,7 +125,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
List<Asset> assets, List<Asset> assets,
) { ) {
return DailyTitleText( return DailyTitleText(
isoDate: title, text: title,
multiselectEnabled: widget.selectionActive, multiselectEnabled: widget.selectionActive,
onSelect: () => _selectAssets(assets), onSelect: () => _selectAssets(assets),
onDeselect: () => _deselectAssets(assets), onDeselect: () => _deselectAssets(assets),
@ -146,14 +134,11 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
Widget _buildMonthTitle(BuildContext context, String title) { Widget _buildMonthTitle(BuildContext context, String title) {
var monthTitleText = DateFormat("monthly_title_text_date_format".tr())
.format(DateTime.parse(title));
return Padding( return Padding(
key: Key("month-$title"), key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 32), padding: const EdgeInsets.only(left: 12.0, top: 32),
child: Text( child: Text(
monthTitleText, title,
style: TextStyle( style: TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@ -164,7 +149,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
Widget _itemBuilder(BuildContext c, int position) { Widget _itemBuilder(BuildContext c, int position) {
final item = widget.renderList[position]; final item = widget.renderList.elements[position];
if (item.type == RenderAssetGridElementType.dayTitle) { if (item.type == RenderAssetGridElementType.dayTitle) {
return _buildTitle(c, item.title!, item.relatedAssetList!); return _buildTitle(c, item.title!, item.relatedAssetList!);
@ -178,7 +163,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
Text _labelBuilder(int pos) { Text _labelBuilder(int pos) {
final date = widget.renderList[pos].date; final date = widget.renderList.elements[pos].date;
return Text( return Text(
DateFormat.yMMMd().format(date), DateFormat.yMMMd().format(date),
style: const TextStyle( style: const TextStyle(
@ -196,7 +181,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
Widget _buildAssetGrid() { Widget _buildAssetGrid() {
final useDragScrolling = _assets.length >= 20; final useDragScrolling = widget.allAssets.length >= 20;
void dragScrolling(bool active) { void dragScrolling(bool active) {
setState(() { setState(() {
@ -208,7 +193,8 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
itemBuilder: _itemBuilder, itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener, itemPositionsListener: _itemPositionsListener,
itemScrollController: _itemScrollController, itemScrollController: _itemScrollController,
itemCount: widget.renderList.length, itemCount: widget.renderList.elements.length,
addRepaintBoundaries: true,
); );
if (!useDragScrolling) { if (!useDragScrolling) {
@ -250,16 +236,18 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
} }
class ImmichAssetGrid extends StatefulWidget { class ImmichAssetGrid extends StatefulWidget {
final List<RenderAssetGridElement> renderList; final RenderList renderList;
final int assetsPerRow; final int assetsPerRow;
final double margin; final double margin;
final bool showStorageIndicator; final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener; final ImmichAssetGridSelectionListener? listener;
final bool selectionActive; final bool selectionActive;
final List<Asset> allAssets;
const ImmichAssetGrid({ const ImmichAssetGrid({
super.key, super.key,
required this.renderList, required this.renderList,
required this.allAssets,
required this.assetsPerRow, required this.assetsPerRow,
required this.showStorageIndicator, required this.showStorageIndicator,
this.listener, this.listener,

View File

@ -8,7 +8,6 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.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/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/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.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'; import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
@ -32,7 +31,6 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider); final appSettingService = ref.watch(appSettingsServiceProvider);
var renderList = ref.watch(renderListProvider);
final multiselectEnabled = ref.watch(multiselectProvider.notifier); final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
@ -212,10 +210,12 @@ class HomePage extends HookConsumerWidget {
top: selectionEnabledHook.value ? 0 : 60, top: selectionEnabledHook.value ? 0 : 60,
bottom: 0.0, bottom: 0.0,
), ),
child: ref.watch(assetProvider).isEmpty child: ref.watch(assetProvider).renderList == null ||
ref.watch(assetProvider).allAssets.isEmpty
? buildLoadingIndicator() ? buildLoadingIndicator()
: ImmichAssetGrid( : ImmichAssetGrid(
renderList: renderList, renderList: ref.watch(assetProvider).renderList!,
allAssets: ref.watch(assetProvider).allAssets,
assetsPerRow: appSettingService assetsPerRow: appSettingService
.getSetting(AppSettingsEnum.tilesPerRow), .getSetting(AppSettingsEnum.tilesPerRow),
showStorageIndicator: appSettingService showStorageIndicator: appSettingService

View File

@ -70,11 +70,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
); );
}); });
final searchRenderListProvider = StateProvider((ref) { final searchRenderListProvider = FutureProvider((ref) {
var assetGroups = ref.watch(searchResultGroupByDateTimeProvider); var assetGroups = ref.watch(searchResultGroupByDateTimeProvider);
var settings = ref.watch(appSettingsServiceProvider); var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow); final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
return assetGroupsToRenderList(assetGroups, assetsPerRow); return RenderList.fromAssetGroups(assetGroups, assetsPerRow);
}); });

View File

@ -111,6 +111,7 @@ class SearchResultPage extends HookConsumerWidget {
buildSearchResult() { buildSearchResult() {
var searchResultPageState = ref.watch(searchResultPageProvider); var searchResultPageState = ref.watch(searchResultPageProvider);
var searchResultRenderList = ref.watch(searchRenderListProvider); var searchResultRenderList = ref.watch(searchRenderListProvider);
var allSearchAssets = ref.watch(searchResultPageProvider).searchResult;
var settings = ref.watch(appSettingsServiceProvider); var settings = ref.watch(appSettingsServiceProvider);
final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow); final assetsPerRow = settings.getSetting(AppSettingsEnum.tilesPerRow);
@ -126,10 +127,21 @@ class SearchResultPage extends HookConsumerWidget {
} }
if (searchResultPageState.isSuccess) { if (searchResultPageState.isSuccess) {
return ImmichAssetGrid( return searchResultRenderList.when(
renderList: searchResultRenderList, data: (result) {
assetsPerRow: assetsPerRow, return ImmichAssetGrid(
showStorageIndicator: showStorageIndicator, allAssets: allSearchAssets,
renderList: result,
assetsPerRow: assetsPerRow,
showStorageIndicator: showStorageIndicator,
);
},
error: (err, stack) {
return Text("$err");
},
loading: () {
return const CircularProgressIndicator();
},
); );
} }

View File

@ -21,7 +21,7 @@ class StorageIndicator extends HookConsumerWidget {
appSettingService.setSetting(AppSettingsEnum.storageIndicator, value); appSettingService.setSetting(AppSettingsEnum.storageIndicator, value);
showStorageIndicator.value = value; showStorageIndicator.value = value;
ref.invalidate(assetGroupByDateTimeProvider); ref.invalidate(assetProvider);
} }
useEffect( useEffect(

View File

@ -23,7 +23,7 @@ class TilesPerRow extends HookConsumerWidget {
} }
void sliderChangedEnd(double _) { void sliderChangedEnd(double _) {
ref.invalidate(assetGroupByDateTimeProvider); ref.invalidate(assetProvider);
} }
useEffect( useEffect(

View File

@ -1,10 +1,14 @@
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.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.service.dart';
import 'package:immich_mobile/modules/home/services/asset_cache.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/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart'; import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
@ -14,18 +18,79 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.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 AssetService _assetService;
final AssetCacheService _assetCacheService; final AssetCacheService _assetCacheService;
final AppSettingsService _settingsService;
final log = Logger('AssetNotifier'); final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService(); final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false; bool _getAllAssetInProgress = false;
bool _deleteInProgress = false; bool _deleteInProgress = false;
AssetNotifier(this._assetService, this._assetCacheService) : super([]); AssetNotifier(
this._assetService,
this._assetCacheService,
this._settingsService,
) : super(AssetsState.fromAssetList([]));
_cacheState() { _updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async {
_assetCacheService.put(state); if (cache) {
_assetCacheService.put(newAssetList);
}
state =
await AssetsState.fromAssetList(newAssetList).withRenderDataStructure(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
);
} }
getAllAsset() async { getAllAsset() async {
@ -43,17 +108,19 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
final remoteTask = _assetService.getRemoteAssets( final remoteTask = _assetService.getRemoteAssets(
etag: isCacheValid ? box.get(assetEtagKey) : null, etag: isCacheValid ? box.get(assetEtagKey) : null,
); );
if (isCacheValid && state.isEmpty) { if (isCacheValid && state.allAssets.isEmpty) {
state = await _assetCacheService.get(); await _updateAssetsState(await _assetCacheService.get(), cache: false);
log.info( log.info(
"Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms", "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
); );
stopwatch.reset(); stopwatch.reset();
} }
int remoteBegin = state.indexWhere((a) => a.isRemote); int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.length : remoteBegin; remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
final List<Asset> currentLocal = state.slice(0, remoteBegin);
final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin);
final Pair<List<Asset>?, String?> remoteResult = await remoteTask; final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
List<Asset>? newRemote = remoteResult.first; List<Asset>? newRemote = remoteResult.first;
List<Asset>? newLocal = await localTask; List<Asset>? newLocal = await localTask;
@ -64,27 +131,32 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
log.info("state is already up-to-date"); log.info("state is already up-to-date");
return; return;
} }
newRemote ??= state.slice(remoteBegin); newRemote ??= state.allAssets.slice(remoteBegin);
newLocal ??= []; 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"); log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
_cacheState();
box.put(assetEtagKey, remoteResult.second); box.put(assetEtagKey, remoteResult.second);
log.info("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
} finally { } finally {
_getAllAssetInProgress = false; _getAllAssetInProgress = false;
} }
} }
List<Asset> _combineLocalAndRemoteAssets({ static Future<List<Asset>> _computeCombine(
required Iterable<Asset> local, _CombineAssetsComputeParameters data,
required List<Asset> remote, ) async {
}) { var local = data.local;
var remote = data.remote;
final deviceId = data.deviceId;
final List<Asset> assets = []; final List<Asset> assets = [];
if (remote.isNotEmpty && local.isNotEmpty) { if (remote.isNotEmpty && local.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remote final Set<String> existingIds = remote
.where((e) => e.deviceId == deviceId) .where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId) .map((e) => e.deviceAssetId)
@ -97,31 +169,40 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
return assets; 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() { clearAllAsset() {
state = []; _updateAssetsState([]);
_cacheState();
} }
onNewAssetUploaded(AssetResponseDto newAsset) { onNewAssetUploaded(AssetResponseDto newAsset) {
final int i = state.indexWhere( final int i = state.allAssets.indexWhere(
(a) => (a) =>
a.isRemote || a.isRemote ||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId), (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
); );
if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) { if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
state = [...state, Asset.remote(newAsset)]; _updateAssetsState([...state.allAssets, Asset.remote(newAsset)]);
} else { } else {
// order is important to keep all local-only assets at the beginning! // order is important to keep all local-only assets at the beginning!
state = [ _updateAssetsState([
...state.slice(0, i), ...state.allAssets.slice(0, i),
...state.slice(i + 1), ...state.allAssets.slice(i + 1),
Asset.remote(newAsset), Asset.remote(newAsset),
]; ]);
// TODO here is a place to unify local/remote assets by replacing the // TODO here is a place to unify local/remote assets by replacing the
// local-only asset in the state with a local&remote asset // local-only asset in the state with a local&remote asset
} }
_cacheState();
} }
deleteAssets(Set<Asset> deleteAssets) async { deleteAssets(Set<Asset> deleteAssets) async {
@ -133,8 +214,9 @@ class AssetNotifier extends StateNotifier<List<Asset>> {
deleted.addAll(localDeleted); deleted.addAll(localDeleted);
deleted.addAll(remoteDeleted); deleted.addAll(remoteDeleted);
if (deleted.isNotEmpty) { if (deleted.isNotEmpty) {
state = state.where((a) => !deleted.contains(a.id)).toList(); _updateAssetsState(
_cacheState(); state.allAssets.where((a) => !deleted.contains(a.id)).toList(),
);
} }
} finally { } finally {
_deleteInProgress = false; _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( return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetServiceProvider),
ref.watch(assetCacheServiceProvider), ref.watch(assetCacheServiceProvider),
); ref.watch(appSettingsServiceProvider),
});
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()),
); );
}); });
@ -204,7 +274,8 @@ final assetGroupByMonthYearProvider = StateProvider((ref) {
// TODO: remove `where` once temporary workaround is no longer needed (to only // 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 // allow remote assets to be added to album). Keep `toList()` as to NOT sort
// the original list/state // 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>( assets.sortByCompare<DateTime>(
(e) => e.createdAt, (e) => e.createdAt,

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
abstract class JsonCache<T> { 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 { Future<void> putRawData(dynamic data) async {
final jsonString = json.encode(data); final jsonString = await compute(_computeEncodeJson, data);
final file = await _getCacheFile(); final file = await _getCacheFile();
if (!await file.exists()) { if (!await file.exists()) {
@ -42,10 +48,15 @@ abstract class JsonCache<T> {
await file.writeAsString(jsonString); 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 file = await _getCacheFile();
final data = await file.readAsString(); final data = await file.readAsString();
return json.decode(data);
return await compute(_computeDecodeJson, data);
} }
void put(T data); void put(T data);

View File

@ -54,55 +54,9 @@ void main() {
}).toList() }).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', () { group('Test grouped', () {
test('test grouped check months', () { test('test grouped check months', () async {
final renderList = assetGroupsToRenderList(groups, 3); final renderList = await RenderList.fromAssetGroups(groups, 3);
// Jan // Jan
// Day 1 // Day 1
@ -115,17 +69,17 @@ void main() {
// Oct // Oct
// Day 1 // Day 1
// 15 Assets => 5 Rows // 15 Assets => 5 Rows
expect(renderList.length, 18); expect(renderList.elements.length, 18);
expect(renderList[0].type, RenderAssetGridElementType.monthTitle); expect(renderList.elements[0].type, RenderAssetGridElementType.monthTitle);
expect(renderList[0].date.month, 1); expect(renderList.elements[0].date.month, 1);
expect(renderList[7].type, RenderAssetGridElementType.monthTitle); expect(renderList.elements[7].type, RenderAssetGridElementType.monthTitle);
expect(renderList[7].date.month, 2); expect(renderList.elements[7].date.month, 2);
expect(renderList[11].type, RenderAssetGridElementType.monthTitle); expect(renderList.elements[11].type, RenderAssetGridElementType.monthTitle);
expect(renderList[11].date.month, 10); expect(renderList.elements[11].date.month, 10);
}); });
test('test grouped check types', () { test('test grouped check types', () async {
final renderList = assetGroupsToRenderList(groups, 5); final renderList = await RenderList.fromAssetGroups(groups, 5);
// Jan // Jan
// Day 1 // Day 1
@ -155,10 +109,10 @@ void main() {
RenderAssetGridElementType.assetRow RenderAssetGridElementType.assetRow
]; ];
expect(renderList.length, types.length); expect(renderList.elements.length, types.length);
for (int i = 0; i < renderList.length; i++) { for (int i = 0; i < renderList.elements.length; i++) {
expect(renderList[i].type, types[i]); expect(renderList.elements[i].type, types[i]);
} }
}); });
}); });