diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index b338b7b75..7ca6b7a2b 100644 --- a/mobile/analysis_options.yaml +++ b/mobile/analysis_options.yaml @@ -56,7 +56,7 @@ custom_lint: allowed: # required / wanted - 'lib/infrastructure/repositories/album_media.repository.dart' - - 'lib/infrastructure/repositories/storage.repository.dart' + - 'lib/infrastructure/repositories/{storage,asset_media}.repository.dart' - 'lib/repositories/{album,asset,file}_media.repository.dart' # acceptable exceptions for the time being - lib/entities/asset.entity.dart # to provide local AssetEntity for now diff --git a/mobile/build.yaml b/mobile/build.yaml index d5de77a37..76cc0a998 100644 --- a/mobile/build.yaml +++ b/mobile/build.yaml @@ -17,6 +17,7 @@ targets: main: lib/infrastructure/repositories/db.repository.dart generate_for: &drift_generate_for - lib/infrastructure/entities/*.dart + - lib/infrastructure/entities/*.drift - lib/infrastructure/repositories/db.repository.dart drift_dev:modular: enabled: true diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 0d147b9b2..1b2c86026 100644 Binary files a/mobile/drift_schemas/main/drift_schema_v1.json and b/mobile/drift_schemas/main/drift_schema_v1.json differ diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 861e8196c..85f3b1fcf 100644 --- a/mobile/ios/Runner/Sync/MessagesImpl.swift +++ b/mobile/ios/Runner/Sync/MessagesImpl.swift @@ -36,6 +36,7 @@ class NativeSyncApiImpl: NativeSyncApi { private let defaults: UserDefaults private let changeTokenKey = "immich:changeToken" private let albumTypes: [PHAssetCollectionType] = [.album, .smartAlbum] + private let recoveredAlbumSubType = 1000000219 private let hashBufferSize = 2 * 1024 * 1024 @@ -91,9 +92,17 @@ class NativeSyncApiImpl: NativeSyncApi { albumTypes.forEach { type in let collections = PHAssetCollection.fetchAssetCollections(with: type, subtype: .any, options: nil) - collections.enumerateObjects { (album, _, _) in + for i in 0.. %@ OR modificationDate > %@", date, date) + options.includeHiddenAssets = false let assets = PHAsset.fetchAssets(in: album, options: options) return Int64(assets.count) } @@ -230,6 +245,7 @@ class NativeSyncApiImpl: NativeSyncApi { } let options = PHFetchOptions() + options.includeHiddenAssets = false if(updatedTimeCond != nil) { let date = NSDate(timeIntervalSince1970: TimeInterval(updatedTimeCond!)) options.predicate = NSPredicate(format: "creationDate > %@ OR modificationDate > %@", date, date) diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index 8c95922a3..3d9d9a906 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -15,3 +15,8 @@ const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB // Secure storage keys const String kSecuredPinCode = "secured_pin_code"; + +// Timeline constants +const int kTimelineNoneSegmentSize = 120; +const int kTimelineAssetLoadBatchSize = 256; +const int kTimelineAssetLoadOppositeSize = 64; diff --git a/mobile/lib/domain/interfaces/asset_media.interface.dart b/mobile/lib/domain/interfaces/asset_media.interface.dart new file mode 100644 index 000000000..93f99827e --- /dev/null +++ b/mobile/lib/domain/interfaces/asset_media.interface.dart @@ -0,0 +1,10 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +abstract interface class IAssetMediaRepository { + Future getThumbnail( + String id, { + int quality = 80, + Size size = const Size.square(256), + }); +} diff --git a/mobile/lib/domain/interfaces/timeline.interface.dart b/mobile/lib/domain/interfaces/timeline.interface.dart new file mode 100644 index 000000000..e60dd83b5 --- /dev/null +++ b/mobile/lib/domain/interfaces/timeline.interface.dart @@ -0,0 +1,27 @@ +import 'package:immich_mobile/domain/interfaces/db.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; + +abstract interface class ITimelineRepository implements IDatabaseRepository { + Stream> watchMainBucket( + List timelineUsers, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }); + + Future> getMainBucketAssets( + List timelineUsers, { + required int offset, + required int count, + }); + + Stream> watchLocalBucket( + String albumId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }); + + Future> getLocalBucketAssets( + String albumId, { + required int offset, + required int count, + }); +} diff --git a/mobile/lib/domain/models/asset/asset.model.dart b/mobile/lib/domain/models/asset/asset.model.dart index c170f7f84..b0a0b0dfb 100644 --- a/mobile/lib/domain/models/asset/asset.model.dart +++ b/mobile/lib/domain/models/asset/asset.model.dart @@ -11,6 +11,7 @@ enum AssetVisibility { class Asset extends BaseAsset { final String id; final String? localId; + final String? thumbHash; final AssetVisibility visibility; const Asset({ @@ -25,9 +26,14 @@ class Asset extends BaseAsset { super.height, super.durationInSeconds, super.isFavorite = false, + this.thumbHash, this.visibility = AssetVisibility.timeline, }); + @override + AssetState get storage => + localId == null ? AssetState.remote : AssetState.merged; + @override String toString() { return '''Asset { @@ -41,6 +47,7 @@ class Asset extends BaseAsset { durationInSeconds: ${durationInSeconds ?? ""}, localId: ${localId ?? ""}, isFavorite: $isFavorite, + thumbHash: ${thumbHash ?? ""}, visibility: $visibility, }'''; } @@ -52,10 +59,15 @@ class Asset extends BaseAsset { return super == other && id == other.id && localId == other.localId && + thumbHash == other.thumbHash && visibility == other.visibility; } @override int get hashCode => - super.hashCode ^ id.hashCode ^ localId.hashCode ^ visibility.hashCode; + super.hashCode ^ + id.hashCode ^ + localId.hashCode ^ + thumbHash.hashCode ^ + visibility.hashCode; } diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index fb9543765..509998a10 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -9,6 +9,12 @@ enum AssetType { audio, } +enum AssetState { + local, + remote, + merged, +} + sealed class BaseAsset { final String name; final String? checksum; @@ -32,6 +38,10 @@ sealed class BaseAsset { this.isFavorite = false, }); + bool get isImage => type == AssetType.image; + bool get isVideo => type == AssetType.video; + AssetState get storage; + @override String toString() { return '''BaseAsset { diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 25e617d8e..95eb1bce9 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -18,6 +18,10 @@ class LocalAsset extends BaseAsset { super.isFavorite = false, }); + @override + AssetState get storage => + remoteId == null ? AssetState.local : AssetState.merged; + @override String toString() { return '''LocalAsset { diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart new file mode 100644 index 000000000..d975cbb4f --- /dev/null +++ b/mobile/lib/domain/models/setting.model.dart @@ -0,0 +1,12 @@ +import 'package:immich_mobile/domain/models/store.model.dart'; + +enum Setting { + tilesPerRow(StoreKey.tilesPerRow, 4), + groupAssetsBy(StoreKey.groupAssetsBy, 0), + showStorageIndicator(StoreKey.storageIndicator, true); + + const Setting(this.storeKey, this.defaultValue); + + final StoreKey storeKey; + final T defaultValue; +} diff --git a/mobile/lib/domain/models/timeline.model.dart b/mobile/lib/domain/models/timeline.model.dart new file mode 100644 index 000000000..4a49708b7 --- /dev/null +++ b/mobile/lib/domain/models/timeline.model.dart @@ -0,0 +1,40 @@ +enum GroupAssetsBy { + day, + month, + none; +} + +enum HeaderType { + none, + month, + day, + monthAndDay; +} + +class Bucket { + final int assetCount; + + const Bucket({required this.assetCount}); + + @override + bool operator ==(covariant Bucket other) { + return assetCount == other.assetCount; + } + + @override + int get hashCode => assetCount.hashCode; +} + +class TimeBucket extends Bucket { + final DateTime date; + + const TimeBucket({required this.date, required super.assetCount}); + + @override + bool operator ==(covariant TimeBucket other) { + return super == other && date == other.date; + } + + @override + int get hashCode => super.hashCode ^ date.hashCode; +} diff --git a/mobile/lib/domain/services/setting.service.dart b/mobile/lib/domain/services/setting.service.dart new file mode 100644 index 000000000..2d1937be5 --- /dev/null +++ b/mobile/lib/domain/services/setting.service.dart @@ -0,0 +1,19 @@ +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/store.service.dart'; + +class SettingsService { + final StoreService _storeService; + + const SettingsService({required StoreService storeService}) + : _storeService = storeService; + + T get(Setting setting) => + _storeService.get(setting.storeKey, setting.defaultValue); + + Future set(Setting setting, T value) => + _storeService.put(setting.storeKey, value); + + Stream watch(Setting setting) => _storeService + .watch(setting.storeKey) + .map((v) => v ?? setting.defaultValue); +} diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart new file mode 100644 index 000000000..d1211f46e --- /dev/null +++ b/mobile/lib/domain/services/timeline.service.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/utils/async_mutex.dart'; + +typedef TimelineAssetSource = Future> Function( + int index, + int count, +); + +typedef TimelineBucketSource = Stream> Function(); + +class TimelineFactory { + final ITimelineRepository _timelineRepository; + final SettingsService _settingsService; + + const TimelineFactory({ + required ITimelineRepository timelineRepository, + required SettingsService settingsService, + }) : _timelineRepository = timelineRepository, + _settingsService = settingsService; + + GroupAssetsBy get groupBy => + GroupAssetsBy.values[_settingsService.get(Setting.groupAssetsBy)]; + + TimelineService main(List timelineUsers) => TimelineService( + assetSource: (offset, count) => _timelineRepository + .getMainBucketAssets(timelineUsers, offset: offset, count: count), + bucketSource: () => _timelineRepository.watchMainBucket( + timelineUsers, + groupBy: groupBy, + ), + ); + + TimelineService localAlbum({required String albumId}) => TimelineService( + assetSource: (offset, count) => _timelineRepository + .getLocalBucketAssets(albumId, offset: offset, count: count), + bucketSource: () => + _timelineRepository.watchLocalBucket(albumId, groupBy: groupBy), + ); +} + +class TimelineService { + final TimelineAssetSource _assetSource; + final TimelineBucketSource _bucketSource; + + TimelineService({ + required TimelineAssetSource assetSource, + required TimelineBucketSource bucketSource, + }) : _assetSource = assetSource, + _bucketSource = bucketSource { + _bucketSubscription = + _bucketSource().listen((_) => unawaited(_reloadBucket())); + } + + final AsyncMutex _mutex = AsyncMutex(); + int _bufferOffset = 0; + List _buffer = []; + StreamSubscription? _bucketSubscription; + + Stream> Function() get watchBuckets => _bucketSource; + + Future _reloadBucket() => _mutex.run(() async { + _buffer = await _assetSource(_bufferOffset, _buffer.length); + }); + + Future> loadAssets(int index, int count) => + _mutex.run(() => _loadAssets(index, count)); + + Future> _loadAssets(int index, int count) async { + if (hasRange(index, count)) { + return getAssets(index, count); + } + + // if the requested offset is greater than the cached offset, the user scrolls forward "down" + final bool forward = _bufferOffset < index; + + // 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 [kTimelineAssetLoadBatchSize], adds some legroom into the opposite scroll direction for large requests + final len = math.max( + kTimelineAssetLoadBatchSize, + count + kTimelineAssetLoadOppositeSize, + ); + // 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 another required load from the DB + final start = math.max( + 0, + forward + ? index - kTimelineAssetLoadOppositeSize + : (len > kTimelineAssetLoadBatchSize ? index : index + count - len), + ); + + final assets = await _assetSource(start, len); + _buffer = assets; + _bufferOffset = start; + + return getAssets(index, count); + } + + bool hasRange(int index, int count) => + index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length; + + List getAssets(int index, int count) { + if (!hasRange(index, count)) { + throw RangeError('TimelineService::getAssets Index out of range'); + } + int start = index - _bufferOffset; + return _buffer.slice(start, start + count); + } + + Future dispose() async { + await _bucketSubscription?.cancel(); + _bucketSubscription = null; + _buffer.clear(); + _bufferOffset = 0; + } +} diff --git a/mobile/lib/extensions/asyncvalue_extensions.dart b/mobile/lib/extensions/asyncvalue_extensions.dart index f30b5481b..554c3a8a8 100644 --- a/mobile/lib/extensions/asyncvalue_extensions.dart +++ b/mobile/lib/extensions/asyncvalue_extensions.dart @@ -23,9 +23,7 @@ extension LogOnError on AsyncValue { if (!skip) { return onLoading?.call() ?? - const Center( - child: ImmichLoadingIndicator(), - ); + const Center(child: ImmichLoadingIndicator()); } } diff --git a/mobile/lib/infrastructure/entities/local_asset.entity.dart b/mobile/lib/infrastructure/entities/local_asset.entity.dart index ff5ee7481..39c3822b0 100644 --- a/mobile/lib/infrastructure/entities/local_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/local_asset.entity.dart @@ -1,4 +1,6 @@ import 'package:drift/drift.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/utils/asset.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; @@ -15,3 +17,16 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { @override Set get primaryKey => {id}; } + +extension LocalAssetEntityDataDomainEx on LocalAssetEntityData { + LocalAsset toDto() => LocalAsset( + id: id, + name: name, + checksum: checksum, + type: type, + createdAt: createdAt, + updatedAt: updatedAt, + durationInSeconds: durationInSeconds, + isFavorite: isFavorite, + ); +} diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift new file mode 100644 index 000000000..51f731f0f --- /dev/null +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -0,0 +1,81 @@ +import 'remote_asset.entity.dart'; +import 'local_asset.entity.dart'; + +mergedAsset: SELECT * FROM +( + SELECT + rae.id as remote_id, + lae.id as local_id, + rae.name, + rae."type", + rae.created_at, + rae.updated_at, + rae.width, + rae.height, + rae.duration_in_seconds, + rae.is_favorite, + rae.thumb_hash, + rae.checksum, + rae.owner_id + FROM + remote_asset_entity rae + LEFT JOIN + local_asset_entity lae ON rae.checksum = lae.checksum + WHERE + rae.visibility = 0 AND rae.owner_id in ? + UNION ALL + SELECT + NULL as remote_id, + lae.id as local_id, + lae.name, + lae."type", + lae.created_at, + lae.updated_at, + lae.width, + lae.height, + lae.duration_in_seconds, + lae.is_favorite, + NULL as thumb_hash, + lae.checksum, + NULL as owner_id + FROM + local_asset_entity lae + LEFT JOIN + remote_asset_entity rae ON rae.checksum = lae.checksum + WHERE + rae.id IS NULL +) +ORDER BY created_at DESC +LIMIT $limit; + +mergedBucket(:group_by AS INTEGER): +SELECT + COUNT(*) as asset_count, + CASE + WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at) -- day + WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at) -- month + END AS bucket_date +FROM +( + SELECT + rae.name, + rae.created_at + FROM + remote_asset_entity rae + LEFT JOIN + local_asset_entity lae ON rae.checksum = lae.checksum + WHERE + rae.visibility = 0 AND rae.owner_id in ? + UNION ALL + SELECT + lae.name, + lae.created_at + FROM + local_asset_entity lae + LEFT JOIN + remote_asset_entity rae ON rae.checksum = lae.checksum + WHERE + rae.id IS NULL +) +GROUP BY bucket_date +ORDER BY bucket_date DESC; diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart new file mode 100644 index 000000000..be9d8b521 Binary files /dev/null and b/mobile/lib/infrastructure/entities/merged_asset.drift.dart differ diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 96f4077a2..3c7589949 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; columns: {#checksum, #ownerId}, unique: true, ) +@TableIndex(name: 'idx_remote_asset_checksum', columns: {#checksum}) class RemoteAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin { const RemoteAssetEntity(); diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart index c244b5a09..4a13b74f5 100644 Binary files a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart and b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/asset_media.repository.dart b/mobile/lib/infrastructure/repositories/asset_media.repository.dart new file mode 100644 index 000000000..d46c34002 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/asset_media.repository.dart @@ -0,0 +1,28 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart'; +import 'package:photo_manager/photo_manager.dart'; + +class AssetMediaRepository implements IAssetMediaRepository { + const AssetMediaRepository(); + + @override + Future getThumbnail( + String id, { + int quality = 80, + Size size = const Size.square(256), + }) => + AssetEntity( + id: id, + // The below fields are not used in thumbnailDataWithSize but are required + // to create an AssetEntity instance. It is faster to create a dummy AssetEntity + // instance than to fetch the asset from the device first. + typeInt: AssetType.image.index, + width: size.width.toInt(), + height: size.height.toInt(), + ).thumbnailDataWithSize( + ThumbnailSize(size.width.toInt(), size.height.toInt()), + quality: quality, + ); +} diff --git a/mobile/lib/infrastructure/repositories/db.repository.dart b/mobile/lib/infrastructure/repositories/db.repository.dart index 4ad60276a..15b19f5c8 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.dart @@ -41,6 +41,9 @@ class IsarDatabaseRepository implements IDatabaseRepository { RemoteAssetEntity, RemoteExifEntity, ], + include: { + 'package:immich_mobile/infrastructure/entities/merged_asset.drift', + }, ) class Drift extends $Drift implements IDatabaseRepository { Drift([QueryExecutor? executor]) diff --git a/mobile/lib/infrastructure/repositories/db.repository.drift.dart b/mobile/lib/infrastructure/repositories/db.repository.drift.dart index d1bda9365..d088e5420 100644 Binary files a/mobile/lib/infrastructure/repositories/db.repository.drift.dart and b/mobile/lib/infrastructure/repositories/db.repository.drift.dart differ diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart new file mode 100644 index 000000000..909332ec6 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:drift/drift.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/domain/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:stream_transform/stream_transform.dart'; + +class DriftTimelineRepository extends DriftDatabaseRepository + implements ITimelineRepository { + final Drift _db; + + const DriftTimelineRepository(super._db) : _db = _db; + + List _generateBuckets(int count) { + final numBuckets = (count / kTimelineNoneSegmentSize).floor(); + final buckets = List.generate( + numBuckets, + (_) => const Bucket(assetCount: kTimelineNoneSegmentSize), + ); + if (count % kTimelineNoneSegmentSize != 0) { + buckets.add(Bucket(assetCount: count % kTimelineNoneSegmentSize)); + } + return buckets; + } + + @override + Stream> watchMainBucket( + List userIds, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + throw UnsupportedError( + "GroupAssetsBy.none is not supported for watchMainBucket", + ); + } + + return _db.mergedAssetDrift + .mergedBucket(userIds, groupBy: groupBy.index) + .map((row) { + final date = row.bucketDate.dateFmt(groupBy); + return TimeBucket(date: date, assetCount: row.assetCount); + }) + .watch() + .throttle(const Duration(seconds: 3), trailing: true); + } + + @override + Future> getMainBucketAssets( + List userIds, { + required int offset, + required int count, + }) { + return _db.mergedAssetDrift + .mergedAsset(userIds, limit: Limit(count, offset)) + .map( + (row) => row.remoteId != null + ? Asset( + id: row.remoteId!, + localId: row.localId, + name: row.name, + checksum: row.checksum, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + thumbHash: row.thumbHash, + width: row.width, + height: row.height, + isFavorite: row.isFavorite, + durationInSeconds: row.durationInSeconds, + ) + : LocalAsset( + id: row.localId!, + remoteId: row.remoteId, + name: row.name, + checksum: row.checksum, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + width: row.width, + height: row.height, + isFavorite: row.isFavorite, + durationInSeconds: row.durationInSeconds, + ), + ) + .get(); + } + + @override + Stream> watchLocalBucket( + String albumId, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + return _db.localAlbumAssetEntity + .count(where: (row) => row.albumId.equals(albumId)) + .map(_generateBuckets) + .watchSingle(); + } + + final assetCountExp = _db.localAssetEntity.id.count(); + final dateExp = _db.localAssetEntity.createdAt.dateFmt(groupBy); + + final query = _db.localAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ]) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + @override + Future> getLocalBucketAssets( + String albumId, { + required int offset, + required int count, + }) { + final query = _db.localAssetEntity.select().join( + [ + innerJoin( + _db.localAlbumAssetEntity, + _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id), + ), + ], + ) + ..where(_db.localAlbumAssetEntity.albumId.equals(albumId)) + ..orderBy([OrderingTerm.desc(_db.localAssetEntity.createdAt)]) + ..limit(count, offset: offset); + return query + .map((row) => row.readTable(_db.localAssetEntity).toDto()) + .get(); + } +} + +extension on Expression { + Expression dateFmt(GroupAssetsBy groupBy) { + // DateTimes are stored in UTC, so we need to convert them to local time inside the query before formatting + // to create the correct time bucket + final localTimeExp = modify(const DateTimeModifier.localTime()); + return switch (groupBy) { + GroupAssetsBy.day => localTimeExp.date, + GroupAssetsBy.month => localTimeExp.strftime("%Y-%m"), + GroupAssetsBy.none => throw ArgumentError( + "GroupAssetsBy.none is not supported for date formatting", + ), + }; + } +} + +extension on String { + DateTime dateFmt(GroupAssetsBy groupBy) { + final format = switch (groupBy) { + GroupAssetsBy.day => "y-M-d", + GroupAssetsBy.month => "y-M", + GroupAssetsBy.none => throw ArgumentError( + "GroupAssetsBy.none is not supported for date formatting", + ), + }; + try { + return DateFormat(format).parse(this); + } catch (e) { + throw FormatException("Invalid date format: $this", e); + } + } +} diff --git a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart index cc397364e..6fbb83185 100644 --- a/mobile/lib/presentation/pages/dev/feat_in_development.page.dart +++ b/mobile/lib/presentation/pages/dev/feat_in_development.page.dart @@ -88,6 +88,11 @@ final _features = [ } }, ), + _Feature( + name: 'Main Timeline', + icon: Icons.timeline_rounded, + onTap: (ctx, _) => ctx.pushRoute(const MainTimelineRoute()), + ), ]; @RoutePage() diff --git a/mobile/lib/presentation/pages/dev/local_timeline.page.dart b/mobile/lib/presentation/pages/dev/local_timeline.page.dart new file mode 100644 index 000000000..8c06a6b62 --- /dev/null +++ b/mobile/lib/presentation/pages/dev/local_timeline.page.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +@RoutePage() +class LocalTimelinePage extends StatelessWidget { + final String albumId; + + const LocalTimelinePage({super.key, required this.albumId}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = + ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId); + ref.onDispose(() => unawaited(timelineService.dispose())); + return timelineService; + }, + ), + ], + child: const Timeline(), + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/main_timeline.page.dart b/mobile/lib/presentation/pages/dev/main_timeline.page.dart new file mode 100644 index 000000000..8c04f129e --- /dev/null +++ b/mobile/lib/presentation/pages/dev/main_timeline.page.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; + +@RoutePage() +class MainTimelinePage extends StatelessWidget { + const MainTimelinePage({super.key}); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = ref + .watch(timelineFactoryProvider) + .main(ref.watch(timelineUsersIdsProvider)); + ref.onDispose(() => unawaited(timelineService.dispose())); + return timelineService; + }, + ), + ], + child: const Timeline(), + ); + } +} diff --git a/mobile/lib/presentation/pages/dev/media_stat.page.dart b/mobile/lib/presentation/pages/dev/media_stat.page.dart index 7028f8e4e..cc1fd0ae0 100644 --- a/mobile/lib/presentation/pages/dev/media_stat.page.dart +++ b/mobile/lib/presentation/pages/dev/media_stat.page.dart @@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; class _Stat { const _Stat({required this.name, required this.load}); @@ -16,9 +17,16 @@ class _Stat { class _Summary extends StatelessWidget { final String name; + final Widget? leading; final Future countFuture; + final void Function()? onTap; - const _Summary({required this.name, required this.countFuture}); + const _Summary({ + required this.name, + required this.countFuture, + this.leading, + this.onTap, + }); @override Widget build(BuildContext context) { @@ -34,7 +42,12 @@ class _Summary extends StatelessWidget { } else { subtitle = Text('${snapshot.data ?? 0}'); } - return ListTile(title: Text(name), trailing: subtitle); + return ListTile( + leading: leading, + title: Text(name), + trailing: subtitle, + onTap: onTap, + ); }, ); } @@ -105,8 +118,12 @@ class LocalMediaSummaryPage extends StatelessWidget { .filter((f) => f.albumId.id.equals(album.id)) .count(); return _Summary( + leading: const Icon(Icons.photo_album_rounded), name: album.name, countFuture: countFuture, + onTap: () => context.router.push( + LocalTimelineRoute(albumId: album.id), + ), ); }, itemCount: albums.length, diff --git a/mobile/lib/presentation/widgets/images/local_thumb_provider.dart b/mobile/lib/presentation/widgets/images/local_thumb_provider.dart new file mode 100644 index 000000000..607057cf4 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/local_thumb_provider.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/domain/interfaces/asset_media.interface.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; + +class LocalThumbProvider extends ImageProvider { + final IAssetMediaRepository _assetMediaRepository = + const AssetMediaRepository(); + final CacheManager? cacheManager; + + final LocalAsset asset; + final double height; + final double width; + + LocalThumbProvider({ + required this.asset, + this.height = kTimelineFixedTileExtent, + this.width = kTimelineFixedTileExtent, + this.cacheManager, + }); + + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + LocalThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ThumbnailImageCacheManager(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, cache, decode), + scale: 1.0, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset', key.asset), + ], + ); + } + + Future _codec( + LocalThumbProvider key, + CacheManager cache, + ImageDecoderCallback decode, + ) async { + final cacheKey = '${key.asset.id}-${key.asset.updatedAt}-${width}x$height'; + + final fileFromCache = await cache.getFileFromCache(cacheKey); + if (fileFromCache != null) { + try { + final buffer = + await ImmutableBuffer.fromFilePath(fileFromCache.file.path); + return await decode(buffer); + } catch (_) {} + } + + final thumbnailBytes = await _assetMediaRepository.getThumbnail( + key.asset.id, + size: Size(key.width, key.height), + ); + if (thumbnailBytes == null) { + PaintingBinding.instance.imageCache.evict(key); + throw StateError( + "Loading thumb for local photo ${key.asset.name} failed", + ); + } + + final buffer = await ImmutableBuffer.fromUint8List(thumbnailBytes); + unawaited(cache.putFile(cacheKey, thumbnailBytes)); + return decode(buffer); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is LocalThumbProvider) { + return asset.id == other.asset.id && + asset.updatedAt == other.asset.updatedAt; + } + return false; + } + + @override + int get hashCode => asset.id.hashCode ^ asset.updatedAt.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart b/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart new file mode 100644 index 000000000..c9561ee15 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/image/cache/image_loader.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class RemoteThumbProvider extends ImageProvider { + final String assetId; + final double height; + final double width; + final CacheManager? cacheManager; + + RemoteThumbProvider({ + required this.assetId, + this.height = kTimelineFixedTileExtent, + this.width = kTimelineFixedTileExtent, + this.cacheManager, + }); + + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + RemoteThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? ThumbnailImageCacheManager(); + final chunkController = StreamController(); + return MultiFrameImageStreamCompleter( + codec: _codec(key, cache, decode, chunkController), + scale: 1.0, + chunkEvents: chunkController.stream, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Asset Id', key.assetId), + ], + ); + } + + Future _codec( + RemoteThumbProvider key, + CacheManager cache, + ImageDecoderCallback decode, + StreamController chunkController, + ) async { + final preview = getThumbnailUrlForRemoteId( + key.assetId, + ); + + return ImageLoader.loadImageFromCache( + preview, + cache: cache, + decode: decode, + chunkEvents: chunkController, + ).whenComplete(chunkController.close); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is RemoteThumbProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart new file mode 100644 index 000000000..308c92e96 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/thumb_hash_provider.dart @@ -0,0 +1,50 @@ +import 'dart:convert' hide Codec; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/rendering.dart'; +import 'package:thumbhash/thumbhash.dart'; + +class ThumbHashProvider extends ImageProvider { + final String thumbHash; + + ThumbHashProvider({ + required this.thumbHash, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + ThumbHashProvider key, + ImageDecoderCallback decode, + ) { + return MultiFrameImageStreamCompleter( + codec: _loadCodec(key, decode), + scale: 1.0, + ); + } + + Future _loadCodec( + ThumbHashProvider key, + ImageDecoderCallback decode, + ) async { + final image = thumbHashToRGBA(base64Decode(key.thumbHash)); + return decode(await ImmutableBuffer.fromUint8List(rgbaToBmp(image))); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is ThumbHashProvider) { + return thumbHash == other.thumbHash; + } + return false; + } + + @override + int get hashCode => thumbHash.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart new file mode 100644 index 000000000..e9648ab06 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_thumb_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_thumb_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumb_hash_provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart'; +import 'package:logging/logging.dart'; +import 'package:octo_image/octo_image.dart'; + +class Thumbnail extends StatelessWidget { + const Thumbnail({ + required this.asset, + this.size = const Size.square(256), + this.fit = BoxFit.cover, + super.key, + }); + + final BaseAsset asset; + final Size size; + final BoxFit fit; + + static ImageProvider imageProvider({ + required BaseAsset asset, + Size size = const Size.square(256), + }) { + if (asset is LocalAsset) { + return LocalThumbProvider( + asset: asset, + height: size.height, + width: size.width, + ); + } + + if (asset is Asset) { + return RemoteThumbProvider( + assetId: asset.id, + height: size.height, + width: size.width, + ); + } + + throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); + } + + @override + Widget build(BuildContext context) { + final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null; + final provider = imageProvider(asset: asset, size: size); + + return OctoImage.fromSet( + image: provider, + octoSet: OctoSet( + placeholderBuilder: _blurHashPlaceholderBuilder(thumbHash, fit: fit), + errorBuilder: _blurHashErrorBuilder( + thumbHash, + provider: provider, + fit: fit, + asset: asset, + ), + ), + fadeOutDuration: const Duration(milliseconds: 100), + fadeInDuration: Duration.zero, + width: size.width, + height: size.height, + fit: fit, + placeholderFadeInDuration: Duration.zero, + ); + } +} + +OctoPlaceholderBuilder _blurHashPlaceholderBuilder( + String? thumbHash, { + BoxFit? fit, +}) { + return (context) => thumbHash == null + ? const ThumbnailPlaceholder() + : FadeInPlaceholderImage( + placeholder: const ThumbnailPlaceholder(), + image: ThumbHashProvider(thumbHash: thumbHash), + fit: fit ?? BoxFit.cover, + ); +} + +OctoErrorBuilder _blurHashErrorBuilder( + String? blurhash, { + BaseAsset? asset, + ImageProvider? provider, + BoxFit? fit, +}) => + (context, e, s) { + Logger("ImThumbnail") + .warning("Error loading thumbnail for ${asset?.name}", e, s); + provider?.evict(); + return Stack( + alignment: Alignment.center, + children: [ + _blurHashPlaceholderBuilder(blurhash, fit: fit)(context), + const Opacity( + opacity: 0.75, + child: Icon(Icons.error_outline_rounded), + ), + ], + ); + }; diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart new file mode 100644 index 000000000..eb64b2dcd --- /dev/null +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; + +class ThumbnailTile extends StatelessWidget { + const ThumbnailTile( + this.asset, { + this.size = const Size.square(256), + this.fit = BoxFit.cover, + this.showStorageIndicator = true, + super.key, + }); + + final BaseAsset asset; + final Size size; + final BoxFit fit; + final bool showStorageIndicator; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned.fill(child: Thumbnail(asset: asset, fit: fit, size: size)), + if (asset.isVideo) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, top: 6.0), + child: _VideoIndicator(asset.durationInSeconds ?? 0), + ), + ), + if (showStorageIndicator) + Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.only(right: 10.0, bottom: 6.0), + child: _TileOverlayIcon( + switch (asset.storage) { + AssetState.local => Icons.cloud_off_outlined, + AssetState.remote => Icons.cloud_outlined, + AssetState.merged => Icons.cloud_done_outlined, + }, + ), + ), + ), + if (asset.isFavorite) + const Align( + alignment: Alignment.bottomLeft, + child: Padding( + padding: EdgeInsets.only(left: 10.0, bottom: 6.0), + child: _TileOverlayIcon(Icons.favorite_rounded), + ), + ), + ], + ); + } +} + +class _VideoIndicator extends StatelessWidget { + final int durationInSeconds; + const _VideoIndicator(this.durationInSeconds); + + String _formatDuration(int durationInSec) { + final int hours = durationInSec ~/ 3600; + final int minutes = (durationInSec % 3600) ~/ 60; + final int seconds = durationInSec % 60; + + final String minutesPadded = minutes.toString().padLeft(2, '0'); + final String secondsPadded = seconds.toString().padLeft(2, '0'); + + if (hours > 0) { + return "$hours:$minutesPadded:$secondsPadded"; // H:MM:SS + } else { + return "$minutesPadded:$secondsPadded"; // MM:SS + } + } + + @override + Widget build(BuildContext context) { + return Row( + spacing: 3, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + // CrossAxisAlignment.end looks more centered vertically than CrossAxisAlignment.center + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + _formatDuration(durationInSeconds), + style: TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Colors.black.withValues(alpha: 0.6), + ), + ], + ), + ), + const _TileOverlayIcon(Icons.play_circle_outline_rounded), + ], + ); + } +} + +class _TileOverlayIcon extends StatelessWidget { + final IconData icon; + + const _TileOverlayIcon(this.icon); + + @override + Widget build(BuildContext context) { + return Icon( + icon, + color: Colors.white, + size: 16, + shadows: [ + Shadow( + blurRadius: 5.0, + color: Colors.black.withValues(alpha: 0.6), + offset: const Offset(0.0, 0.0), + ), + ], + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/constants.dart b/mobile/lib/presentation/widgets/timeline/constants.dart new file mode 100644 index 000000000..fb9034f17 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/constants.dart @@ -0,0 +1,7 @@ +const double kTimelineHeaderExtent = 80.0; +const double kTimelineFixedTileExtent = 256; +const double kTimelineSpacing = 2.0; +const int kTimelineColumnCount = 3; + +const Duration kTimelineScrubberFadeInDuration = Duration(milliseconds: 300); +const Duration kTimelineScrubberFadeOutDuration = Duration(milliseconds: 800); diff --git a/mobile/lib/presentation/widgets/timeline/fixed/row.dart b/mobile/lib/presentation/widgets/timeline/fixed/row.dart new file mode 100644 index 000000000..1062c0074 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/fixed/row.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class FixedTimelineRow extends MultiChildRenderObjectWidget { + final double dimension; + final double spacing; + final TextDirection textDirection; + + const FixedTimelineRow({ + super.key, + required this.dimension, + required this.spacing, + required this.textDirection, + required super.children, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderFixedRow( + dimension: dimension, + spacing: spacing, + textDirection: textDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderFixedRow renderObject) { + renderObject.dimension = dimension; + renderObject.spacing = spacing; + renderObject.textDirection = textDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } +} + +class _RowParentData extends ContainerBoxParentData {} + +class RenderFixedRow extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + RenderFixedRow({ + List? children, + required double dimension, + required double spacing, + required TextDirection textDirection, + }) : _dimension = dimension, + _spacing = spacing, + _textDirection = textDirection { + addAll(children); + } + + double get dimension => _dimension; + double _dimension; + + set dimension(double value) { + if (_dimension == value) return; + _dimension = value; + markNeedsLayout(); + } + + double get spacing => _spacing; + double _spacing; + + set spacing(double value) { + if (_spacing == value) return; + _spacing = value; + markNeedsLayout(); + } + + TextDirection get textDirection => _textDirection; + TextDirection _textDirection; + + set textDirection(TextDirection value) { + if (_textDirection == value) return; + _textDirection = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _RowParentData) { + child.parentData = _RowParentData(); + } + } + + double get intrinsicWidth => + dimension * childCount + spacing * (childCount - 1); + + @override + double computeMinIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMaxIntrinsicWidth(double height) => intrinsicWidth; + + @override + double computeMinIntrinsicHeight(double width) => dimension; + + @override + double computeMaxIntrinsicHeight(double width) => dimension; + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return defaultComputeDistanceToHighestActualBaseline(baseline); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('dimension', dimension)); + properties.add(DoubleProperty('spacing', spacing)); + properties.add(EnumProperty('textDirection', textDirection)); + } + + @override + void performLayout() { + RenderBox? child = firstChild; + if (child == null) { + size = constraints.smallest; + return; + } + // Use the entire width of the parent for the row. + size = Size(constraints.maxWidth, dimension); + // Each tile is forced to be dimension x dimension. + final childConstraints = BoxConstraints.tight(Size(dimension, dimension)); + final flipMainAxis = textDirection == TextDirection.rtl; + Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0); + final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing); + // Layout each child horizontally. + while (child != null) { + child.layout(childConstraints, parentUsesSize: false); + final childParentData = child.parentData! as _RowParentData; + childParentData.offset = offset; + offset += Offset(dx, 0); + child = childParentData.nextSibling; + } + } +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart new file mode 100644 index 000000000..bea754b3f --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -0,0 +1,132 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class FixedSegment extends Segment { + final double tileHeight; + final int columnCount; + final double mainAxisExtend; + + const FixedSegment({ + required super.firstIndex, + required super.lastIndex, + required super.startOffset, + required super.endOffset, + required super.firstAssetIndex, + required super.bucket, + required this.tileHeight, + required this.columnCount, + required super.headerExtent, + required super.spacing, + required super.header, + }) : assert(tileHeight != 0), + mainAxisExtend = tileHeight + spacing; + + @override + double indexToLayoutOffset(int index) { + index -= gridIndex; + if (index < 0) { + return startOffset; + } + return gridOffset + (mainAxisExtend * index); + } + + @override + int getMinChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= gridOffset; + if (!scrollOffset.isFinite || scrollOffset < 0) { + return firstIndex; + } + final rowsAbove = (scrollOffset / mainAxisExtend).floor(); + return gridIndex + rowsAbove; + } + + @override + int getMaxChildIndexForScrollOffset(double scrollOffset) { + scrollOffset -= gridOffset; + if (!scrollOffset.isFinite || scrollOffset < 0) { + return firstIndex; + } + final firstRowBelow = (scrollOffset / mainAxisExtend).ceil(); + return gridIndex + firstRowBelow - 1; + } + + @override + Widget builder(BuildContext context, int index) { + if (index == firstIndex) { + return TimelineHeader( + bucket: bucket, + header: header, + height: headerExtent, + ); + } + + final rowIndexInSegment = index - (firstIndex + 1); + final assetIndex = rowIndexInSegment * columnCount; + final assetCount = bucket.assetCount; + final numberOfAssets = math.min(columnCount, assetCount - assetIndex); + + return _buildRow(firstAssetIndex + assetIndex, numberOfAssets); + } + + Widget _buildRow(int assetIndex, int count) => Consumer( + builder: (ctx, ref, _) { + final isScrubbing = + ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); + final timelineService = ref.read(timelineServiceProvider); + + // Timeline is being scrubbed, show placeholders + if (isScrubbing) { + return SegmentBuilder.buildPlaceholder( + ctx, + count, + size: Size.square(tileHeight), + spacing: spacing, + ); + } + + // Bucket is already loaded, show the assets + if (timelineService.hasRange(assetIndex, count)) { + final assets = timelineService.getAssets(assetIndex, count); + return _buildAssetRow(ctx, assets); + } + + // Bucket is not loaded, show placeholders and load the bucket + return FutureBuilder( + future: timelineService.loadAssets(assetIndex, count), + builder: (ctxx, snap) { + if (snap.connectionState != ConnectionState.done) { + return SegmentBuilder.buildPlaceholder( + ctx, + count, + size: Size.square(tileHeight), + spacing: spacing, + ); + } + + return _buildAssetRow(ctxx, snap.requireData); + }, + ); + }, + ); + + Widget _buildAssetRow(BuildContext context, List assets) => + FixedTimelineRow( + dimension: tileHeight, + spacing: spacing, + textDirection: Directionality.of(context), + children: List.generate( + assets.length, + (i) => RepaintBoundary(child: ThumbnailTile(assets[i])), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart new file mode 100644 index 000000000..327e69026 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart @@ -0,0 +1,75 @@ +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart'; + +class FixedSegmentBuilder extends SegmentBuilder { + final double tileHeight; + final int columnCount; + + const FixedSegmentBuilder({ + required super.buckets, + required this.tileHeight, + required this.columnCount, + super.spacing, + super.groupBy, + }); + + List generate() { + final segments = []; + int firstIndex = 0; + double startOffset = 0; + int assetIndex = 0; + DateTime? previousDate; + + for (int i = 0; i < buckets.length; i++) { + final bucket = buckets[i]; + + final assetCount = bucket.assetCount; + final numberOfRows = (assetCount / columnCount).ceil(); + final segmentCount = numberOfRows + 1; + + final segmentFirstIndex = firstIndex; + firstIndex += segmentCount; + final segmentLastIndex = firstIndex - 1; + + final timelineHeader = switch (groupBy) { + GroupAssetsBy.month => HeaderType.month, + GroupAssetsBy.day => + bucket is TimeBucket && bucket.date.month != previousDate?.month + ? HeaderType.monthAndDay + : HeaderType.day, + GroupAssetsBy.none => HeaderType.none, + }; + final headerExtent = SegmentBuilder.headerExtent(timelineHeader); + + final segmentStartOffset = startOffset; + startOffset += headerExtent + + (tileHeight * numberOfRows) + + spacing * (numberOfRows - 1); + final segmentEndOffset = startOffset; + + segments.add( + FixedSegment( + firstIndex: segmentFirstIndex, + lastIndex: segmentLastIndex, + startOffset: segmentStartOffset, + endOffset: segmentEndOffset, + firstAssetIndex: assetIndex, + bucket: bucket, + tileHeight: tileHeight, + columnCount: columnCount, + headerExtent: headerExtent, + spacing: spacing, + header: timelineHeader, + ), + ); + + assetIndex += assetCount; + if (bucket is TimeBucket) { + previousDate = bucket.date; + } + } + return segments; + } +} diff --git a/mobile/lib/presentation/widgets/timeline/header.widget.dart b/mobile/lib/presentation/widgets/timeline/header.widget.dart new file mode 100644 index 000000000..f5cce1dbb --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/header.widget.dart @@ -0,0 +1,60 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; + +class TimelineHeader extends StatelessWidget { + final Bucket bucket; + final HeaderType header; + final double height; + + const TimelineHeader({ + super.key, + required this.bucket, + required this.header, + required this.height, + }); + + String _formatMonth(BuildContext context, DateTime date) { + final formatter = date.year == DateTime.now().year + ? DateFormat.MMMM(context.locale.toLanguageTag()) + : DateFormat.yMMMM(context.locale.toLanguageTag()); + return formatter.format(date); + } + + String _formatDay(BuildContext context, DateTime date) { + final formatter = DateFormat.yMMMEd(context.locale.toLanguageTag()); + return formatter.format(date); + } + + @override + Widget build(BuildContext context) { + if (bucket is! TimeBucket || header == HeaderType.none) { + return const SizedBox.shrink(); + } + + final date = (bucket as TimeBucket).date; + return Container( + padding: const EdgeInsets.only(left: 10, top: 30, bottom: 10), + height: height, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + if (header == HeaderType.month || header == HeaderType.monthAndDay) + Text( + _formatMonth(context, date), + style: context.textTheme.labelLarge + ?.copyWith(fontSize: 24, fontWeight: FontWeight.w500), + ), + if (header == HeaderType.day || header == HeaderType.monthAndDay) + Text( + _formatDay(context, date), + style: context.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart new file mode 100644 index 000000000..d68d9cfd6 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/scrubber.widget.dart @@ -0,0 +1,455 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/theme_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:intl/intl.dart' hide TextDirection; + +/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged +/// for quick navigation of the BoxScrollView. +class Scrubber extends StatefulWidget { + /// The view that will be scrolled with the scroll thumb + final CustomScrollView child; + + /// The segments of the timeline + final List layoutSegments; + + final double timelineHeight; + + final double topPadding; + + final double bottomPadding; + + Scrubber({ + super.key, + Key? scrollThumbKey, + required this.layoutSegments, + required this.timelineHeight, + this.topPadding = 0, + this.bottomPadding = 0, + required this.child, + }) : assert(child.scrollDirection == Axis.vertical); + + @override + State createState() => ScrubberState(); +} + +List<_Segment> _buildSegments({ + required List layoutSegments, + required double timelineHeight, +}) { + final segments = <_Segment>[]; + if (layoutSegments.isEmpty || layoutSegments.first.bucket is! TimeBucket) { + return []; + } + + final formatter = DateFormat.yMMM(); + for (final layoutSegment in layoutSegments) { + final scrollPercentage = + layoutSegment.startOffset / layoutSegments.last.endOffset; + final startOffset = scrollPercentage * timelineHeight; + + final date = (layoutSegment.bucket as TimeBucket).date; + final label = formatter.format(date); + + segments.add( + _Segment( + date: date, + startOffset: startOffset, + scrollLabel: label, + ), + ); + } + + return segments; +} + +class ScrubberState extends State with TickerProviderStateMixin { + double _thumbTopOffset = 0.0; + bool _isDragging = false; + List<_Segment> _segments = []; + + late AnimationController _thumbAnimationController; + Timer? _fadeOutTimer; + late Animation _thumbAnimation; + + late AnimationController _labelAnimationController; + late Animation _labelAnimation; + + double get _scrubberHeight => + widget.timelineHeight - widget.topPadding - widget.bottomPadding; + + late final ScrollController _scrollController; + + double get _currentOffset => + _scrollController.offset * + _scrubberHeight / + _scrollController.position.maxScrollExtent; + + @override + void initState() { + super.initState(); + _isDragging = false; + _segments = _buildSegments( + layoutSegments: widget.layoutSegments, + timelineHeight: _scrubberHeight, + ); + _thumbAnimationController = AnimationController( + vsync: this, + duration: kTimelineScrubberFadeInDuration, + ); + _thumbAnimation = CurvedAnimation( + parent: _thumbAnimationController, + curve: Curves.fastEaseInToSlowEaseOut, + ); + _labelAnimationController = AnimationController( + vsync: this, + duration: kTimelineScrubberFadeInDuration, + ); + + _labelAnimation = CurvedAnimation( + parent: _labelAnimationController, + curve: Curves.fastOutSlowIn, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scrollController = PrimaryScrollController.of(context); + } + + @override + void didUpdateWidget(covariant Scrubber oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.layoutSegments.lastOrNull?.endOffset != + widget.layoutSegments.lastOrNull?.endOffset) { + _segments = _buildSegments( + layoutSegments: widget.layoutSegments, + timelineHeight: _scrubberHeight, + ); + } + } + + @override + void dispose() { + _thumbAnimationController.dispose(); + _labelAnimationController.dispose(); + _fadeOutTimer?.cancel(); + super.dispose(); + } + + void _resetThumbTimer() { + _fadeOutTimer?.cancel(); + _fadeOutTimer = Timer(kTimelineScrubberFadeOutDuration, () { + _thumbAnimationController.reverse(); + _fadeOutTimer = null; + }); + } + + bool _onScrollNotification(ScrollNotification notification) { + if (_isDragging) { + // If the user is dragging the thumb, we don't want to update the position + return false; + } + + setState(() { + if (notification is ScrollUpdateNotification) { + _thumbTopOffset = _currentOffset; + if (_labelAnimation.status != AnimationStatus.reverse) { + _labelAnimationController.reverse(); + } + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } + } + _resetThumbTimer(); + }); + + return false; + } + + void _onDragStart(WidgetRef ref) { + ref.read(timelineStateProvider.notifier).setScrubbing(true); + setState(() { + _isDragging = true; + _labelAnimationController.forward(); + _fadeOutTimer?.cancel(); + }); + } + + void _onDragUpdate(DragUpdateDetails details) { + if (!_isDragging) { + return; + } + + if (_thumbAnimationController.status != AnimationStatus.forward) { + _thumbAnimationController.forward(); + } + + final newOffset = + details.globalPosition.dy - widget.topPadding - widget.bottomPadding; + + setState(() { + _thumbTopOffset = newOffset.clamp(0, _scrubberHeight); + final scrollPercentage = _thumbTopOffset / _scrubberHeight; + final maxScrollExtent = _scrollController.position.maxScrollExtent; + _scrollController.jumpTo(maxScrollExtent * scrollPercentage); + }); + } + + void _onDragEnd(WidgetRef ref) { + ref.read(timelineStateProvider.notifier).setScrubbing(false); + _labelAnimationController.reverse(); + _isDragging = false; + _resetThumbTimer(); + } + + @override + Widget build(BuildContext ctx) { + Text? label; + if (_scrollController.hasClients) { + // Cache to avoid multiple calls to [_currentOffset] + final scrollOffset = _currentOffset; + final labelText = _segments + .lastWhereOrNull( + (segment) => segment.startOffset <= scrollOffset, + ) + ?.scrollLabel ?? + _segments.firstOrNull?.scrollLabel; + label = labelText != null + ? Text( + labelText, + style: ctx.textTheme.bodyLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ) + : null; + } + + return NotificationListener( + onNotification: _onScrollNotification, + child: Stack( + children: [ + RepaintBoundary(child: widget.child), + PositionedDirectional( + top: _thumbTopOffset + widget.topPadding, + end: 0, + child: Consumer( + builder: (_, ref, child) => GestureDetector( + onVerticalDragStart: (_) => _onDragStart(ref), + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: (_) => _onDragEnd(ref), + child: child, + ), + child: _Scrubber( + thumbAnimation: _thumbAnimation, + labelAnimation: _labelAnimation, + label: label, + ), + ), + ), + ], + ), + ); + } +} + +class _ScrollLabel extends StatelessWidget { + final Text label; + final Color backgroundColor; + final Animation animation; + + const _ScrollLabel({ + required this.label, + required this.backgroundColor, + required this.animation, + }); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: FadeTransition( + opacity: animation, + child: Container( + margin: const EdgeInsets.only(right: 12.0), + child: Material( + elevation: 4.0, + color: backgroundColor, + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + child: Container( + constraints: const BoxConstraints(maxHeight: 28), + padding: const EdgeInsets.symmetric(horizontal: 10.0), + alignment: Alignment.center, + child: label, + ), + ), + ), + ), + ); + } +} + +class _Scrubber extends StatelessWidget { + final Text? label; + final Animation thumbAnimation; + final Animation labelAnimation; + + const _Scrubber({ + this.label, + required this.thumbAnimation, + required this.labelAnimation, + }); + + @override + Widget build(BuildContext context) { + final backgroundColor = context.isDarkTheme + ? context.colorScheme.primary.darken(amount: .5) + : context.colorScheme.primary; + + return _SlideFadeTransition( + animation: thumbAnimation, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (label != null) + _ScrollLabel( + label: label!, + backgroundColor: backgroundColor, + animation: labelAnimation, + ), + _CircularThumb(backgroundColor), + ], + ), + ); + } +} + +class _CircularThumb extends StatelessWidget { + final Color backgroundColor; + + const _CircularThumb(this.backgroundColor); + + @override + Widget build(BuildContext context) { + return CustomPaint( + foregroundPainter: const _ArrowPainter(Colors.white), + child: Material( + elevation: 4.0, + color: backgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(48.0), + bottomLeft: Radius.circular(48.0), + topRight: Radius.circular(4.0), + bottomRight: Radius.circular(4.0), + ), + child: Container( + constraints: BoxConstraints.tight(const Size(48.0 * 0.6, 48.0)), + ), + ), + ); + } +} + +class _ArrowPainter extends CustomPainter { + final Color color; + + const _ArrowPainter(this.color); + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color; + const width = 12.0; + const height = 8.0; + final baseX = size.width / 2; + final baseY = size.height / 2; + + canvas.drawPath( + _trianglePath(Offset(baseX, baseY - 2.0), width, height, true), + paint, + ); + canvas.drawPath( + _trianglePath(Offset(baseX, baseY + 2.0), width, height, false), + paint, + ); + } + + static Path _trianglePath(Offset o, double width, double height, bool isUp) { + return Path() + ..moveTo(o.dx, o.dy) + ..lineTo(o.dx + width, o.dy) + ..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height) + ..close(); + } +} + +class _SlideFadeTransition extends StatelessWidget { + final Animation _animation; + final Widget _child; + + const _SlideFadeTransition({ + required Animation animation, + required Widget child, + }) : _animation = animation, + _child = child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) => + _animation.value == 0.0 ? const SizedBox() : child!, + child: SlideTransition( + position: Tween( + begin: const Offset(0.3, 0.0), + end: const Offset(0.0, 0.0), + ).animate(_animation), + child: FadeTransition( + opacity: _animation, + child: _child, + ), + ), + ); + } +} + +class _Segment { + final DateTime date; + final double startOffset; + final String scrollLabel; + + const _Segment({ + required this.date, + required this.startOffset, + required this.scrollLabel, + }); + + _Segment copyWith({ + DateTime? date, + double? startOffset, + String? scrollLabel, + }) { + return _Segment( + date: date ?? this.date, + startOffset: startOffset ?? this.startOffset, + scrollLabel: scrollLabel ?? this.scrollLabel, + ); + } + + @override + String toString() { + return 'Segment(scrollLabel: $scrollLabel, date: $date)'; + } +} diff --git a/mobile/lib/presentation/widgets/timeline/segment.model.dart b/mobile/lib/presentation/widgets/timeline/segment.model.dart new file mode 100644 index 000000000..09d892f69 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/segment.model.dart @@ -0,0 +1,100 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; + +// Segments are the time groups buckets in the timeline view. +// Each segment contains a header and a list of asset rows. +abstract class Segment { + // The index of the first row of the segment, usually the header, but if not, it can be any asset. + final int firstIndex; + // The index of the last asset of the segment. + final int lastIndex; + // The offset of the first row from beginning of the timeline. + final double startOffset; + // The offset of the last row from beginning of the timeline. + final double endOffset; + // The spacing between the header and the first row of the segment. + final double spacing; + final double headerExtent; + // the start index of the asset of this segment from the beginning of the timeline. + final int firstAssetIndex; + final Bucket bucket; + + // The index of the row after the header + final int gridIndex; + // The offset of the row after the header + final double gridOffset; + // The type of the header + final HeaderType header; + + const Segment({ + required this.firstIndex, + required this.lastIndex, + required this.startOffset, + required this.endOffset, + required this.firstAssetIndex, + required this.bucket, + required this.headerExtent, + required this.spacing, + required this.header, + }) : gridIndex = firstIndex + 1, + gridOffset = startOffset + headerExtent + spacing; + + bool containsIndex(int index) => firstIndex <= index && index <= lastIndex; + + bool isWithinOffset(double offset) => + startOffset <= offset && offset <= endOffset; + + int getMinChildIndexForScrollOffset(double scrollOffset); + int getMaxChildIndexForScrollOffset(double scrollOffset); + double indexToLayoutOffset(int index); + + Widget builder(BuildContext context, int index); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Segment && + other.firstIndex == firstIndex && + other.lastIndex == lastIndex && + other.startOffset == startOffset && + other.endOffset == endOffset && + other.spacing == spacing && + other.firstAssetIndex == firstAssetIndex && + other.headerExtent == headerExtent && + other.gridIndex == gridIndex && + other.gridOffset == gridOffset && + other.header == header; + } + + @override + int get hashCode => + firstIndex.hashCode ^ + lastIndex.hashCode ^ + startOffset.hashCode ^ + endOffset.hashCode ^ + spacing.hashCode ^ + headerExtent.hashCode ^ + firstAssetIndex.hashCode ^ + gridIndex.hashCode ^ + gridOffset.hashCode ^ + header.hashCode; + + @override + String toString() { + return 'Segment(firstIndex: $firstIndex, lastIndex: $lastIndex)'; + } +} + +extension SegmentListExtension on List { + bool equals(List other) => + length == other.length && + lastOrNull?.endOffset == other.lastOrNull?.endOffset; + + Segment? findByIndex(int index) => + firstWhereOrNull((s) => s.containsIndex(index)); + + Segment? findByOffset(double offset) => + firstWhereOrNull((s) => s.isWithinOffset(offset)) ?? lastOrNull; +} diff --git a/mobile/lib/presentation/widgets/timeline/segment_builder.dart b/mobile/lib/presentation/widgets/timeline/segment_builder.dart new file mode 100644 index 000000000..97031c623 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/segment_builder.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; + +abstract class SegmentBuilder { + final List buckets; + final double spacing; + final GroupAssetsBy groupBy; + + const SegmentBuilder({ + required this.buckets, + this.spacing = kTimelineSpacing, + this.groupBy = GroupAssetsBy.day, + }); + + static double headerExtent(HeaderType header) { + switch (header) { + case HeaderType.month: + return kTimelineHeaderExtent; + case HeaderType.day: + return kTimelineHeaderExtent * 0.90; + case HeaderType.monthAndDay: + return kTimelineHeaderExtent * 1.5; + case HeaderType.none: + return 0.0; + } + } + + static Widget buildPlaceholder( + BuildContext context, + int count, { + Size size = const Size.square(kTimelineFixedTileExtent), + double spacing = kTimelineSpacing, + }) => + RepaintBoundary( + child: FixedTimelineRow( + dimension: size.height, + spacing: spacing, + textDirection: Directionality.of(context), + children: List.generate( + count, + (_) => ThumbnailPlaceholder(width: size.width, height: size.height), + ), + ), + ); +} diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart new file mode 100644 index 000000000..6e38bf2ac --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -0,0 +1,100 @@ +import 'dart:math' as math; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/fixed/segment_builder.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class TimelineArgs { + final double maxWidth; + final double maxHeight; + final double spacing; + final int columnCount; + + const TimelineArgs({ + required this.maxWidth, + required this.maxHeight, + this.spacing = kTimelineSpacing, + this.columnCount = kTimelineColumnCount, + }); + + @override + bool operator ==(covariant TimelineArgs other) { + return spacing == other.spacing && + maxWidth == other.maxWidth && + maxHeight == other.maxHeight && + columnCount == other.columnCount; + } + + @override + int get hashCode => + maxWidth.hashCode ^ + maxHeight.hashCode ^ + spacing.hashCode ^ + columnCount.hashCode; +} + +class TimelineState { + final bool isScrubbing; + + const TimelineState({this.isScrubbing = false}); + + @override + bool operator ==(covariant TimelineState other) { + return isScrubbing == other.isScrubbing; + } + + @override + int get hashCode => isScrubbing.hashCode; + + TimelineState copyWith({bool? isScrubbing}) { + return TimelineState(isScrubbing: isScrubbing ?? this.isScrubbing); + } +} + +class TimelineStateNotifier extends Notifier { + TimelineStateNotifier(); + + void setScrubbing(bool isScrubbing) { + state = state.copyWith(isScrubbing: isScrubbing); + } + + @override + TimelineState build() => const TimelineState(isScrubbing: false); +} + +// This provider watches the buckets from the timeline service & args and serves the segments. +// It should be used only after the timeline service and timeline args provider is overridden +final timelineSegmentProvider = StreamProvider.autoDispose>( + (ref) async* { + final args = ref.watch(timelineArgsProvider); + final columnCount = args.columnCount; + final spacing = args.spacing; + final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); + final tileExtent = math.max(0, availableTileWidth) / columnCount; + + final groupBy = GroupAssetsBy + .values[ref.watch(settingsProvider).get(Setting.groupAssetsBy)]; + + final timelineService = ref.watch(timelineServiceProvider); + yield* timelineService.watchBuckets().map((buckets) { + return FixedSegmentBuilder( + buckets: buckets, + tileHeight: tileExtent, + columnCount: columnCount, + spacing: spacing, + groupBy: groupBy, + ).generate(); + }); + }, + dependencies: [timelineServiceProvider, timelineArgsProvider], +); + +final timelineStateProvider = + NotifierProvider( + TimelineStateNotifier.new, +); diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart new file mode 100644 index 000000000..6ea3ddaf4 --- /dev/null +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -0,0 +1,365 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/extensions/asyncvalue_extensions.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/scrubber.widget.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +class Timeline extends StatelessWidget { + const Timeline({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: LayoutBuilder( + builder: (_, constraints) => ProviderScope( + overrides: [ + timelineArgsProvider.overrideWith( + (ref) => TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: ref.watch( + settingsProvider.select((s) => s.get(Setting.tilesPerRow)), + ), + ), + ), + ], + child: const _SliverTimeline(), + ), + ), + ); + } +} + +class _SliverTimeline extends StatefulWidget { + const _SliverTimeline(); + + @override + State createState() => _SliverTimelineState(); +} + +class _SliverTimelineState extends State<_SliverTimeline> { + final _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext _) { + return Consumer( + builder: (context, ref, child) { + final asyncSegments = ref.watch(timelineSegmentProvider); + final maxHeight = + ref.watch(timelineArgsProvider.select((args) => args.maxHeight)); + return asyncSegments.widgetWhen( + onData: (segments) { + final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; + + return PrimaryScrollController( + controller: _scrollController, + child: Scrubber( + layoutSegments: segments, + timelineHeight: maxHeight, + topPadding: context.padding.top + 10, + bottomPadding: context.padding.bottom + 10, + child: CustomScrollView( + primary: true, + cacheExtent: maxHeight * 2, + slivers: [ + _SliverSegmentedList( + segments: segments, + delegate: SliverChildBuilderDelegate( + (ctx, index) { + if (index >= childCount) return null; + final segment = segments.findByIndex(index); + return segment?.builder(ctx, index) ?? + const SizedBox.shrink(); + }, + childCount: childCount, + addAutomaticKeepAlives: false, + // We add repaint boundary around tiles, so skip the auto boundaries + addRepaintBoundaries: false, + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } +} + +class _SliverSegmentedList extends SliverMultiBoxAdaptorWidget { + final List _segments; + + const _SliverSegmentedList({ + required List segments, + required super.delegate, + }) : _segments = segments; + + @override + _RenderSliverTimelineBoxAdaptor createRenderObject(BuildContext context) => + _RenderSliverTimelineBoxAdaptor( + childManager: context as SliverMultiBoxAdaptorElement, + segments: _segments, + ); + + @override + void updateRenderObject( + BuildContext context, + _RenderSliverTimelineBoxAdaptor renderObject, + ) { + renderObject.segments = _segments; + } +} + +/// Modified version of [RenderSliverFixedExtentBoxAdaptor] to use precomputed offsets +class _RenderSliverTimelineBoxAdaptor extends RenderSliverMultiBoxAdaptor { + List _segments; + + set segments(List updatedSegments) { + if (_segments.equals(updatedSegments)) { + return; + } + _segments = updatedSegments; + markNeedsLayout(); + } + + _RenderSliverTimelineBoxAdaptor({ + required super.childManager, + required List segments, + }) : _segments = segments; + + int getMinChildIndexForScrollOffset(double offset) => + _segments.findByOffset(offset)?.getMinChildIndexForScrollOffset(offset) ?? + 0; + + int getMaxChildIndexForScrollOffset(double offset) => + _segments.findByOffset(offset)?.getMaxChildIndexForScrollOffset(offset) ?? + 0; + + double indexToLayoutOffset(int index) => + (_segments.findByIndex(index) ?? _segments.lastOrNull) + ?.indexToLayoutOffset(index) ?? + 0; + + double estimateMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0; + + double computeMaxScrollOffset() => _segments.lastOrNull?.endOffset ?? 0; + + @override + void performLayout() { + childManager.didStartLayout(); + // Assume initially that we have enough children to fill the viewport/cache area. + childManager.setDidUnderflow(false); + + final double scrollOffset = + constraints.scrollOffset + constraints.cacheOrigin; + assert(scrollOffset >= 0.0); + + final double remainingExtent = constraints.remainingCacheExtent; + assert(remainingExtent >= 0.0); + + final double targetScrollOffset = scrollOffset + remainingExtent; + + // Find the index of the first child that should be visible or in the leading cache area. + final int firstRequiredChildIndex = + getMinChildIndexForScrollOffset(scrollOffset); + + // Find the index of the last child that should be visible or in the trailing cache area. + final int? lastRequiredChildIndex = targetScrollOffset.isFinite + ? getMaxChildIndexForScrollOffset(targetScrollOffset) + : null; + + // Remove children that are no longer visible or within the cache area. + if (firstChild == null) { + collectGarbage(0, 0); + } else { + final int leadingChildrenToRemove = + calculateLeadingGarbage(firstIndex: firstRequiredChildIndex); + final int trailingChildrenToRemove = lastRequiredChildIndex == null + ? 0 + : calculateTrailingGarbage(lastIndex: lastRequiredChildIndex); + collectGarbage(leadingChildrenToRemove, trailingChildrenToRemove); + } + + // If there are currently no children laid out (e.g., initial load), + // try to add the first child needed for the current scroll offset. + if (firstChild == null) { + final double firstChildLayoutOffset = + indexToLayoutOffset(firstRequiredChildIndex); + final bool childAdded = addInitialChild( + index: firstRequiredChildIndex, + layoutOffset: firstChildLayoutOffset, + ); + + if (!childAdded) { + // There are either no children, or we are past the end of all our children. + final double max = + firstRequiredChildIndex <= 0 ? 0.0 : computeMaxScrollOffset(); + geometry = SliverGeometry(scrollExtent: max, maxPaintExtent: max); + childManager.didFinishLayout(); + return; + } + } + + // Layout children that might have scrolled into view from the top (before the current firstChild). + RenderBox? highestLaidOutChild; + final childConstraints = constraints.asBoxConstraints(); + + for (int currentIndex = indexOf(firstChild!) - 1; + currentIndex >= firstRequiredChildIndex; + --currentIndex) { + final RenderBox? newLeadingChild = + insertAndLayoutLeadingChild(childConstraints); + if (newLeadingChild == null) { + // If a child is missing where we expect one, it indicates + // an inconsistency in offset that needs correction. + final Segment? segment = + _segments.findByIndex(currentIndex) ?? _segments.firstOrNull; + geometry = SliverGeometry( + // Request a scroll correction based on where the missing child should have been. + scrollOffsetCorrection: + segment?.indexToLayoutOffset(currentIndex) ?? 0.0, + ); + // Parent will re-layout everything. + return; + } + final childParentData = + newLeadingChild.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = indexToLayoutOffset(currentIndex); + assert(childParentData.index == currentIndex); + highestLaidOutChild ??= newLeadingChild; + } + + // If the loop above didn't run (meaning the firstChild was already the correct [firstRequiredChildIndex]), + // or even if it did, we need to ensure the first visible child is correctly laid out + // and establish our starting point for laying out trailing children. + + // If [highestLaidOutChild] is still null, it means the loop above didn't add any new leading children. + // The [firstChild] that existed at the start of performLayout is still the first one we need. + if (highestLaidOutChild == null) { + firstChild!.layout(childConstraints); + final childParentData = + firstChild!.parentData! as SliverMultiBoxAdaptorParentData; + childParentData.layoutOffset = + indexToLayoutOffset(firstRequiredChildIndex); + highestLaidOutChild = firstChild; + } + + RenderBox? mostRecentlyLaidOutChild = highestLaidOutChild; + + // Starting from the child after [mostRecentlyLaidOutChild], layout subsequent children + // until we reach the [lastRequiredChildIndex] or run out of children. + double calculatedMaxScrollOffset = double.infinity; + + for (int currentIndex = indexOf(mostRecentlyLaidOutChild!) + 1; + lastRequiredChildIndex == null || + currentIndex <= lastRequiredChildIndex; + ++currentIndex) { + RenderBox? child = childAfter(mostRecentlyLaidOutChild!); + + if (child == null || indexOf(child) != currentIndex) { + child = insertAndLayoutChild( + childConstraints, + after: mostRecentlyLaidOutChild, + ); + if (child == null) { + final Segment? segment = + _segments.findByIndex(currentIndex) ?? _segments.lastOrNull; + calculatedMaxScrollOffset = + segment?.indexToLayoutOffset(currentIndex) ?? + computeMaxScrollOffset(); + break; + } + } else { + child.layout(childConstraints); + } + + mostRecentlyLaidOutChild = child; + final childParentData = mostRecentlyLaidOutChild.parentData! + as SliverMultiBoxAdaptorParentData; + assert(childParentData.index == currentIndex); + childParentData.layoutOffset = indexToLayoutOffset(currentIndex); + } + + final int lastLaidOutChildIndex = indexOf(lastChild!); + final double leadingScrollOffset = + indexToLayoutOffset(firstRequiredChildIndex); + final double trailingScrollOffset = + indexToLayoutOffset(lastLaidOutChildIndex + 1); + + assert( + firstRequiredChildIndex == 0 || + (childScrollOffset(firstChild!) ?? -1.0) - scrollOffset <= + precisionErrorTolerance, + ); + assert(debugAssertChildListIsNonEmptyAndContiguous()); + assert(indexOf(firstChild!) == firstRequiredChildIndex); + assert( + lastRequiredChildIndex == null || + lastLaidOutChildIndex <= lastRequiredChildIndex, + ); + + calculatedMaxScrollOffset = math.min( + calculatedMaxScrollOffset, + estimateMaxScrollOffset(), + ); + + final double paintExtent = calculatePaintOffset( + constraints, + from: leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double cacheExtent = calculateCacheOffset( + constraints, + from: leadingScrollOffset, + to: trailingScrollOffset, + ); + + final double targetEndScrollOffsetForPaint = + constraints.scrollOffset + constraints.remainingPaintExtent; + final int? targetLastIndexForPaint = targetEndScrollOffsetForPaint.isFinite + ? getMaxChildIndexForScrollOffset(targetEndScrollOffsetForPaint) + : null; + + final maxPaintExtent = math.max(paintExtent, calculatedMaxScrollOffset); + + geometry = SliverGeometry( + scrollExtent: calculatedMaxScrollOffset, + paintExtent: paintExtent, + maxPaintExtent: maxPaintExtent, + // Indicates if there's content scrolled off-screen. + // This is true if the last child needed for painting is actually laid out, + // or if the first child is partially visible. + hasVisualOverflow: (targetLastIndexForPaint != null && + lastLaidOutChildIndex >= targetLastIndexForPaint) || + constraints.scrollOffset > 0.0, + cacheExtent: cacheExtent, + ); + + // We may have started the layout while scrolled to the end, which would not + // expose a new child. + if (calculatedMaxScrollOffset == trailingScrollOffset) { + childManager.setDidUnderflow(true); + } + + childManager.didFinishLayout(); + } +} diff --git a/mobile/lib/providers/image/cache/image_loader.dart b/mobile/lib/providers/image/cache/image_loader.dart index 6e83e9af6..fd6e567b2 100644 --- a/mobile/lib/providers/image/cache/image_loader.dart +++ b/mobile/lib/providers/image/cache/image_loader.dart @@ -37,8 +37,7 @@ class ImageLoader { } else if (result is FileInfo) { // We have the file final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); - final decoded = await decode(buffer); - return decoded; + return decode(buffer); } } diff --git a/mobile/lib/providers/infrastructure/setting.provider.dart b/mobile/lib/providers/infrastructure/setting.provider.dart new file mode 100644 index 000000000..ad0af8282 --- /dev/null +++ b/mobile/lib/providers/infrastructure/setting.provider.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/providers/infrastructure/store.provider.dart'; + +class SettingsNotifier extends Notifier { + @override + SettingsService build() => + SettingsService(storeService: ref.read(storeServiceProvider)); + + T get(Setting setting) => state.get(setting); + + Future set(Setting setting, T value) async { + await state.set(setting, value); + ref.invalidateSelf(); + } + + Stream watch(Setting setting) => state.watch(setting); +} + +final settingsProvider = + NotifierProvider(SettingsNotifier.new); diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart new file mode 100644 index 000000000..7004dd026 --- /dev/null +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -0,0 +1,28 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/interfaces/timeline.interface.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/setting.provider.dart'; + +final timelineRepositoryProvider = Provider( + (ref) => DriftTimelineRepository(ref.watch(driftProvider)), +); + +final timelineArgsProvider = Provider.autoDispose( + (ref) => + throw UnimplementedError('Will be overridden through a ProviderScope.'), +); + +final timelineServiceProvider = Provider.autoDispose( + (ref) => + throw UnimplementedError('Will be overridden through a ProviderScope.'), +); + +final timelineFactoryProvider = Provider( + (ref) => TimelineFactory( + timelineRepository: ref.watch(timelineRepositoryProvider), + settingsService: ref.watch(settingsProvider), + ), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 1f14aaa5b..3e1563dd2 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -64,6 +64,8 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart'; import 'package:immich_mobile/pages/search/search.page.dart'; import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/dev/feat_in_development.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/local_timeline.page.dart'; +import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; @@ -330,5 +332,13 @@ class AppRouter extends RootStackRouter { page: RemoteMediaSummaryRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: LocalTimelineRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: MainTimelineRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 0c57949f0..efc9e71a2 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -871,6 +871,43 @@ class LocalMediaSummaryRoute extends PageRouteInfo { ); } +/// generated route for +/// [LocalTimelinePage] +class LocalTimelineRoute extends PageRouteInfo { + LocalTimelineRoute({ + Key? key, + required String albumId, + List? children, + }) : super( + LocalTimelineRoute.name, + args: LocalTimelineRouteArgs(key: key, albumId: albumId), + initialChildren: children, + ); + + static const String name = 'LocalTimelineRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return LocalTimelinePage(key: args.key, albumId: args.albumId); + }, + ); +} + +class LocalTimelineRouteArgs { + const LocalTimelineRouteArgs({this.key, required this.albumId}); + + final Key? key; + + final String albumId; + + @override + String toString() { + return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}'; + } +} + /// generated route for /// [LockedPage] class LockedRoute extends PageRouteInfo { @@ -903,6 +940,22 @@ class LoginRoute extends PageRouteInfo { ); } +/// generated route for +/// [MainTimelinePage] +class MainTimelineRoute extends PageRouteInfo { + const MainTimelineRoute({List? children}) + : super(MainTimelineRoute.name, initialChildren: children); + + static const String name = 'MainTimelineRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const MainTimelinePage(); + }, + ); +} + /// generated route for /// [MapLocationPickerPage] class MapLocationPickerRoute extends PageRouteInfo { diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index a02379a6b..79f2901b7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1810,7 +1810,7 @@ packages: source: hosted version: "2.1.4" stream_transform: - dependency: transitive + dependency: "direct main" description: name: stream_transform sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 52b04e0d9..998062218 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: share_handler: ^0.0.22 share_plus: ^10.1.4 socket_io_client: ^2.0.3+1 + stream_transform: ^2.1.1 thumbhash: 0.1.0+1 timezone: ^0.9.4 url_launcher: ^6.3.1