From bcda2c6e228cff77d857e7a81aedae7dd6e7a90c Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Mon, 16 Jun 2025 20:37:45 +0530 Subject: [PATCH] feat(mobile): sqlite timeline (#19197) * wip: timeline * more segment extensions * added scrubber * refactor: timeline state * more refactors * fix scrubber segments * added remote thumb & thumbhash provider * feat: merged view * scrub / merged asset fixes * rename stuff & add tile indicators * fix local album timeline query * ignore hidden assets during sync * ignore recovered assets during sync * old scrubber * add video indicator * handle groupBy * handle partner inTimeline * show duration * reduce widget nesting in thumb tile * merge main * chore: extend cacheExtent * ignore touch events on scrub label when not visible * scrub label ignore events and hide immediately * auto reload on sync * refactor image providers * throttle db updates --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran --- mobile/analysis_options.yaml | 2 +- mobile/build.yaml | 1 + .../drift_schemas/main/drift_schema_v1.json | 2 +- mobile/ios/Runner/Sync/MessagesImpl.swift | 22 +- mobile/lib/constants/constants.dart | 5 + .../interfaces/asset_media.interface.dart | 10 + .../domain/interfaces/timeline.interface.dart | 27 ++ .../lib/domain/models/asset/asset.model.dart | 14 +- .../domain/models/asset/base_asset.model.dart | 10 + .../models/asset/local_asset.model.dart | 4 + mobile/lib/domain/models/setting.model.dart | 12 + mobile/lib/domain/models/timeline.model.dart | 40 ++ .../lib/domain/services/setting.service.dart | 19 + .../lib/domain/services/timeline.service.dart | 126 +++++ .../lib/extensions/asyncvalue_extensions.dart | 4 +- .../entities/local_asset.entity.dart | 15 + .../entities/merged_asset.drift | 81 ++++ .../entities/merged_asset.drift.dart | 114 +++++ .../entities/remote_asset.entity.dart | 1 + .../entities/remote_asset.entity.drift.dart | 3 + .../repositories/asset_media.repository.dart | 28 ++ .../repositories/db.repository.dart | 3 + .../repositories/db.repository.drift.dart | 96 ++-- .../repositories/timeline.repository.dart | 180 +++++++ .../pages/dev/feat_in_development.page.dart | 5 + .../pages/dev/local_timeline.page.dart | 31 ++ .../pages/dev/main_timeline.page.dart | 31 ++ .../pages/dev/media_stat.page.dart | 21 +- .../widgets/images/local_thumb_provider.dart | 96 ++++ .../widgets/images/remote_thumb_provider.dart | 80 +++ .../widgets/images/thumb_hash_provider.dart | 50 ++ .../widgets/images/thumbnail.widget.dart | 105 ++++ .../widgets/images/thumbnail_tile.widget.dart | 127 +++++ .../widgets/timeline/constants.dart | 7 + .../widgets/timeline/fixed/row.dart | 154 ++++++ .../widgets/timeline/fixed/segment.model.dart | 132 +++++ .../timeline/fixed/segment_builder.dart | 75 +++ .../widgets/timeline/header.widget.dart | 60 +++ .../widgets/timeline/scrubber.widget.dart | 455 ++++++++++++++++++ .../widgets/timeline/segment.model.dart | 100 ++++ .../widgets/timeline/segment_builder.dart | 48 ++ .../widgets/timeline/timeline.state.dart | 100 ++++ .../widgets/timeline/timeline.widget.dart | 365 ++++++++++++++ .../providers/image/cache/image_loader.dart | 3 +- .../infrastructure/setting.provider.dart | 22 + .../infrastructure/timeline.provider.dart | 28 ++ mobile/lib/routing/router.dart | 10 + mobile/lib/routing/router.gr.dart | 53 ++ mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 50 files changed, 2921 insertions(+), 59 deletions(-) create mode 100644 mobile/lib/domain/interfaces/asset_media.interface.dart create mode 100644 mobile/lib/domain/interfaces/timeline.interface.dart create mode 100644 mobile/lib/domain/models/setting.model.dart create mode 100644 mobile/lib/domain/models/timeline.model.dart create mode 100644 mobile/lib/domain/services/setting.service.dart create mode 100644 mobile/lib/domain/services/timeline.service.dart create mode 100644 mobile/lib/infrastructure/entities/merged_asset.drift create mode 100644 mobile/lib/infrastructure/entities/merged_asset.drift.dart create mode 100644 mobile/lib/infrastructure/repositories/asset_media.repository.dart create mode 100644 mobile/lib/infrastructure/repositories/timeline.repository.dart create mode 100644 mobile/lib/presentation/pages/dev/local_timeline.page.dart create mode 100644 mobile/lib/presentation/pages/dev/main_timeline.page.dart create mode 100644 mobile/lib/presentation/widgets/images/local_thumb_provider.dart create mode 100644 mobile/lib/presentation/widgets/images/remote_thumb_provider.dart create mode 100644 mobile/lib/presentation/widgets/images/thumb_hash_provider.dart create mode 100644 mobile/lib/presentation/widgets/images/thumbnail.widget.dart create mode 100644 mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart create mode 100644 mobile/lib/presentation/widgets/timeline/constants.dart create mode 100644 mobile/lib/presentation/widgets/timeline/fixed/row.dart create mode 100644 mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart create mode 100644 mobile/lib/presentation/widgets/timeline/fixed/segment_builder.dart create mode 100644 mobile/lib/presentation/widgets/timeline/header.widget.dart create mode 100644 mobile/lib/presentation/widgets/timeline/scrubber.widget.dart create mode 100644 mobile/lib/presentation/widgets/timeline/segment.model.dart create mode 100644 mobile/lib/presentation/widgets/timeline/segment_builder.dart create mode 100644 mobile/lib/presentation/widgets/timeline/timeline.state.dart create mode 100644 mobile/lib/presentation/widgets/timeline/timeline.widget.dart create mode 100644 mobile/lib/providers/infrastructure/setting.provider.dart create mode 100644 mobile/lib/providers/infrastructure/timeline.provider.dart diff --git a/mobile/analysis_options.yaml b/mobile/analysis_options.yaml index b338b7b758..7ca6b7a2b8 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 d5de77a377..76cc0a9988 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 0d147b9b22..1b2c86026c 100644 --- a/mobile/drift_schemas/main/drift_schema_v1.json +++ b/mobile/drift_schemas/main/drift_schema_v1.json @@ -1 +1 @@ -{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":2,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":3,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":4,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":5,"references":[4,3],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":6,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":7,"references":[6],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":8,"references":[4],"type":"index","data":{"on":4,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":9,"references":[6],"type":"index","data":{"on":6,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}}]} \ No newline at end of file +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":true},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"user_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_admin","getter_name":"isAdmin","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_admin\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_admin\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"profile_image_path","getter_name":"profileImagePath","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"quota_size_in_bytes","getter_name":"quotaSizeInBytes","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"quota_usage_in_bytes","getter_name":"quotaUsageInBytes","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":1,"references":[0],"type":"table","data":{"name":"remote_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"local_date_time","getter_name":"localDateTime","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"thumb_hash","getter_name":"thumbHash","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"deleted_at","getter_name":"deletedAt","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"visibility","getter_name":"visibility","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetVisibility.values)","dart_type_name":"AssetVisibility"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":2,"references":[],"type":"table","data":{"name":"local_asset_entity","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"type","getter_name":"type","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetType.values)","dart_type_name":"AssetType"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"duration_in_seconds","getter_name":"durationInSeconds","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"checksum","getter_name":"checksum","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_favorite","getter_name":"isFavorite","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_favorite\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_favorite\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":3,"references":[2],"type":"index","data":{"on":2,"name":"idx_local_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":4,"references":[1],"type":"index","data":{"on":1,"name":"UQ_remote_asset_owner_checksum","sql":null,"unique":true,"columns":["checksum","owner_id"]}},{"id":5,"references":[1],"type":"index","data":{"on":1,"name":"idx_remote_asset_checksum","sql":null,"unique":false,"columns":["checksum"]}},{"id":6,"references":[0],"type":"table","data":{"name":"user_metadata_entity","was_declared_in_moor":false,"columns":[{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"preferences","getter_name":"preferences","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"userPreferenceConverter","dart_type_name":"UserPreferences"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["user_id"]}},{"id":7,"references":[0],"type":"table","data":{"name":"partner_entity","was_declared_in_moor":false,"columns":[{"name":"shared_by_id","getter_name":"sharedById","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"shared_with_id","getter_name":"sharedWithId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"in_timeline","getter_name":"inTimeline","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"in_timeline\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"in_timeline\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["shared_by_id","shared_with_id"]}},{"id":8,"references":[],"type":"table","data":{"name":"local_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"backup_selection","getter_name":"backupSelection","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(BackupSelection.values)","dart_type_name":"BackupSelection"}},{"name":"is_ios_shared_album","getter_name":"isIosSharedAlbum","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_ios_shared_album\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_ios_shared_album\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]},{"name":"marker","getter_name":"marker_","moor_type":"bool","nullable":true,"customConstraints":null,"defaultConstraints":"CHECK (\"marker\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"marker\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":9,"references":[2,8],"type":"table","data":{"name":"local_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES local_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES local_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":10,"references":[1],"type":"table","data":{"name":"remote_exif_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"city","getter_name":"city","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"state","getter_name":"state","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"country","getter_name":"country","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"date_time_original","getter_name":"dateTimeOriginal","moor_type":"dateTime","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"height","getter_name":"height","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"width","getter_name":"width","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"exposure_time","getter_name":"exposureTime","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"f_number","getter_name":"fNumber","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"file_size","getter_name":"fileSize","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"focal_length","getter_name":"focalLength","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"iso","getter_name":"iso","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"make","getter_name":"make","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"model","getter_name":"model","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"orientation","getter_name":"orientation","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"time_zone","getter_name":"timeZone","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"rating","getter_name":"rating","moor_type":"int","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"projection_type","getter_name":"projectionType","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id"]}},{"id":11,"references":[0,1],"type":"table","data":{"name":"remote_album_entity","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"description","getter_name":"description","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"updated_at","getter_name":"updatedAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CURRENT_TIMESTAMP')","default_client_dart":null,"dsl_features":[]},{"name":"owner_id","getter_name":"ownerId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"thumbnail_asset_id","getter_name":"thumbnailAssetId","moor_type":"string","nullable":true,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE SET NULL"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"is_activity_enabled","getter_name":"isActivityEnabled","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_activity_enabled\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_activity_enabled\" IN (0, 1))"},"default_dart":"const CustomExpression('1')","default_client_dart":null,"dsl_features":[]},{"name":"order","getter_name":"order","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AssetOrder.values)","dart_type_name":"AssetOrder"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["id"]}},{"id":12,"references":[1,11],"type":"table","data":{"name":"remote_album_asset_entity","was_declared_in_moor":false,"columns":[{"name":"asset_id","getter_name":"assetId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_asset_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["asset_id","album_id"]}},{"id":13,"references":[11,0],"type":"table","data":{"name":"album_user_entity","was_declared_in_moor":false,"columns":[{"name":"album_id","getter_name":"albumId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES remote_album_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES remote_album_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"user_id","getter_name":"userId","moor_type":"string","nullable":false,"customConstraints":null,"defaultConstraints":"REFERENCES user_entity (id) ON DELETE CASCADE","dialectAwareDefaultConstraints":{"sqlite":"REFERENCES user_entity (id) ON DELETE CASCADE"},"default_dart":null,"default_client_dart":null,"dsl_features":["unknown"]},{"name":"role","getter_name":"role","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumIndexConverter(AlbumUserRole.values)","dart_type_name":"AlbumUserRole"}}],"is_virtual":false,"without_rowid":true,"constraints":[],"strict":true,"explicit_pk":["album_id","user_id"]}}]} \ No newline at end of file diff --git a/mobile/ios/Runner/Sync/MessagesImpl.swift b/mobile/ios/Runner/Sync/MessagesImpl.swift index 861e8196c5..85f3b1fcfb 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 8c95922a3a..3d9d9a9063 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 0000000000..93f99827ee --- /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 0000000000..e60dd83b50 --- /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 c170f7f848..b0a0b0dfb9 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 fb95437659..509998a109 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 25e617d8ed..95eb1bce9f 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 0000000000..d975cbb4fe --- /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 0000000000..4a49708b74 --- /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 0000000000..2d1937be5a --- /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 0000000000..d1211f46e2 --- /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 f30b5481bd..554c3a8a8a 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 ff5ee74818..39c3822b04 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 0000000000..51f731f0ff --- /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 0000000000..be9d8b521e --- /dev/null +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -0,0 +1,114 @@ +// dart format width=80 +// ignore_for_file: type=lint +import 'package:drift/drift.dart' as i0; +import 'package:drift/internal/modular.dart' as i1; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as i2; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i3; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' + as i4; + +class MergedAssetDrift extends i1.ModularAccessor { + MergedAssetDrift(i0.GeneratedDatabase db) : super(db); + i0.Selectable mergedAsset(List var1, + {required i0.Limit limit}) { + var $arrayStartIndex = 1; + final expandedvar1 = $expandVar($arrayStartIndex, var1.length); + $arrayStartIndex += var1.length; + final generatedlimit = $write(limit, startIndex: $arrayStartIndex); + $arrayStartIndex += generatedlimit.amountOfVariables; + return customSelect( + '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 AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar1) 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 AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) ORDER BY created_at DESC ${generatedlimit.sql}', + variables: [ + for (var $ in var1) i0.Variable($), + ...generatedlimit.introducedVariables + ], + readsFrom: { + remoteAssetEntity, + localAssetEntity, + ...generatedlimit.watchedTables, + }).map((i0.QueryRow row) => MergedAssetResult( + remoteId: row.readNullable('remote_id'), + localId: row.readNullable('local_id'), + name: row.read('name'), + type: i3.$RemoteAssetEntityTable.$convertertype + .fromSql(row.read('type')), + createdAt: row.read('created_at'), + updatedAt: row.read('updated_at'), + width: row.readNullable('width'), + height: row.readNullable('height'), + durationInSeconds: row.readNullable('duration_in_seconds'), + isFavorite: row.read('is_favorite'), + thumbHash: row.readNullable('thumb_hash'), + checksum: row.readNullable('checksum'), + ownerId: row.readNullable('owner_id'), + )); + } + + i0.Selectable mergedBucket(List var2, + {required int groupBy}) { + var $arrayStartIndex = 2; + final expandedvar2 = $expandVar($arrayStartIndex, var2.length); + $arrayStartIndex += var2.length; + return customSelect( + 'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at) WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at) END AS bucket_date FROM (SELECT rae.name, rae.created_at FROM remote_asset_entity AS rae LEFT JOIN local_asset_entity AS lae ON rae.checksum = lae.checksum WHERE rae.visibility = 0 AND rae.owner_id IN ($expandedvar2) UNION ALL SELECT lae.name, lae.created_at FROM local_asset_entity AS lae LEFT JOIN remote_asset_entity AS rae ON rae.checksum = lae.checksum WHERE rae.id IS NULL) GROUP BY bucket_date ORDER BY bucket_date DESC', + variables: [ + i0.Variable(groupBy), + for (var $ in var2) i0.Variable($) + ], + readsFrom: { + remoteAssetEntity, + localAssetEntity, + }).map((i0.QueryRow row) => MergedBucketResult( + assetCount: row.read('asset_count'), + bucketDate: row.read('bucket_date'), + )); + } + + i3.$RemoteAssetEntityTable get remoteAssetEntity => + i1.ReadDatabaseContainer(attachedDatabase) + .resultSet('remote_asset_entity'); + i4.$LocalAssetEntityTable get localAssetEntity => + i1.ReadDatabaseContainer(attachedDatabase) + .resultSet('local_asset_entity'); +} + +class MergedAssetResult { + final String? remoteId; + final String? localId; + final String name; + final i2.AssetType type; + final DateTime createdAt; + final DateTime updatedAt; + final int? width; + final int? height; + final int? durationInSeconds; + final bool isFavorite; + final String? thumbHash; + final String? checksum; + final String? ownerId; + MergedAssetResult({ + this.remoteId, + this.localId, + required this.name, + required this.type, + required this.createdAt, + required this.updatedAt, + this.width, + this.height, + this.durationInSeconds, + required this.isFavorite, + this.thumbHash, + this.checksum, + this.ownerId, + }); +} + +class MergedBucketResult { + final int assetCount; + final String bucketDate; + MergedBucketResult({ + required this.assetCount, + required this.bucketDate, + }); +} diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index 96f4077a2a..3c7589949f 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 c244b5a09a..4a13b74f5d 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.drift.dart @@ -1178,3 +1178,6 @@ class RemoteAssetEntityCompanion .toString(); } } + +i0.Index get idxRemoteAssetChecksum => i0.Index('idx_remote_asset_checksum', + 'CREATE INDEX idx_remote_asset_checksum ON remote_asset_entity (checksum)'); 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 0000000000..d46c340028 --- /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 4ad60276a2..15b19f5c80 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 d1bda93653..d088e5420a 100644 --- a/mobile/lib/infrastructure/repositories/db.repository.drift.dart +++ b/mobile/lib/infrastructure/repositories/db.repository.drift.dart @@ -3,59 +3,72 @@ import 'package:drift/drift.dart' as i0; import 'package:immich_mobile/infrastructure/entities/user.entity.drift.dart' as i1; -import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' - as i2; -import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' - as i3; -import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' - as i4; -import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' - as i5; -import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' - as i6; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart' + as i2; +import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart' + as i3; +import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart' + as i4; +import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart' + as i5; +import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart' + as i6; +import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart' as i7; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart' as i8; +import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart' + as i9; +import 'package:drift/internal/modular.dart' as i10; abstract class $Drift extends i0.GeneratedDatabase { $Drift(i0.QueryExecutor e) : super(e); $DriftManager get managers => $DriftManager(this); late final i1.$UserEntityTable userEntity = i1.$UserEntityTable(this); - late final i2.$UserMetadataEntityTable userMetadataEntity = - i2.$UserMetadataEntityTable(this); - late final i3.$PartnerEntityTable partnerEntity = - i3.$PartnerEntityTable(this); - late final i4.$LocalAlbumEntityTable localAlbumEntity = - i4.$LocalAlbumEntityTable(this); - late final i5.$LocalAssetEntityTable localAssetEntity = - i5.$LocalAssetEntityTable(this); - late final i6.$LocalAlbumAssetEntityTable localAlbumAssetEntity = - i6.$LocalAlbumAssetEntityTable(this); - late final i7.$RemoteAssetEntityTable remoteAssetEntity = - i7.$RemoteAssetEntityTable(this); + late final i2.$RemoteAssetEntityTable remoteAssetEntity = + i2.$RemoteAssetEntityTable(this); + late final i3.$LocalAssetEntityTable localAssetEntity = + i3.$LocalAssetEntityTable(this); + late final i4.$UserMetadataEntityTable userMetadataEntity = + i4.$UserMetadataEntityTable(this); + late final i5.$PartnerEntityTable partnerEntity = + i5.$PartnerEntityTable(this); + late final i6.$LocalAlbumEntityTable localAlbumEntity = + i6.$LocalAlbumEntityTable(this); + late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = + i7.$LocalAlbumAssetEntityTable(this); late final i8.$RemoteExifEntityTable remoteExifEntity = i8.$RemoteExifEntityTable(this); + i9.MergedAssetDrift get mergedAssetDrift => i10.ReadDatabaseContainer(this) + .accessor(i9.MergedAssetDrift.new); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override List get allSchemaEntities => [ userEntity, + remoteAssetEntity, + localAssetEntity, + i3.idxLocalAssetChecksum, + i2.uQRemoteAssetOwnerChecksum, + i2.idxRemoteAssetChecksum, userMetadataEntity, partnerEntity, localAlbumEntity, - localAssetEntity, localAlbumAssetEntity, - remoteAssetEntity, - remoteExifEntity, - i5.idxLocalAssetChecksum, - i7.uQRemoteAssetOwnerChecksum + remoteExifEntity ]; @override i0.StreamQueryUpdateRules get streamUpdateRules => const i0.StreamQueryUpdateRules( [ + i0.WritePropagation( + on: i0.TableUpdateQuery.onTableName('user_entity', + limitUpdateKind: i0.UpdateKind.delete), + result: [ + i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete), + ], + ), i0.WritePropagation( on: i0.TableUpdateQuery.onTableName('user_entity', limitUpdateKind: i0.UpdateKind.delete), @@ -94,13 +107,6 @@ abstract class $Drift extends i0.GeneratedDatabase { kind: i0.UpdateKind.delete), ], ), - i0.WritePropagation( - on: i0.TableUpdateQuery.onTableName('user_entity', - limitUpdateKind: i0.UpdateKind.delete), - result: [ - i0.TableUpdate('remote_asset_entity', kind: i0.UpdateKind.delete), - ], - ), i0.WritePropagation( on: i0.TableUpdateQuery.onTableName('remote_asset_entity', limitUpdateKind: i0.UpdateKind.delete), @@ -120,18 +126,18 @@ class $DriftManager { $DriftManager(this._db); i1.$$UserEntityTableTableManager get userEntity => i1.$$UserEntityTableTableManager(_db, _db.userEntity); - i2.$$UserMetadataEntityTableTableManager get userMetadataEntity => - i2.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); - i3.$$PartnerEntityTableTableManager get partnerEntity => - i3.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); - i4.$$LocalAlbumEntityTableTableManager get localAlbumEntity => - i4.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); - i5.$$LocalAssetEntityTableTableManager get localAssetEntity => - i5.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); - i6.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i6 + i2.$$RemoteAssetEntityTableTableManager get remoteAssetEntity => + i2.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity); + i3.$$LocalAssetEntityTableTableManager get localAssetEntity => + i3.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity); + i4.$$UserMetadataEntityTableTableManager get userMetadataEntity => + i4.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity); + i5.$$PartnerEntityTableTableManager get partnerEntity => + i5.$$PartnerEntityTableTableManager(_db, _db.partnerEntity); + i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity => + i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity); + i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7 .$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity); - i7.$$RemoteAssetEntityTableTableManager get remoteAssetEntity => - i7.$$RemoteAssetEntityTableTableManager(_db, _db.remoteAssetEntity); i8.$$RemoteExifEntityTableTableManager get remoteExifEntity => i8.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity); } diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart new file mode 100644 index 0000000000..909332ec6e --- /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 cc397364eb..6fbb83185e 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 0000000000..8c06a6b62a --- /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 0000000000..8c04f129eb --- /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 7028f8e4e4..cc1fd0ae0c 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 0000000000..607057cf44 --- /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 0000000000..c9561ee156 --- /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 0000000000..308c92e968 --- /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 0000000000..e9648ab06e --- /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 0000000000..eb64b2dcd3 --- /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 0000000000..fb9034f179 --- /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 0000000000..1062c00740 --- /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 0000000000..bea754b3ff --- /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 0000000000..327e690267 --- /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 0000000000..f5cce1dbbb --- /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 0000000000..d68d9cfd67 --- /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 0000000000..09d892f69a --- /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 0000000000..97031c623f --- /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 0000000000..6e38bf2ac1 --- /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 0000000000..6ea3ddaf44 --- /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 6e83e9af64..fd6e567b2c 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 0000000000..ad0af8282e --- /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 0000000000..7004dd0262 --- /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 1f14aaa5bf..3e1563dd25 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 0c57949f04..efc9e71a23 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 a02379a6bd..79f2901b7d 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 52b04e0d9a..9980622185 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