|
@@ -2,212 +2,313 @@ import 'dart:math';
|
|
|
|
|
|
import 'package:collection/collection.dart';
|
|
|
import 'package:easy_localization/easy_localization.dart';
|
|
|
-import 'package:flutter/foundation.dart';
|
|
|
import 'package:immich_mobile/shared/models/asset.dart';
|
|
|
+import 'package:isar/isar.dart';
|
|
|
import 'package:logging/logging.dart';
|
|
|
|
|
|
final log = Logger('AssetGridDataStructure');
|
|
|
|
|
|
enum RenderAssetGridElementType {
|
|
|
+ assets,
|
|
|
assetRow,
|
|
|
groupDividerTitle,
|
|
|
monthTitle;
|
|
|
}
|
|
|
|
|
|
-class RenderAssetGridRow {
|
|
|
- final List<Asset> assets;
|
|
|
- final List<double> widthDistribution;
|
|
|
-
|
|
|
- RenderAssetGridRow(this.assets, this.widthDistribution);
|
|
|
-}
|
|
|
-
|
|
|
class RenderAssetGridElement {
|
|
|
final RenderAssetGridElementType type;
|
|
|
- final RenderAssetGridRow? assetRow;
|
|
|
final String? title;
|
|
|
final DateTime date;
|
|
|
- final List<Asset>? relatedAssetList;
|
|
|
+ final int count;
|
|
|
+ final int offset;
|
|
|
+ final int totalCount;
|
|
|
|
|
|
RenderAssetGridElement(
|
|
|
this.type, {
|
|
|
- this.assetRow,
|
|
|
this.title,
|
|
|
required this.date,
|
|
|
- this.relatedAssetList,
|
|
|
+ this.count = 0,
|
|
|
+ this.offset = 0,
|
|
|
+ this.totalCount = 0,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
enum GroupAssetsBy {
|
|
|
day,
|
|
|
- month;
|
|
|
-}
|
|
|
-
|
|
|
-class AssetGridLayoutParameters {
|
|
|
- final int perRow;
|
|
|
- final bool dynamicLayout;
|
|
|
- final GroupAssetsBy groupBy;
|
|
|
-
|
|
|
- AssetGridLayoutParameters(
|
|
|
- this.perRow,
|
|
|
- this.dynamicLayout,
|
|
|
- this.groupBy,
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-class _AssetGroupsToRenderListComputeParameters {
|
|
|
- final List<Asset> assets;
|
|
|
- final AssetGridLayoutParameters layout;
|
|
|
-
|
|
|
- _AssetGroupsToRenderListComputeParameters(
|
|
|
- this.assets,
|
|
|
- this.layout,
|
|
|
- );
|
|
|
+ month,
|
|
|
+ auto,
|
|
|
+ none,
|
|
|
+ ;
|
|
|
}
|
|
|
|
|
|
class RenderList {
|
|
|
final List<RenderAssetGridElement> elements;
|
|
|
-
|
|
|
- RenderList(this.elements);
|
|
|
-
|
|
|
- static Map<DateTime, List<Asset>> _groupAssets(
|
|
|
- List<Asset> assets,
|
|
|
- GroupAssetsBy groupBy,
|
|
|
- ) {
|
|
|
- if (groupBy == GroupAssetsBy.day) {
|
|
|
- return assets.groupListsBy(
|
|
|
- (element) {
|
|
|
- final date = element.fileCreatedAt.toLocal();
|
|
|
- return DateTime(date.year, date.month, date.day);
|
|
|
- },
|
|
|
- );
|
|
|
- } else if (groupBy == GroupAssetsBy.month) {
|
|
|
- return assets.groupListsBy(
|
|
|
- (element) {
|
|
|
- final date = element.fileCreatedAt.toLocal();
|
|
|
- return DateTime(date.year, date.month);
|
|
|
- },
|
|
|
- );
|
|
|
+ final List<Asset>? allAssets;
|
|
|
+ final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
|
|
|
+ final int totalAssets;
|
|
|
+
|
|
|
+ /// reference to batch of assets loaded from DB with offset [_bufOffset]
|
|
|
+ List<Asset> _buf = [];
|
|
|
+
|
|
|
+ /// global offset of assets in [_buf]
|
|
|
+ int _bufOffset = 0;
|
|
|
+
|
|
|
+ RenderList(this.elements, this.query, this.allAssets)
|
|
|
+ : totalAssets = allAssets?.length ?? query!.countSync();
|
|
|
+
|
|
|
+ bool get isEmpty => totalAssets == 0;
|
|
|
+
|
|
|
+ /// Loads the requested assets from the database to an internal buffer if not cached
|
|
|
+ /// and returns a slice of that buffer
|
|
|
+ List<Asset> loadAssets(int offset, int count) {
|
|
|
+ assert(offset >= 0);
|
|
|
+ assert(count > 0);
|
|
|
+ assert(offset + count <= totalAssets);
|
|
|
+ if (allAssets != null) {
|
|
|
+ // if we already loaded all assets (e.g. from search result)
|
|
|
+ // simply return the requested slice of that array
|
|
|
+ return allAssets!.slice(offset, offset + count);
|
|
|
+ } else if (query != null) {
|
|
|
+ // general case: we have the query to load assets via offset from the DB on demand
|
|
|
+ if (offset < _bufOffset || offset + count > _bufOffset + _buf.length) {
|
|
|
+ // the requested slice (offset:offset+count) is not contained in the cache buffer `_buf`
|
|
|
+ // thus, fill the buffer with a new batch of assets that at least contains the requested
|
|
|
+ // assets and some more
|
|
|
+
|
|
|
+ final bool forward = _bufOffset < offset;
|
|
|
+ // if the requested offset is greater than the cached offset, the user scrolls forward "down"
|
|
|
+ const batchSize = 256;
|
|
|
+ const oppositeSize = 64;
|
|
|
+
|
|
|
+ // make sure to load a meaningful amount of data (and not only the requested slice)
|
|
|
+ // otherwise, each call to [loadAssets] would result in DB call trashing performance
|
|
|
+ // fills small requests to [batchSize], adds some legroom into the opposite scroll direction for large requests
|
|
|
+ final len = max(batchSize, count + oppositeSize);
|
|
|
+ // when scrolling forward, start shortly before the requested offset...
|
|
|
+ // when scrolling backward, end shortly after the requested offset...
|
|
|
+ // ... to guard against the user scrolling in the other direction
|
|
|
+ // a tiny bit resulting in a another required load from the DB
|
|
|
+ final start = max(
|
|
|
+ 0,
|
|
|
+ forward
|
|
|
+ ? offset - oppositeSize
|
|
|
+ : (len > batchSize ? offset : offset + count - len),
|
|
|
+ );
|
|
|
+ // load the calculated batch (start:start+len) from the DB and put it into the buffer
|
|
|
+ _buf = query!.offset(start).limit(len).findAllSync();
|
|
|
+ _bufOffset = start;
|
|
|
+ }
|
|
|
+ assert(_bufOffset <= offset);
|
|
|
+ assert(_bufOffset + _buf.length >= offset + count);
|
|
|
+ // return the requested slice from the buffer (we made sure before that the assets are loaded!)
|
|
|
+ return _buf.slice(offset - _bufOffset, offset - _bufOffset + count);
|
|
|
}
|
|
|
+ throw Exception("RenderList has neither assets nor query");
|
|
|
+ }
|
|
|
|
|
|
- return {};
|
|
|
+ /// Returns the requested asset either from cached buffer or directly from the database
|
|
|
+ Asset loadAsset(int index) {
|
|
|
+ if (allAssets != null) {
|
|
|
+ // all assets are already loaded (e.g. from search result)
|
|
|
+ return allAssets![index];
|
|
|
+ } else if (query != null) {
|
|
|
+ // general case: we have the DB query to load asset(s) on demand
|
|
|
+ if (index >= _bufOffset && index < _bufOffset + _buf.length) {
|
|
|
+ // lucky case: the requested asset is already cached in the buffer!
|
|
|
+ return _buf[index - _bufOffset];
|
|
|
+ }
|
|
|
+ // request the asset from the database (not changing the buffer!)
|
|
|
+ final asset = query!.offset(index).findFirstSync();
|
|
|
+ if (asset == null) {
|
|
|
+ throw Exception(
|
|
|
+ "Asset at index $index does no longer exist in database",
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return asset;
|
|
|
+ }
|
|
|
+ throw Exception("RenderList has neither assets nor query");
|
|
|
}
|
|
|
|
|
|
- static Future<RenderList> _processAssetGroupData(
|
|
|
- _AssetGroupsToRenderListComputeParameters data,
|
|
|
- ) async {
|
|
|
- // TODO: Make DateFormat use the configured locale.
|
|
|
- final monthFormat = DateFormat.yMMM();
|
|
|
- final dayFormatSameYear = DateFormat.MMMEd();
|
|
|
- final dayFormatOtherYear = DateFormat.yMMMEd();
|
|
|
- final allAssets = data.assets;
|
|
|
- final perRow = data.layout.perRow;
|
|
|
- final dynamicLayout = data.layout.dynamicLayout;
|
|
|
- final groupBy = data.layout.groupBy;
|
|
|
-
|
|
|
- List<RenderAssetGridElement> elements = [];
|
|
|
- DateTime? lastDate;
|
|
|
-
|
|
|
- final groups = _groupAssets(allAssets, groupBy);
|
|
|
-
|
|
|
- groups.entries.sortedBy((e) => e.key).reversed.forEach((entry) {
|
|
|
- final date = entry.key;
|
|
|
- final assets = entry.value;
|
|
|
-
|
|
|
- try {
|
|
|
- // Month title
|
|
|
- if (groupBy == GroupAssetsBy.day &&
|
|
|
- (lastDate == null || lastDate!.month != date.month)) {
|
|
|
- elements.add(
|
|
|
- RenderAssetGridElement(
|
|
|
- RenderAssetGridElementType.monthTitle,
|
|
|
- title: monthFormat.format(date),
|
|
|
- date: date,
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
+ static Future<RenderList> fromQuery(
|
|
|
+ QueryBuilder<Asset, Asset, QAfterSortBy> query,
|
|
|
+ GroupAssetsBy groupBy,
|
|
|
+ ) =>
|
|
|
+ _buildRenderList(null, query, groupBy);
|
|
|
|
|
|
- // Group divider title (day or month)
|
|
|
- var formatDate = dayFormatOtherYear;
|
|
|
+ static Future<RenderList> _buildRenderList(
|
|
|
+ List<Asset>? assets,
|
|
|
+ QueryBuilder<Asset, Asset, QAfterSortBy>? query,
|
|
|
+ GroupAssetsBy groupBy,
|
|
|
+ ) async {
|
|
|
+ final List<RenderAssetGridElement> elements = [];
|
|
|
+
|
|
|
+ const pageSize = 500;
|
|
|
+ const sectionSize = 60; // divides evenly by 2,3,4,5,6
|
|
|
+
|
|
|
+ if (groupBy == GroupAssetsBy.none) {
|
|
|
+ final int total = assets?.length ?? query!.countSync();
|
|
|
+ for (int i = 0; i < total; i += sectionSize) {
|
|
|
+ final date = assets != null
|
|
|
+ ? assets[i].fileCreatedAt
|
|
|
+ : await query!.offset(i).fileCreatedAtProperty().findFirst();
|
|
|
+ final int count = i + sectionSize > total ? total - i : sectionSize;
|
|
|
+ if (date == null) break;
|
|
|
+ elements.add(
|
|
|
+ RenderAssetGridElement(
|
|
|
+ RenderAssetGridElementType.assets,
|
|
|
+ date: date,
|
|
|
+ count: count,
|
|
|
+ totalCount: total,
|
|
|
+ offset: i,
|
|
|
+ ),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ return RenderList(elements, query, assets);
|
|
|
+ }
|
|
|
|
|
|
- if (DateTime.now().year == date.year) {
|
|
|
- formatDate = dayFormatSameYear;
|
|
|
- }
|
|
|
+ final formatSameYear =
|
|
|
+ groupBy == GroupAssetsBy.month ? DateFormat.MMMM() : DateFormat.MMMEd();
|
|
|
+ final formatOtherYear = groupBy == GroupAssetsBy.month
|
|
|
+ ? DateFormat.yMMMM()
|
|
|
+ : DateFormat.yMMMEd();
|
|
|
+ final currentYear = DateTime.now().year;
|
|
|
+ final formatMergedSameYear = DateFormat.MMMd();
|
|
|
+ final formatMergedOtherYear = DateFormat.yMMMd();
|
|
|
+
|
|
|
+ int offset = 0;
|
|
|
+ DateTime? last;
|
|
|
+ DateTime? current;
|
|
|
+ int lastOffset = 0;
|
|
|
+ int count = 0;
|
|
|
+ int monthCount = 0;
|
|
|
+ int lastMonthIndex = 0;
|
|
|
+
|
|
|
+ String formatDateRange(DateTime from, DateTime to) {
|
|
|
+ final startDate = (from.year == currentYear
|
|
|
+ ? formatMergedSameYear
|
|
|
+ : formatMergedOtherYear)
|
|
|
+ .format(from);
|
|
|
+ final endDate = (to.year == currentYear
|
|
|
+ ? formatMergedSameYear
|
|
|
+ : formatMergedOtherYear)
|
|
|
+ .format(to);
|
|
|
+ if (DateTime(from.year, from.month, from.day) ==
|
|
|
+ DateTime(to.year, to.month, to.day)) {
|
|
|
+ // format range with time when both dates are on the same day
|
|
|
+ final startTime = DateFormat.Hm().format(from);
|
|
|
+ final endTime = DateFormat.Hm().format(to);
|
|
|
+ return "$startDate $startTime - $endTime";
|
|
|
+ }
|
|
|
+ return "$startDate - $endDate";
|
|
|
+ }
|
|
|
|
|
|
- if (groupBy == GroupAssetsBy.month) {
|
|
|
- formatDate = monthFormat;
|
|
|
- }
|
|
|
+ void mergeMonth() {
|
|
|
+ if (last != null &&
|
|
|
+ groupBy == GroupAssetsBy.auto &&
|
|
|
+ monthCount <= 30 &&
|
|
|
+ elements.length > lastMonthIndex + 1) {
|
|
|
+ // merge all days into a single section
|
|
|
+ assert(elements[lastMonthIndex].date.month == last.month);
|
|
|
+ final e = elements[lastMonthIndex];
|
|
|
+
|
|
|
+ elements[lastMonthIndex] = RenderAssetGridElement(
|
|
|
+ RenderAssetGridElementType.monthTitle,
|
|
|
+ date: e.date,
|
|
|
+ count: monthCount,
|
|
|
+ totalCount: monthCount,
|
|
|
+ offset: e.offset,
|
|
|
+ title: formatDateRange(e.date, elements.last.date),
|
|
|
+ );
|
|
|
+ elements.removeRange(lastMonthIndex + 1, elements.length);
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
+ void addElems(DateTime d, DateTime? prevDate) {
|
|
|
+ final bool newMonth =
|
|
|
+ last == null || last.year != d.year || last.month != d.month;
|
|
|
+ if (newMonth) {
|
|
|
+ mergeMonth();
|
|
|
+ lastMonthIndex = elements.length;
|
|
|
+ monthCount = 0;
|
|
|
+ }
|
|
|
+ for (int j = 0; j < count; j += sectionSize) {
|
|
|
+ final type = j == 0
|
|
|
+ ? (groupBy != GroupAssetsBy.month && newMonth
|
|
|
+ ? RenderAssetGridElementType.monthTitle
|
|
|
+ : RenderAssetGridElementType.groupDividerTitle)
|
|
|
+ : (groupBy == GroupAssetsBy.auto
|
|
|
+ ? RenderAssetGridElementType.groupDividerTitle
|
|
|
+ : RenderAssetGridElementType.assets);
|
|
|
+ final sectionCount = j + sectionSize > count ? count - j : sectionSize;
|
|
|
+ assert(sectionCount > 0 && sectionCount <= sectionSize);
|
|
|
elements.add(
|
|
|
RenderAssetGridElement(
|
|
|
- RenderAssetGridElementType.groupDividerTitle,
|
|
|
- title: formatDate.format(date),
|
|
|
- date: date,
|
|
|
- relatedAssetList: assets,
|
|
|
+ type,
|
|
|
+ date: d,
|
|
|
+ count: sectionCount,
|
|
|
+ totalCount: groupBy == GroupAssetsBy.auto ? sectionCount : count,
|
|
|
+ offset: lastOffset + j,
|
|
|
+ title: j == 0
|
|
|
+ ? (d.year == currentYear
|
|
|
+ ? formatSameYear.format(d)
|
|
|
+ : formatOtherYear.format(d))
|
|
|
+ : (groupBy == GroupAssetsBy.auto
|
|
|
+ ? formatDateRange(d, prevDate ?? d)
|
|
|
+ : null),
|
|
|
),
|
|
|
);
|
|
|
+ }
|
|
|
+ monthCount += count;
|
|
|
+ }
|
|
|
|
|
|
- // Add rows
|
|
|
- int cursor = 0;
|
|
|
- while (cursor < assets.length) {
|
|
|
- int rowElements = min(assets.length - cursor, perRow);
|
|
|
- final rowAssets = assets.sublist(cursor, cursor + rowElements);
|
|
|
-
|
|
|
- // Default: All assets have the same width
|
|
|
- var widthDistribution = List.filled(rowElements, 1.0);
|
|
|
-
|
|
|
- if (dynamicLayout) {
|
|
|
- final aspectRatios =
|
|
|
- rowAssets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
|
|
|
- final meanAspectRatio = aspectRatios.sum / rowElements;
|
|
|
-
|
|
|
- // 1: mean width
|
|
|
- // 0.5: width < mean - threshold
|
|
|
- // 1.5: width > mean + threshold
|
|
|
- final arConfiguration = aspectRatios.map((e) {
|
|
|
- if (e - meanAspectRatio > 0.3) return 1.5;
|
|
|
- if (e - meanAspectRatio < -0.3) return 0.5;
|
|
|
- return 1.0;
|
|
|
- });
|
|
|
-
|
|
|
- // Normalize:
|
|
|
- final sum = arConfiguration.sum;
|
|
|
- widthDistribution =
|
|
|
- arConfiguration.map((e) => (e * rowElements) / sum).toList();
|
|
|
- }
|
|
|
-
|
|
|
- final rowElement = RenderAssetGridElement(
|
|
|
- RenderAssetGridElementType.assetRow,
|
|
|
- date: date,
|
|
|
- assetRow: RenderAssetGridRow(
|
|
|
- rowAssets,
|
|
|
- widthDistribution,
|
|
|
- ),
|
|
|
- );
|
|
|
-
|
|
|
- elements.add(rowElement);
|
|
|
- cursor += rowElements;
|
|
|
+ DateTime? prevDate;
|
|
|
+ while (true) {
|
|
|
+ // this iterates all assets (only their createdAt property) in batches
|
|
|
+ // memory usage is okay, however runtime is linear with number of assets
|
|
|
+ // TODO replace with groupBy once Isar supports such queries
|
|
|
+ final dates = assets != null
|
|
|
+ ? assets.map((a) => a.fileCreatedAt)
|
|
|
+ : await query!
|
|
|
+ .offset(offset)
|
|
|
+ .limit(pageSize)
|
|
|
+ .fileCreatedAtProperty()
|
|
|
+ .findAll();
|
|
|
+ int i = 0;
|
|
|
+ for (final date in dates) {
|
|
|
+ final d = DateTime(
|
|
|
+ date.year,
|
|
|
+ date.month,
|
|
|
+ groupBy == GroupAssetsBy.month ? 1 : date.day,
|
|
|
+ );
|
|
|
+ current ??= d;
|
|
|
+ if (current != d) {
|
|
|
+ addElems(current, prevDate);
|
|
|
+ last = current;
|
|
|
+ current = d;
|
|
|
+ lastOffset = offset + i;
|
|
|
+ count = 0;
|
|
|
}
|
|
|
-
|
|
|
- lastDate = date;
|
|
|
- } catch (e, stackTrace) {
|
|
|
- log.severe(e, stackTrace);
|
|
|
+ prevDate = date;
|
|
|
+ count++;
|
|
|
+ i++;
|
|
|
}
|
|
|
- });
|
|
|
|
|
|
- return RenderList(elements);
|
|
|
+ if (assets != null || dates.length != pageSize) break;
|
|
|
+ offset += pageSize;
|
|
|
+ }
|
|
|
+ if (count > 0 && current != null) {
|
|
|
+ addElems(current, prevDate);
|
|
|
+ mergeMonth();
|
|
|
+ }
|
|
|
+ assert(elements.every((e) => e.count <= sectionSize), "too large section");
|
|
|
+ return RenderList(elements, query, assets);
|
|
|
}
|
|
|
|
|
|
+ static RenderList empty() => RenderList([], null, []);
|
|
|
+
|
|
|
static Future<RenderList> fromAssets(
|
|
|
List<Asset> assets,
|
|
|
- AssetGridLayoutParameters layout,
|
|
|
- ) async {
|
|
|
- // Compute only allows for one parameter. Therefore we pass all parameters in a map
|
|
|
- return compute(
|
|
|
- _processAssetGroupData,
|
|
|
- _AssetGroupsToRenderListComputeParameters(
|
|
|
- assets,
|
|
|
- layout,
|
|
|
- ),
|
|
|
- );
|
|
|
- }
|
|
|
+ GroupAssetsBy groupBy,
|
|
|
+ ) =>
|
|
|
+ _buildRenderList(assets, null, groupBy);
|
|
|
}
|