From 7855974a29e28746b854fca858b69b71637e8806 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Wed, 2 Jul 2025 23:54:37 +0530 Subject: [PATCH] feat(mobile): sqlite asset viewer (#19552) * add full image provider and refactor thumb providers * photo_view updates * wip: asset-viewer * fix controller dispose on page change * wip: bottom sheet * fix interactions * more bottomsheet changes * generate schema * PR feedback * refactor asset viewer * never rotate and fix background on page change * use photoview as the loading builder * precache after delay * claude: optimizing rebuild of image provider * claude: optimizing image decoding and caching * use proper cache for new full size image providers * chore: load local HEIC fullsize for iOS * make controller callbacks nullable * remove imageprovider cache * do not handle drag gestures when zoomed * use loadOriginal setting for HEIC / larger images * preload assets outside timer * never use same controllers in photo-view gallery * fix: cannot scroll down once swipe with bottom sheet --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex --- .../drift_schemas/main/drift_schema_v1.json | 2 +- .../domain/models/asset/base_asset.model.dart | 10 + .../models/asset/local_asset.model.dart | 5 + .../models/asset/remote_asset.model.dart | 5 + mobile/lib/domain/models/exif.model.dart | 13 + mobile/lib/domain/models/setting.model.dart | 5 +- mobile/lib/domain/services/asset.service.dart | 19 + .../lib/domain/services/setting.service.dart | 5 + .../lib/domain/services/timeline.service.dart | 21 +- .../infrastructure/entities/exif.entity.dart | 27 + .../entities/exif.entity.drift.dart | 52 ++ .../entities/merged_asset.drift | 4 +- .../entities/merged_asset.drift.dart | 2 +- .../repositories/exif.repository.dart | 35 -- .../repositories/remote_asset.repository.dart | 14 +- .../repositories/storage.repository.dart | 7 +- .../repositories/sync_stream.repository.dart | 5 +- .../lib/pages/common/gallery_viewer.page.dart | 6 +- .../asset_viewer/asset_viewer.page.dart | 473 ++++++++++++++++++ .../widgets/asset_viewer/bottom_sheet.dart | 199 ++++++++ .../base_bottom_sheet.widget.dart | 31 +- .../widgets/images/full_image.widget.dart | 38 ++ .../widgets/images/image_provider.dart | 63 +++ .../widgets/images/local_image_provider.dart | 241 +++++++++ .../widgets/images/local_thumb_provider.dart | 95 ---- .../widgets/images/remote_image_provider.dart | 142 ++++++ .../widgets/images/remote_thumb_provider.dart | 80 --- .../widgets/images/thumbnail.widget.dart | 42 +- .../widgets/images/thumbnail_tile.widget.dart | 11 +- .../widgets/timeline/fixed/segment.model.dart | 270 +++++----- .../infrastructure/asset.provider.dart | 11 +- .../asset_viewer/current_asset.provider.dart | 26 + .../infrastructure/exif.provider.dart | 4 - .../infrastructure/storage.provider.dart | 2 +- mobile/lib/routing/router.dart | 14 + mobile/lib/routing/router.gr.dart | 52 ++ mobile/lib/services/action.service.dart | 11 +- .../lib/utils/cache/custom_image_cache.dart | 12 +- mobile/lib/utils/image_url_builder.dart | 3 + mobile/lib/widgets/photo_view/photo_view.dart | 40 +- .../photo_view/photo_view_gallery.dart | 56 ++- .../src/controller/photo_view_controller.dart | 41 ++ .../photo_view_controller_delegate.dart | 10 + .../photo_view_scalestate_controller.dart | 21 + .../photo_view/src/core/photo_view_core.dart | 112 ++++- .../src/core/photo_view_gesture_detector.dart | 11 +- .../photo_view/src/photo_view_wrappers.dart | 9 + 47 files changed, 1867 insertions(+), 490 deletions(-) create mode 100644 mobile/lib/domain/services/asset.service.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart create mode 100644 mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart create mode 100644 mobile/lib/presentation/widgets/images/full_image.widget.dart create mode 100644 mobile/lib/presentation/widgets/images/image_provider.dart create mode 100644 mobile/lib/presentation/widgets/images/local_image_provider.dart delete mode 100644 mobile/lib/presentation/widgets/images/local_thumb_provider.dart create mode 100644 mobile/lib/presentation/widgets/images/remote_image_provider.dart delete mode 100644 mobile/lib/presentation/widgets/images/remote_thumb_provider.dart create mode 100644 mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart diff --git a/mobile/drift_schemas/main/drift_schema_v1.json b/mobile/drift_schemas/main/drift_schema_v1.json index 096128e526..30f92cf8db 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":"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":"double","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":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","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":"const CustomExpression('\\'\\'')","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(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"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":"remote_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 +{"_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":"double","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":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"latitude","getter_name":"latitude","moor_type":"double","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"longitude","getter_name":"longitude","moor_type":"double","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":"lens","getter_name":"lens","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":"const CustomExpression('\\'\\'')","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(AlbumAssetOrder.values)","dart_type_name":"AlbumAssetOrder"}}],"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":"remote_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/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 9833f9a682..dd57456c76 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -40,7 +40,17 @@ sealed class BaseAsset { bool get isImage => type == AssetType.image; bool get isVideo => type == AssetType.video; + + double? get aspectRatio { + if (width != null && height != null && height! > 0) { + return width! / height!; + } + return null; + } + + // Overridden in subclasses AssetState get storage; + String get heroTag; @override String toString() { diff --git a/mobile/lib/domain/models/asset/local_asset.model.dart b/mobile/lib/domain/models/asset/local_asset.model.dart index 95eb1bce9f..8aab1e3431 100644 --- a/mobile/lib/domain/models/asset/local_asset.model.dart +++ b/mobile/lib/domain/models/asset/local_asset.model.dart @@ -22,6 +22,11 @@ class LocalAsset extends BaseAsset { AssetState get storage => remoteId == null ? AssetState.local : AssetState.merged; + @override + String get heroTag => '${id}_${remoteId ?? checksum}'; + + bool get hasRemote => remoteId != null; + @override String toString() { return '''LocalAsset { diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 608a03f2b2..052e1d5eff 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -36,6 +36,11 @@ class RemoteAsset extends BaseAsset { AssetState get storage => localId == null ? AssetState.remote : AssetState.merged; + @override + String get heroTag => '${localId ?? checksum}_$id'; + + bool get hasLocal => localId != null; + @override String toString() { return '''Asset { diff --git a/mobile/lib/domain/models/exif.model.dart b/mobile/lib/domain/models/exif.model.dart index e95653ca4e..b73aa4cae1 100644 --- a/mobile/lib/domain/models/exif.model.dart +++ b/mobile/lib/domain/models/exif.model.dart @@ -3,6 +3,8 @@ class ExifInfo { final int? fileSize; final String? description; final bool isFlipped; + final double? width; + final double? height; final String? orientation; final String? timeZone; final DateTime? dateTimeOriginal; @@ -45,6 +47,8 @@ class ExifInfo { this.fileSize, this.description, this.orientation, + this.width, + this.height, this.timeZone, this.dateTimeOriginal, this.isFlipped = false, @@ -68,6 +72,9 @@ class ExifInfo { return other.fileSize == fileSize && other.description == description && + other.isFlipped == isFlipped && + other.width == width && + other.height == height && other.orientation == orientation && other.timeZone == timeZone && other.dateTimeOriginal == dateTimeOriginal && @@ -91,6 +98,9 @@ class ExifInfo { return fileSize.hashCode ^ description.hashCode ^ orientation.hashCode ^ + isFlipped.hashCode ^ + width.hashCode ^ + height.hashCode ^ timeZone.hashCode ^ dateTimeOriginal.hashCode ^ latitude.hashCode ^ @@ -114,6 +124,9 @@ class ExifInfo { fileSize: ${fileSize ?? 'NA'}, description: ${description ?? 'NA'}, orientation: ${orientation ?? 'NA'}, +width: ${width ?? 'NA'}, +height: ${height ?? 'NA'}, +isFlipped: $isFlipped, timeZone: ${timeZone ?? 'NA'}, dateTimeOriginal: ${dateTimeOriginal ?? 'NA'}, latitude: ${latitude ?? 'NA'}, diff --git a/mobile/lib/domain/models/setting.model.dart b/mobile/lib/domain/models/setting.model.dart index d975cbb4fe..fe341dc028 100644 --- a/mobile/lib/domain/models/setting.model.dart +++ b/mobile/lib/domain/models/setting.model.dart @@ -3,7 +3,10 @@ import 'package:immich_mobile/domain/models/store.model.dart'; enum Setting { tilesPerRow(StoreKey.tilesPerRow, 4), groupAssetsBy(StoreKey.groupAssetsBy, 0), - showStorageIndicator(StoreKey.storageIndicator, true); + showStorageIndicator(StoreKey.storageIndicator, true), + loadOriginal(StoreKey.loadOriginal, false), + preferRemoteImage(StoreKey.preferRemoteImage, false), + ; const Setting(this.storeKey, this.defaultValue); diff --git a/mobile/lib/domain/services/asset.service.dart b/mobile/lib/domain/services/asset.service.dart new file mode 100644 index 0000000000..ee39220554 --- /dev/null +++ b/mobile/lib/domain/services/asset.service.dart @@ -0,0 +1,19 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; + +class AssetService { + final RemoteAssetRepository _remoteAssetRepository; + + const AssetService({ + required RemoteAssetRepository remoteAssetRepository, + }) : _remoteAssetRepository = remoteAssetRepository; + + Future getExif(BaseAsset asset) async { + if (asset is LocalAsset || asset is! RemoteAsset) { + return null; + } + + return _remoteAssetRepository.getExif(asset.id); + } +} diff --git a/mobile/lib/domain/services/setting.service.dart b/mobile/lib/domain/services/setting.service.dart index 2d1937be5a..8f91e9c66b 100644 --- a/mobile/lib/domain/services/setting.service.dart +++ b/mobile/lib/domain/services/setting.service.dart @@ -1,6 +1,11 @@ import 'package:immich_mobile/domain/models/setting.model.dart'; import 'package:immich_mobile/domain/services/store.service.dart'; +// Singleton instance of SettingsService, to use in places +// where reactivity is not required +// ignore: non_constant_identifier_names +final AppSetting = SettingsService(storeService: StoreService.I); + class SettingsService { final StoreService _storeService; diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 56a12cac07..54a9a3a142 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -57,14 +57,19 @@ class TimelineFactory { class TimelineService { final TimelineAssetSource _assetSource; final TimelineBucketSource _bucketSource; + int _totalAssets = 0; + int get totalAssets => _totalAssets; TimelineService({ required TimelineAssetSource assetSource, required TimelineBucketSource bucketSource, }) : _assetSource = assetSource, _bucketSource = bucketSource { - _bucketSubscription = - _bucketSource().listen((_) => unawaited(reloadBucket())); + _bucketSubscription = _bucketSource().listen((buckets) { + _totalAssets = + buckets.fold(0, (acc, bucket) => acc + bucket.assetCount); + unawaited(reloadBucket()); + }); } final AsyncMutex _mutex = AsyncMutex(); @@ -117,6 +122,7 @@ class TimelineService { index >= _bufferOffset && index + count <= _bufferOffset + _buffer.length; List getAssets(int index, int count) { + assert(index + count <= totalAssets); if (!hasRange(index, count)) { throw RangeError('TimelineService::getAssets Index out of range'); } @@ -124,6 +130,17 @@ class TimelineService { return _buffer.slice(start, start + count); } + // Pre-cache assets around the given index for asset viewer + Future preCacheAssets(int index) => + _mutex.run(() => _loadAssets(index, 5)); + + BaseAsset getAsset(int index) { + if (!hasRange(index, 1)) { + throw RangeError('TimelineService::getAsset Index out of range'); + } + return _buffer.elementAt(index - _bufferOffset); + } + Future dispose() async { await _bucketSubscription?.cancel(); _bucketSubscription = null; diff --git a/mobile/lib/infrastructure/entities/exif.entity.dart b/mobile/lib/infrastructure/entities/exif.entity.dart index c78643d89b..2ec8f0023f 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart' hide Query; import 'package:immich_mobile/domain/models/exif.model.dart' as domain; +import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart'; import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart'; import 'package:immich_mobile/infrastructure/utils/exif.converter.dart'; @@ -132,6 +133,8 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin { TextColumn get model => text().nullable()(); + TextColumn get lens => text().nullable()(); + TextColumn get orientation => text().nullable()(); TextColumn get timeZone => text().nullable()(); @@ -143,3 +146,27 @@ class RemoteExifEntity extends Table with DriftDefaultsMixin { @override Set get primaryKey => {assetId}; } + +extension RemoteExifEntityDataDomainEx on RemoteExifEntityData { + domain.ExifInfo toDto() => domain.ExifInfo( + fileSize: fileSize, + dateTimeOriginal: dateTimeOriginal, + timeZone: timeZone, + make: make, + model: model, + iso: iso, + city: city, + state: state, + country: country, + description: description, + orientation: orientation, + latitude: latitude, + longitude: longitude, + f: fNumber?.toDouble(), + mm: focalLength?.toDouble(), + lens: lens, + width: width?.toDouble(), + height: height?.toDouble(), + isFlipped: ExifDtoConverter.isOrientationFlipped(orientation), + ); +} diff --git a/mobile/lib/infrastructure/entities/exif.entity.drift.dart b/mobile/lib/infrastructure/entities/exif.entity.drift.dart index a8fd3a4477..10712948ea 100644 --- a/mobile/lib/infrastructure/entities/exif.entity.drift.dart +++ b/mobile/lib/infrastructure/entities/exif.entity.drift.dart @@ -27,6 +27,7 @@ typedef $$RemoteExifEntityTableCreateCompanionBuilder i0.Value iso, i0.Value make, i0.Value model, + i0.Value lens, i0.Value orientation, i0.Value timeZone, i0.Value rating, @@ -51,6 +52,7 @@ typedef $$RemoteExifEntityTableUpdateCompanionBuilder i0.Value iso, i0.Value make, i0.Value model, + i0.Value lens, i0.Value orientation, i0.Value timeZone, i0.Value rating, @@ -150,6 +152,9 @@ class $$RemoteExifEntityTableFilterComposer i0.ColumnFilters get model => $composableBuilder( column: $table.model, builder: (column) => i0.ColumnFilters(column)); + i0.ColumnFilters get lens => $composableBuilder( + column: $table.lens, builder: (column) => i0.ColumnFilters(column)); + i0.ColumnFilters get orientation => $composableBuilder( column: $table.orientation, builder: (column) => i0.ColumnFilters(column)); @@ -249,6 +254,9 @@ class $$RemoteExifEntityTableOrderingComposer i0.ColumnOrderings get model => $composableBuilder( column: $table.model, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get lens => $composableBuilder( + column: $table.lens, builder: (column) => i0.ColumnOrderings(column)); + i0.ColumnOrderings get orientation => $composableBuilder( column: $table.orientation, builder: (column) => i0.ColumnOrderings(column)); @@ -345,6 +353,9 @@ class $$RemoteExifEntityTableAnnotationComposer i0.GeneratedColumn get model => $composableBuilder(column: $table.model, builder: (column) => column); + i0.GeneratedColumn get lens => + $composableBuilder(column: $table.lens, builder: (column) => column); + i0.GeneratedColumn get orientation => $composableBuilder( column: $table.orientation, builder: (column) => column); @@ -424,6 +435,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< i0.Value iso = const i0.Value.absent(), i0.Value make = const i0.Value.absent(), i0.Value model = const i0.Value.absent(), + i0.Value lens = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value timeZone = const i0.Value.absent(), i0.Value rating = const i0.Value.absent(), @@ -447,6 +459,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< iso: iso, make: make, model: model, + lens: lens, orientation: orientation, timeZone: timeZone, rating: rating, @@ -470,6 +483,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< i0.Value iso = const i0.Value.absent(), i0.Value make = const i0.Value.absent(), i0.Value model = const i0.Value.absent(), + i0.Value lens = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value timeZone = const i0.Value.absent(), i0.Value rating = const i0.Value.absent(), @@ -493,6 +507,7 @@ class $$RemoteExifEntityTableTableManager extends i0.RootTableManager< iso: iso, make: make, model: model, + lens: lens, orientation: orientation, timeZone: timeZone, rating: rating, @@ -666,6 +681,12 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity late final i0.GeneratedColumn model = i0.GeneratedColumn( 'model', aliasedName, true, type: i0.DriftSqlType.string, requiredDuringInsert: false); + static const i0.VerificationMeta _lensMeta = + const i0.VerificationMeta('lens'); + @override + late final i0.GeneratedColumn lens = i0.GeneratedColumn( + 'lens', aliasedName, true, + type: i0.DriftSqlType.string, requiredDuringInsert: false); static const i0.VerificationMeta _orientationMeta = const i0.VerificationMeta('orientation'); @override @@ -709,6 +730,7 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity iso, make, model, + lens, orientation, timeZone, rating, @@ -803,6 +825,10 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity context.handle( _modelMeta, model.isAcceptableOrUnknown(data['model']!, _modelMeta)); } + if (data.containsKey('lens')) { + context.handle( + _lensMeta, lens.isAcceptableOrUnknown(data['lens']!, _lensMeta)); + } if (data.containsKey('orientation')) { context.handle( _orientationMeta, @@ -868,6 +894,8 @@ class $RemoteExifEntityTable extends i2.RemoteExifEntity .read(i0.DriftSqlType.string, data['${effectivePrefix}make']), model: attachedDatabase.typeMapping .read(i0.DriftSqlType.string, data['${effectivePrefix}model']), + lens: attachedDatabase.typeMapping + .read(i0.DriftSqlType.string, data['${effectivePrefix}lens']), orientation: attachedDatabase.typeMapping .read(i0.DriftSqlType.string, data['${effectivePrefix}orientation']), timeZone: attachedDatabase.typeMapping @@ -909,6 +937,7 @@ class RemoteExifEntityData extends i0.DataClass final int? iso; final String? make; final String? model; + final String? lens; final String? orientation; final String? timeZone; final int? rating; @@ -931,6 +960,7 @@ class RemoteExifEntityData extends i0.DataClass this.iso, this.make, this.model, + this.lens, this.orientation, this.timeZone, this.rating, @@ -987,6 +1017,9 @@ class RemoteExifEntityData extends i0.DataClass if (!nullToAbsent || model != null) { map['model'] = i0.Variable(model); } + if (!nullToAbsent || lens != null) { + map['lens'] = i0.Variable(lens); + } if (!nullToAbsent || orientation != null) { map['orientation'] = i0.Variable(orientation); } @@ -1024,6 +1057,7 @@ class RemoteExifEntityData extends i0.DataClass iso: serializer.fromJson(json['iso']), make: serializer.fromJson(json['make']), model: serializer.fromJson(json['model']), + lens: serializer.fromJson(json['lens']), orientation: serializer.fromJson(json['orientation']), timeZone: serializer.fromJson(json['timeZone']), rating: serializer.fromJson(json['rating']), @@ -1051,6 +1085,7 @@ class RemoteExifEntityData extends i0.DataClass 'iso': serializer.toJson(iso), 'make': serializer.toJson(make), 'model': serializer.toJson(model), + 'lens': serializer.toJson(lens), 'orientation': serializer.toJson(orientation), 'timeZone': serializer.toJson(timeZone), 'rating': serializer.toJson(rating), @@ -1076,6 +1111,7 @@ class RemoteExifEntityData extends i0.DataClass i0.Value iso = const i0.Value.absent(), i0.Value make = const i0.Value.absent(), i0.Value model = const i0.Value.absent(), + i0.Value lens = const i0.Value.absent(), i0.Value orientation = const i0.Value.absent(), i0.Value timeZone = const i0.Value.absent(), i0.Value rating = const i0.Value.absent(), @@ -1101,6 +1137,7 @@ class RemoteExifEntityData extends i0.DataClass iso: iso.present ? iso.value : this.iso, make: make.present ? make.value : this.make, model: model.present ? model.value : this.model, + lens: lens.present ? lens.value : this.lens, orientation: orientation.present ? orientation.value : this.orientation, timeZone: timeZone.present ? timeZone.value : this.timeZone, rating: rating.present ? rating.value : this.rating, @@ -1132,6 +1169,7 @@ class RemoteExifEntityData extends i0.DataClass iso: data.iso.present ? data.iso.value : this.iso, make: data.make.present ? data.make.value : this.make, model: data.model.present ? data.model.value : this.model, + lens: data.lens.present ? data.lens.value : this.lens, orientation: data.orientation.present ? data.orientation.value : this.orientation, timeZone: data.timeZone.present ? data.timeZone.value : this.timeZone, @@ -1162,6 +1200,7 @@ class RemoteExifEntityData extends i0.DataClass ..write('iso: $iso, ') ..write('make: $make, ') ..write('model: $model, ') + ..write('lens: $lens, ') ..write('orientation: $orientation, ') ..write('timeZone: $timeZone, ') ..write('rating: $rating, ') @@ -1189,6 +1228,7 @@ class RemoteExifEntityData extends i0.DataClass iso, make, model, + lens, orientation, timeZone, rating, @@ -1215,6 +1255,7 @@ class RemoteExifEntityData extends i0.DataClass other.iso == this.iso && other.make == this.make && other.model == this.model && + other.lens == this.lens && other.orientation == this.orientation && other.timeZone == this.timeZone && other.rating == this.rating && @@ -1240,6 +1281,7 @@ class RemoteExifEntityCompanion final i0.Value iso; final i0.Value make; final i0.Value model; + final i0.Value lens; final i0.Value orientation; final i0.Value timeZone; final i0.Value rating; @@ -1262,6 +1304,7 @@ class RemoteExifEntityCompanion this.iso = const i0.Value.absent(), this.make = const i0.Value.absent(), this.model = const i0.Value.absent(), + this.lens = const i0.Value.absent(), this.orientation = const i0.Value.absent(), this.timeZone = const i0.Value.absent(), this.rating = const i0.Value.absent(), @@ -1285,6 +1328,7 @@ class RemoteExifEntityCompanion this.iso = const i0.Value.absent(), this.make = const i0.Value.absent(), this.model = const i0.Value.absent(), + this.lens = const i0.Value.absent(), this.orientation = const i0.Value.absent(), this.timeZone = const i0.Value.absent(), this.rating = const i0.Value.absent(), @@ -1308,6 +1352,7 @@ class RemoteExifEntityCompanion i0.Expression? iso, i0.Expression? make, i0.Expression? model, + i0.Expression? lens, i0.Expression? orientation, i0.Expression? timeZone, i0.Expression? rating, @@ -1331,6 +1376,7 @@ class RemoteExifEntityCompanion if (iso != null) 'iso': iso, if (make != null) 'make': make, if (model != null) 'model': model, + if (lens != null) 'lens': lens, if (orientation != null) 'orientation': orientation, if (timeZone != null) 'time_zone': timeZone, if (rating != null) 'rating': rating, @@ -1356,6 +1402,7 @@ class RemoteExifEntityCompanion i0.Value? iso, i0.Value? make, i0.Value? model, + i0.Value? lens, i0.Value? orientation, i0.Value? timeZone, i0.Value? rating, @@ -1378,6 +1425,7 @@ class RemoteExifEntityCompanion iso: iso ?? this.iso, make: make ?? this.make, model: model ?? this.model, + lens: lens ?? this.lens, orientation: orientation ?? this.orientation, timeZone: timeZone ?? this.timeZone, rating: rating ?? this.rating, @@ -1439,6 +1487,9 @@ class RemoteExifEntityCompanion if (model.present) { map['model'] = i0.Variable(model.value); } + if (lens.present) { + map['lens'] = i0.Variable(lens.value); + } if (orientation.present) { map['orientation'] = i0.Variable(orientation.value); } @@ -1474,6 +1525,7 @@ class RemoteExifEntityCompanion ..write('iso: $iso, ') ..write('make: $make, ') ..write('model: $model, ') + ..write('lens: $lens, ') ..write('orientation: $orientation, ') ..write('timeZone: $timeZone, ') ..write('rating: $rating, ') diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift b/mobile/lib/infrastructure/entities/merged_asset.drift index 825484503b..51e5e4d73c 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift +++ b/mobile/lib/infrastructure/entities/merged_asset.drift @@ -52,8 +52,8 @@ 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 + WHEN :group_by = 0 THEN STRFTIME('%Y-%m-%d', created_at, 'localtime') -- day + WHEN :group_by = 1 THEN STRFTIME('%Y-%m', created_at, 'localtime') -- month END AS bucket_date FROM ( diff --git a/mobile/lib/infrastructure/entities/merged_asset.drift.dart b/mobile/lib/infrastructure/entities/merged_asset.drift.dart index 19fb9e3dac..f836dabe6a 100644 --- a/mobile/lib/infrastructure/entities/merged_asset.drift.dart +++ b/mobile/lib/infrastructure/entities/merged_asset.drift.dart @@ -51,7 +51,7 @@ class MergedAssetDrift extends i1.ModularAccessor { 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.deleted_at IS NULL AND 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', + 'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') 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.deleted_at IS NULL AND 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($) diff --git a/mobile/lib/infrastructure/repositories/exif.repository.dart b/mobile/lib/infrastructure/repositories/exif.repository.dart index d25572fdad..0012e329ca 100644 --- a/mobile/lib/infrastructure/repositories/exif.repository.dart +++ b/mobile/lib/infrastructure/repositories/exif.repository.dart @@ -1,8 +1,6 @@ -import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' as entity; -import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:isar/isar.dart'; @@ -43,36 +41,3 @@ class IsarExifRepository extends IsarDatabaseRepository { }); } } - -class DriftRemoteExifRepository extends DriftDatabaseRepository { - final Drift _db; - const DriftRemoteExifRepository(this._db) : super(_db); - - Future get(String assetId) { - final query = _db.remoteExifEntity.select() - ..where((exif) => exif.assetId.equals(assetId)); - - return query.map((asset) => asset.toDto()).getSingleOrNull(); - } -} - -extension on RemoteExifEntityData { - ExifInfo toDto() { - return ExifInfo( - fileSize: fileSize, - description: description, - orientation: orientation, - timeZone: timeZone, - dateTimeOriginal: dateTimeOriginal, - latitude: latitude, - longitude: longitude, - city: city, - state: state, - country: country, - make: make, - model: model, - f: fNumber, - iso: iso, - ); - } -} diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 4ac2073dda..9c036e4ea1 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -1,13 +1,23 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' + hide ExifInfo; import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'; import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; -class DriftRemoteAssetRepository extends DriftDatabaseRepository { +class RemoteAssetRepository extends DriftDatabaseRepository { final Drift _db; - const DriftRemoteAssetRepository(this._db) : super(_db); + const RemoteAssetRepository(this._db) : super(_db); + + Future getExif(String id) { + return _db.managers.remoteExifEntity + .filter((row) => row.assetId.id.equals(id)) + .map((row) => row.toDto()) + .getSingleOrNull(); + } Future updateFavorite(List ids, bool isFavorite) { return _db.batch((batch) async { diff --git a/mobile/lib/infrastructure/repositories/storage.repository.dart b/mobile/lib/infrastructure/repositories/storage.repository.dart index 2b6b616d87..1f41e48522 100644 --- a/mobile/lib/infrastructure/repositories/storage.repository.dart +++ b/mobile/lib/infrastructure/repositories/storage.repository.dart @@ -5,20 +5,21 @@ import 'package:logging/logging.dart'; import 'package:photo_manager/photo_manager.dart'; class StorageRepository { - final _log = Logger('StorageRepository'); + const StorageRepository(); Future getFileForAsset(LocalAsset asset) async { + final log = Logger('StorageRepository'); File? file; try { final entity = await AssetEntity.fromId(asset.id); file = await entity?.originFile; if (file == null) { - _log.warning( + log.warning( "Cannot get file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", ); } } catch (error, stackTrace) { - _log.warning( + log.warning( "Error getting file for asset ${asset.id}, name: ${asset.name}, created on: ${asset.createdAt}", error, stackTrace, diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index dfe65b698e..015e09d663 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -161,8 +161,8 @@ class SyncStreamRepository extends DriftDatabaseRepository { fNumber: Value(exif.fNumber), fileSize: Value(exif.fileSizeInByte), focalLength: Value(exif.focalLength), - latitude: Value(exif.latitude), - longitude: Value(exif.longitude), + latitude: Value(exif.latitude?.toDouble()), + longitude: Value(exif.longitude?.toDouble()), iso: Value(exif.iso), make: Value(exif.make), model: Value(exif.model), @@ -170,6 +170,7 @@ class SyncStreamRepository extends DriftDatabaseRepository { timeZone: Value(exif.timeZone), rating: Value(exif.rating), projectionType: Value(exif.projectionType), + lens: Value(exif.lensModel), ); batch.insert( diff --git a/mobile/lib/pages/common/gallery_viewer.page.dart b/mobile/lib/pages/common/gallery_viewer.page.dart index 77b734ce0b..6fdbecced1 100644 --- a/mobile/lib/pages/common/gallery_viewer.page.dart +++ b/mobile/lib/pages/common/gallery_viewer.page.dart @@ -238,7 +238,7 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildImage(Asset asset) { return PhotoViewGalleryPageOptions( - onDragStart: (_, details, __) { + onDragStart: (_, details, __, ___) { localPosition.value = details.localPosition; }, onDragUpdate: (_, details, __) { @@ -267,7 +267,7 @@ class GalleryViewerPage extends HookConsumerWidget { PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) { return PhotoViewGalleryPageOptions.customChild( - onDragStart: (_, details, __) => + onDragStart: (_, details, __, ___) => localPosition.value = details.localPosition, onDragUpdate: (_, details, __) => handleSwipeUpDown(details), heroAttributes: _getHeroAttributes(asset), @@ -370,7 +370,7 @@ class GalleryViewerPage extends HookConsumerWidget { ), itemCount: totalAssets.value, scrollDirection: Axis.horizontal, - onPageChanged: (value) { + onPageChanged: (value, _) { final next = currentIndex.value < value ? value + 1 : value - 1; ref.read(hapticFeedbackProvider.notifier).selectionClick(); diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart new file mode 100644 index 0000000000..6f5ceb6da1 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_viewer.page.dart @@ -0,0 +1,473 @@ +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/domain/services/timeline.service.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/scroll_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view.dart'; +import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart'; +import 'package:platform/platform.dart'; + +@RoutePage() +class AssetViewerPage extends StatelessWidget { + final int initialIndex; + final TimelineService timelineService; + + const AssetViewerPage({ + super.key, + required this.initialIndex, + required this.timelineService, + }); + + @override + Widget build(BuildContext context) { + // This is necessary to ensure that the timeline service is available + // since the Timeline and AssetViewer are on different routes / Widget subtrees. + return ProviderScope( + overrides: [timelineServiceProvider.overrideWithValue(timelineService)], + child: AssetViewer(initialIndex: initialIndex), + ); + } +} + +class AssetViewer extends ConsumerStatefulWidget { + final int initialIndex; + final Platform? platform; + + const AssetViewer({ + super.key, + required this.initialIndex, + this.platform, + }); + + @override + ConsumerState createState() => _AssetViewerState(); +} + +const double _kBottomSheetMinimumExtent = 0.4; +const double _kBottomSheetSnapExtent = 0.7; + +class _AssetViewerState extends ConsumerState { + late PageController pageController; + late DraggableScrollableController bottomSheetController; + PersistentBottomSheetController? sheetCloseNotifier; + // PhotoViewGallery takes care of disposing it's controllers + PhotoViewControllerBase? viewController; + + late Platform platform; + late PhotoViewControllerValue initialPhotoViewState; + bool? hasDraggedDown; + bool isSnapping = false; + bool blockGestures = false; + bool dragInProgress = false; + bool shouldPopOnDrag = false; + bool showingBottomSheet = false; + double? initialScale; + double previousExtent = _kBottomSheetMinimumExtent; + Offset dragDownPosition = Offset.zero; + int totalAssets = 0; + int backgroundOpacity = 255; + + // Delayed operations that should be cancelled on disposal + final List _delayedOperations = []; + + @override + void initState() { + super.initState(); + pageController = PageController(initialPage: widget.initialIndex); + platform = widget.platform ?? const LocalPlatform(); + totalAssets = ref.read(timelineServiceProvider).totalAssets; + bottomSheetController = DraggableScrollableController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _onAssetChanged(widget.initialIndex); + }); + } + + @override + void dispose() { + pageController.dispose(); + bottomSheetController.dispose(); + _cancelTimers(); + super.dispose(); + } + + Color get backgroundColor { + if (showingBottomSheet && !context.isDarkTheme) { + return Colors.white; + } + return Colors.black.withAlpha(backgroundOpacity); + } + + void _cancelTimers() { + for (final timer in _delayedOperations) { + timer.cancel(); + } + _delayedOperations.clear(); + } + + // This is used to calculate the scale of the asset when the bottom sheet is showing. + // It is a small increment to ensure that the asset is slightly zoomed in when the + // bottom sheet is showing, which emulates the zoom effect. + double get _getScaleForBottomSheet => + (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + + 0.01; + + Future _precacheImage(int index) async { + if (!mounted || index < 0 || index >= totalAssets) { + return; + } + + final asset = ref.read(timelineServiceProvider).getAsset(index); + final screenSize = Size(context.width, context.height); + + // Precache both thumbnail and full image for smooth transitions + unawaited( + Future.wait([ + precacheImage( + getThumbnailImageProvider(asset: asset, size: screenSize), + context, + onError: (_, __) {}, + ), + precacheImage( + getFullImageProvider(asset, size: screenSize), + context, + onError: (_, __) {}, + ), + ]), + ); + } + + void _onAssetChanged(int index) { + final asset = ref.read(timelineServiceProvider).getAsset(index); + ref.read(currentAssetNotifier.notifier).setAsset(asset); + unawaited(ref.read(timelineServiceProvider).preCacheAssets(index)); + _cancelTimers(); + // This will trigger the pre-caching of adjacent assets ensuring + // that they are ready when the user navigates to them. + final timer = Timer(Durations.medium4, () { + // Check if widget is still mounted before proceeding + if (!mounted) return; + + for (final offset in [-1, 1]) { + unawaited(_precacheImage(index + offset)); + } + }); + _delayedOperations.add(timer); + } + + void _onPageBuild(PhotoViewControllerBase controller) { + viewController ??= controller; + if (showingBottomSheet) { + final verticalOffset = (context.height * bottomSheetController.size) - + (context.height * _kBottomSheetMinimumExtent); + controller.position = Offset(0, -verticalOffset); + } + } + + void _onPageChanged(int index, PhotoViewControllerBase? controller) { + _onAssetChanged(index); + viewController = controller; + + // If the bottom sheet is showing, we need to adjust scale the asset to + // emulate the zoom effect + if (showingBottomSheet) { + initialScale = controller?.scale; + controller?.scale = _getScaleForBottomSheet; + } + } + + void _onDragStart( + _, + DragStartDetails details, + PhotoViewControllerValue value, + PhotoViewScaleStateController scaleStateController, + ) { + dragDownPosition = details.localPosition; + initialPhotoViewState = value; + final isZoomed = + scaleStateController.scaleState == PhotoViewScaleState.zoomedIn || + scaleStateController.scaleState == PhotoViewScaleState.covering; + if (!showingBottomSheet && isZoomed) { + blockGestures = true; + } + } + + void _onDragEnd(BuildContext ctx, _, __) { + dragInProgress = false; + + if (shouldPopOnDrag) { + // Dismiss immediately without state updates to avoid rebuilds + ctx.maybePop(); + return; + } + + // Do not reset the state if the bottom sheet is showing + if (showingBottomSheet) { + _snapBottomSheet(); + return; + } + + // If the gestures are blocked, do not reset the state + if (blockGestures) { + blockGestures = false; + return; + } + + setState(() { + shouldPopOnDrag = false; + hasDraggedDown = null; + backgroundOpacity = 255; + viewController?.animateMultiple( + position: initialPhotoViewState.position, + scale: initialPhotoViewState.scale, + rotation: initialPhotoViewState.rotation, + ); + }); + } + + void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) { + if (blockGestures) { + return; + } + + dragInProgress = true; + final delta = details.localPosition - dragDownPosition; + hasDraggedDown ??= delta.dy > 0; + if (!hasDraggedDown! || showingBottomSheet) { + _handleDragUp(ctx, delta); + return; + } + + _handleDragDown(ctx, delta); + } + + void _handleDragUp(BuildContext ctx, Offset delta) { + const double openThreshold = 50; + const double closeThreshold = 25; + + final position = initialPhotoViewState.position + Offset(0, delta.dy); + final distanceToOrigin = position.distance; + + if (showingBottomSheet && distanceToOrigin < closeThreshold) { + // Prevents the user from dragging the bottom sheet further down + blockGestures = true; + sheetCloseNotifier?.close(); + return; + } + + viewController?.updateMultiple(position: position); + // Moves the bottom sheet when the asset is being dragged up + if (showingBottomSheet && bottomSheetController.isAttached) { + final centre = (ctx.height * _kBottomSheetMinimumExtent); + bottomSheetController.jumpTo((centre + distanceToOrigin) / ctx.height); + } + + if (distanceToOrigin > openThreshold && !showingBottomSheet) { + _openBottomSheet(ctx); + } + } + + void _openBottomSheet(BuildContext ctx) { + setState(() { + initialScale = viewController?.scale; + viewController?.animateMultiple(scale: _getScaleForBottomSheet); + showingBottomSheet = true; + previousExtent = _kBottomSheetMinimumExtent; + sheetCloseNotifier = showBottomSheet( + context: ctx, + sheetAnimationStyle: AnimationStyle( + duration: Duration.zero, + reverseDuration: Duration.zero, + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)), + ), + backgroundColor: ctx.colorScheme.surfaceContainerLowest, + builder: (_) { + return NotificationListener( + onNotification: _onNotification, + child: AssetDetailBottomSheet( + controller: bottomSheetController, + initialChildSize: _kBottomSheetMinimumExtent, + ), + ); + }, + ); + sheetCloseNotifier?.closed.then((_) => _handleSheetClose()); + }); + } + + void _handleSheetClose() { + setState(() { + showingBottomSheet = false; + sheetCloseNotifier = null; + viewController?.animateMultiple( + position: Offset.zero, + scale: initialScale, + ); + shouldPopOnDrag = false; + hasDraggedDown = null; + }); + } + + void _snapBottomSheet() { + if (bottomSheetController.size > _kBottomSheetSnapExtent || + bottomSheetController.size < 0.4) { + return; + } + isSnapping = true; + bottomSheetController.animateTo( + _kBottomSheetSnapExtent, + duration: Durations.short3, + curve: Curves.easeOut, + ); + } + + bool _onNotification(Notification delta) { + // Ignore notifications when user dragging the asset + if (dragInProgress) { + return false; + } + + if (delta is DraggableScrollableNotification) { + _handleDraggableNotification(delta); + } + + // Handle sheet snap manually so that the it snaps only at _kBottomSheetSnapExtent but not after + // the isSnapping guard is to prevent the notification from recursively handling the + // notification, eventually resulting in a heap overflow + if (!isSnapping && delta is ScrollEndNotification) { + _snapBottomSheet(); + } + return false; + } + + void _handleDraggableNotification(DraggableScrollableNotification delta) { + final verticalOffset = (context.height * delta.extent) - + (context.height * _kBottomSheetMinimumExtent); + // Moves the asset when the bottom sheet is being dragged + if (verticalOffset > 0) { + viewController?.position = Offset(0, -verticalOffset); + } + + final currentExtent = delta.extent; + final isDraggingDown = currentExtent < previousExtent; + previousExtent = currentExtent; + // Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent + if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) { + sheetCloseNotifier?.close(); + } + } + + void _handleDragDown(BuildContext ctx, Offset delta) { + const double dragRatio = 0.2; + const double popThreshold = 75; + + final distance = delta.distance; + final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold; + + final maxScaleDistance = ctx.height * 0.5; + final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio); + double? updatedScale; + if (initialPhotoViewState.scale != null) { + updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction); + } + + final newBackgroundOpacity = + (255 * (1.0 - (scaleReduction / dragRatio))).round(); + + viewController?.updateMultiple( + position: initialPhotoViewState.position + delta, + scale: updatedScale, + ); + if (shouldPopOnDrag != newShouldPopOnDrag || + backgroundOpacity != newBackgroundOpacity) { + setState(() { + shouldPopOnDrag = newShouldPopOnDrag; + backgroundOpacity = newBackgroundOpacity; + }); + } + } + + Widget _placeholderBuilder( + BuildContext ctx, + ImageChunkEvent? progress, + int index, + ) { + final asset = ref.read(timelineServiceProvider).getAsset(index); + return Container( + width: double.infinity, + height: double.infinity, + color: backgroundColor, + child: Thumbnail( + asset: asset, + fit: BoxFit.contain, + size: Size( + ctx.width, + ctx.height, + ), + ), + ); + } + + PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) { + final asset = ref.read(timelineServiceProvider).getAsset(index); + final size = Size(ctx.width, ctx.height); + final imageProvider = getFullImageProvider(asset, size: size); + + return PhotoViewGalleryPageOptions( + imageProvider: imageProvider, + heroAttributes: PhotoViewHeroAttributes(tag: asset.heroTag), + filterQuality: FilterQuality.high, + tightMode: true, + initialScale: PhotoViewComputedScale.contained * 0.999, + minScale: PhotoViewComputedScale.contained * 0.999, + disableScaleGestures: showingBottomSheet, + onDragStart: _onDragStart, + onDragUpdate: _onDragUpdate, + onDragEnd: _onDragEnd, + errorBuilder: (_, __, ___) => Container( + width: ctx.width, + height: ctx.height, + color: backgroundColor, + child: Thumbnail( + asset: asset, + fit: BoxFit.contain, + size: size, + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // Currently it is not possible to scroll the asset when the bottom sheet is open all the way. + // Issue: https://github.com/flutter/flutter/issues/109037 + // TODO: Add a custom scrum builder once the fix lands on stable + return Scaffold( + backgroundColor: Colors.black.withAlpha(backgroundOpacity), + body: PhotoViewGallery.builder( + gaplessPlayback: true, + loadingBuilder: _placeholderBuilder, + pageController: pageController, + scrollPhysics: platform.isIOS + ? const FastScrollPhysics() // Use bouncing physics for iOS + : const FastClampingScrollPhysics() // Use heavy physics for Android + , + itemCount: totalAssets, + onPageChanged: _onPageChanged, + onPageBuild: _onPageBuild, + builder: _assetBuilder, + backgroundDecoration: BoxDecoration(color: backgroundColor), + enablePanAlways: true, + ), + ); + } +} diff --git a/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart new file mode 100644 index 0000000000..39f28d2f60 --- /dev/null +++ b/mobile/lib/presentation/widgets/asset_viewer/bottom_sheet.dart @@ -0,0 +1,199 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/domain/models/exif.model.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart'; +import 'package:immich_mobile/utils/bytes_units.dart'; + +const _kSeparator = ' • '; + +class AssetDetailBottomSheet extends BaseBottomSheet { + const AssetDetailBottomSheet({ + super.controller, + super.initialChildSize, + super.key, + }) : super( + actions: const [], + slivers: const [_AssetDetailBottomSheet()], + minChildSize: 0.1, + maxChildSize: 1.0, + expand: false, + shouldCloseOnMinExtent: false, + resizeOnScroll: false, + ); +} + +class _AssetDetailBottomSheet extends ConsumerWidget { + const _AssetDetailBottomSheet(); + + String _getDateTime(BuildContext ctx, BaseAsset asset) { + final dateTime = asset.createdAt.toLocal(); + final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime); + final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime); + return '$date$_kSeparator$time'; + } + + String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) { + final height = asset.height ?? exifInfo?.height; + final width = asset.width ?? exifInfo?.width; + final resolution = + (width != null && height != null) ? "$width x $height" : null; + final fileSize = + exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null; + + return switch ((fileSize, resolution)) { + (null, null) => '', + (String fileSize, null) => fileSize, + (null, String resolution) => resolution, + (String fileSize, String resolution) => + '$fileSize$_kSeparator$resolution', + }; + } + + String? _getCameraInfoTitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + return switch ((exifInfo.make, exifInfo.model)) { + (null, null) => null, + (String make, null) => make, + (null, String model) => model, + (String make, String model) => '$make $model', + }; + } + + String? _getCameraInfoSubtitle(ExifInfo? exifInfo) { + if (exifInfo == null) { + return null; + } + + final fNumber = + exifInfo.fNumber.isNotEmpty ? 'ƒ/${exifInfo.fNumber}' : null; + final exposureTime = + exifInfo.exposureTime.isNotEmpty ? exifInfo.exposureTime : null; + final focalLength = + exifInfo.focalLength.isNotEmpty ? '${exifInfo.focalLength} mm' : null; + final iso = exifInfo.iso != null ? 'ISO ${exifInfo.iso}' : null; + + return [fNumber, exposureTime, focalLength, iso] + .where((spec) => spec != null && spec.isNotEmpty) + .join(_kSeparator); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asset = ref.watch(currentAssetNotifier); + final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull; + final cameraTitle = _getCameraInfoTitle(exifInfo); + + return SliverList.list( + children: [ + // Asset Date and Time + _SheetTile( + title: _getDateTime(context, asset), + titleStyle: context.textTheme.bodyLarge + ?.copyWith(fontWeight: FontWeight.w600), + ), + // Details header + _SheetTile( + title: 'exif_bottom_sheet_details'.t(context: context), + titleStyle: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color, + fontWeight: FontWeight.w600, + ), + ), + // File info + _SheetTile( + title: asset.name, + titleStyle: context.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.w600), + leading: Icon( + asset.isImage ? Icons.image_outlined : Icons.videocam_outlined, + size: 30, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getFileInfo(asset, exifInfo), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color?.withAlpha(200), + ), + ), + // Camera info + if (cameraTitle != null) + _SheetTile( + title: cameraTitle, + titleStyle: context.textTheme.labelLarge + ?.copyWith(fontWeight: FontWeight.w600), + leading: Icon( + Icons.camera_outlined, + size: 30, + color: context.textTheme.labelLarge?.color, + ), + subtitle: _getCameraInfoSubtitle(exifInfo), + subtitleStyle: context.textTheme.labelLarge?.copyWith( + color: context.textTheme.labelLarge?.color?.withAlpha(200), + ), + ), + ], + ); + } +} + +class _SheetTile extends StatelessWidget { + final String title; + final Widget? leading; + final String? subtitle; + final TextStyle? titleStyle; + final TextStyle? subtitleStyle; + + const _SheetTile({ + required this.title, + this.titleStyle, + this.leading, + this.subtitle, + this.subtitleStyle, + }); + + @override + Widget build(BuildContext context) { + final Widget titleWidget; + if (leading == null) { + titleWidget = LimitedBox( + maxWidth: double.infinity, + child: Text(title, style: titleStyle), + ); + } else { + titleWidget = Container( + width: double.infinity, + padding: const EdgeInsets.only(left: 15), + child: Text(title, style: titleStyle), + ); + } + + final Widget? subtitleWidget; + if (leading == null && subtitle != null) { + subtitleWidget = Text(subtitle!, style: subtitleStyle); + } else if (leading != null && subtitle != null) { + subtitleWidget = Padding( + padding: const EdgeInsets.only(left: 15), + child: Text(subtitle!, style: subtitleStyle), + ); + } else { + subtitleWidget = null; + } + + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + title: titleWidget, + titleAlignment: ListTileTitleAlignment.center, + leading: leading, + contentPadding: leading == null ? null : const EdgeInsets.only(left: 25), + subtitle: subtitleWidget, + ); + } +} diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart index d041bca514..1fb98a0032 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/base_bottom_sheet.widget.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/timeline.state.dart'; class BaseBottomSheet extends ConsumerStatefulWidget { @@ -74,10 +75,7 @@ class _BaseDraggableScrollableSheetState clipBehavior: Clip.antiAlias, elevation: 6.0, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(6), - topRight: Radius.circular(6), - ), + borderRadius: BorderRadius.vertical(top: Radius.circular(18)), ), margin: const EdgeInsets.symmetric(horizontal: 0), child: CustomScrollView( @@ -86,17 +84,22 @@ class _BaseDraggableScrollableSheetState SliverToBoxAdapter( child: Column( children: [ - const SizedBox(height: 16), + const SizedBox(height: 10), const _DragHandle(), - const SizedBox(height: 16), - SizedBox( - height: 120, - child: ListView( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - children: widget.actions, + const SizedBox(height: 14), + if (widget.actions.isNotEmpty) + SizedBox( + height: 80, + child: ListView( + shrinkWrap: true, + scrollDirection: Axis.horizontal, + children: widget.actions, + ), ), - ), + if (widget.actions.isNotEmpty) const SizedBox(height: 14), + if (widget.actions.isNotEmpty) + const Divider(indent: 20, endIndent: 20), + if (widget.actions.isNotEmpty) const SizedBox(height: 14), ], ), ), @@ -118,7 +121,7 @@ class _DragHandle extends StatelessWidget { height: 6, width: 32, decoration: BoxDecoration( - color: context.themeData.dividerColor, + color: context.themeData.dividerColor.lighten(amount: 0.6), borderRadius: const BorderRadius.all(Radius.circular(20)), ), ); diff --git a/mobile/lib/presentation/widgets/images/full_image.widget.dart b/mobile/lib/presentation/widgets/images/full_image.widget.dart new file mode 100644 index 0000000000..77ea996b89 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/full_image.widget.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/presentation/widgets/images/image_provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart'; +import 'package:octo_image/octo_image.dart'; + +class FullImage extends StatelessWidget { + const FullImage( + this.asset, { + required this.size, + this.fit = BoxFit.cover, + this.placeholder = const ThumbnailPlaceholder(), + super.key, + }); + + final BaseAsset asset; + final Size size; + final Widget? placeholder; + final BoxFit fit; + + @override + Widget build(BuildContext context) { + final provider = getFullImageProvider(asset, size: size); + return OctoImage( + fadeInDuration: const Duration(milliseconds: 0), + fadeOutDuration: const Duration(milliseconds: 100), + placeholderBuilder: placeholder != null ? (_) => placeholder! : null, + image: provider, + width: size.width, + height: size.height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + provider.evict(); + return const Icon(Icons.image_not_supported_outlined, size: 32); + }, + ); + } +} diff --git a/mobile/lib/presentation/widgets/images/image_provider.dart b/mobile/lib/presentation/widgets/images/image_provider.dart new file mode 100644 index 0000000000..e79665baf7 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/image_provider.dart @@ -0,0 +1,63 @@ +import 'package:flutter/widgets.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/services/setting.service.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; + +ImageProvider getFullImageProvider( + BaseAsset asset, { + Size size = const Size(1080, 1920), +}) { + // Create new provider and cache it + final ImageProvider provider; + if (_shouldUseLocalAsset(asset)) { + provider = LocalFullImageProvider(asset: asset as LocalAsset, size: size); + } else { + final String assetId; + if (asset is LocalAsset && asset.hasRemote) { + assetId = asset.remoteId!; + } else if (asset is RemoteAsset) { + assetId = asset.id; + } else { + throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); + } + provider = RemoteFullImageProvider(assetId: assetId); + } + + return provider; +} + +ImageProvider getThumbnailImageProvider({ + BaseAsset? asset, + String? remoteId, + Size size = const Size.square(256), +}) { + assert( + asset != null || remoteId != null, + 'Either asset or remoteId must be provided', + ); + + if (remoteId != null) { + return RemoteThumbProvider(assetId: remoteId); + } + + if (_shouldUseLocalAsset(asset!)) { + return LocalThumbProvider(asset: asset as LocalAsset, size: size); + } + + final String assetId; + if (asset is LocalAsset && asset.hasRemote) { + assetId = asset.remoteId!; + } else if (asset is RemoteAsset) { + assetId = asset.id; + } else { + throw ArgumentError("Unsupported asset type: ${asset.runtimeType}"); + } + + return RemoteThumbProvider(assetId: assetId); +} + +bool _shouldUseLocalAsset(BaseAsset asset) => + asset is LocalAsset && + (!asset.hasRemote || !AppSetting.get(Setting.preferRemoteImage)); diff --git a/mobile/lib/presentation/widgets/images/local_image_provider.dart b/mobile/lib/presentation/widgets/images/local_image_provider.dart new file mode 100644 index 0000000000..214dede1af --- /dev/null +++ b/mobile/lib/presentation/widgets/images/local_image_provider.dart @@ -0,0 +1,241 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.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/services/setting.service.dart'; +import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart'; +import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/constants.dart'; +import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart'; +import 'package:immich_mobile/providers/image/exceptions/image_loading_exception.dart'; +import 'package:logging/logging.dart'; + +class LocalThumbProvider extends ImageProvider { + final AssetMediaRepository _assetMediaRepository = + const AssetMediaRepository(); + final CacheManager? cacheManager; + + final LocalAsset asset; + final Size size; + + const LocalThumbProvider({ + required this.asset, + this.size = const Size.square(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}-${key.size.width}x${key.size.height}'; + + final fileFromCache = await cache.getFileFromCache(cacheKey); + if (fileFromCache != null) { + try { + final buffer = + await ImmutableBuffer.fromFilePath(fileFromCache.file.path); + return decode(buffer); + } catch (_) {} + } + + final thumbnailBytes = + await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size); + 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; +} + +class LocalFullImageProvider extends ImageProvider { + final AssetMediaRepository _assetMediaRepository = + const AssetMediaRepository(); + final StorageRepository _storageRepository = const StorageRepository(); + + final LocalAsset asset; + final Size size; + + const LocalFullImageProvider({ + required this.asset, + required this.size, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) { + return MultiImageStreamCompleter( + codec: _codec(key, decode), + scale: 1.0, + informationCollector: () sync* { + yield ErrorDescription(asset.name); + }, + ); + } + + // Streams in each stage of the image as we ask for it + Stream _codec( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) async* { + try { + switch (key.asset.type) { + case AssetType.image: + yield* _decodeProgressive(key, decode); + break; + case AssetType.video: + final codec = await _getThumbnailCodec(key, decode); + if (codec == null) { + throw StateError("Failed to load preview for ${key.asset.name}"); + } + yield codec; + break; + case AssetType.other: + case AssetType.audio: + throw StateError('Unsupported asset type ${key.asset.type}'); + } + } catch (error, stack) { + Logger('ImmichLocalImageProvider') + .severe('Error loading local image ${key.asset.name}', error, stack); + throw const ImageLoadingException( + 'Could not load image from local storage', + ); + } + } + + Future _getThumbnailCodec( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) async { + final thumbBytes = + await _assetMediaRepository.getThumbnail(key.asset.id, size: key.size); + if (thumbBytes == null) { + return null; + } + final buffer = await ImmutableBuffer.fromUint8List(thumbBytes); + return decode(buffer); + } + + Stream _decodeProgressive( + LocalFullImageProvider key, + ImageDecoderCallback decode, + ) async* { + final file = await _storageRepository.getFileForAsset(key.asset); + if (file == null) { + throw StateError("Opening file for asset ${key.asset.name} failed"); + } + + final fileSize = await file.length(); + final devicePixelRatio = + PlatformDispatcher.instance.views.first.devicePixelRatio; + final isLargeFile = fileSize > 20 * 1024 * 1024; // 20MB + final isHEIC = file.path.toLowerCase().contains(RegExp(r'\.(heic|heif)$')); + final isProgressive = isLargeFile || (isHEIC && !Platform.isIOS); + + if (isProgressive) { + try { + final progressiveMultiplier = devicePixelRatio >= 3.0 ? 1.3 : 1.2; + final size = Size( + (key.size.width * progressiveMultiplier).clamp(256, 1024), + (key.size.height * progressiveMultiplier).clamp(256, 1024), + ); + final mediumThumb = + await _assetMediaRepository.getThumbnail(key.asset.id, size: size); + if (mediumThumb != null) { + final mediumBuffer = await ImmutableBuffer.fromUint8List(mediumThumb); + yield await decode(mediumBuffer); + } + } catch (_) {} + } + + // Load original only when the file is smaller or if the user wants to load original images + // Or load a slightly larger image for progressive loading + if (isProgressive && !(AppSetting.get(Setting.loadOriginal))) { + final progressiveMultiplier = devicePixelRatio >= 3.0 ? 2.0 : 1.6; + final size = Size( + (key.size.width * progressiveMultiplier).clamp(512, 2048), + (key.size.height * progressiveMultiplier).clamp(512, 2048), + ); + final highThumb = + await _assetMediaRepository.getThumbnail(key.asset.id, size: size); + if (highThumb != null) { + final highBuffer = await ImmutableBuffer.fromUint8List(highThumb); + yield await decode(highBuffer); + } + return; + } + + final buffer = await ImmutableBuffer.fromFilePath(file.path); + yield await decode(buffer); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is LocalFullImageProvider) { + return asset.id == other.asset.id && + asset.updatedAt == other.asset.updatedAt && + size == other.size; + } + return false; + } + + @override + int get hashCode => + asset.id.hashCode ^ asset.updatedAt.hashCode ^ size.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/local_thumb_provider.dart b/mobile/lib/presentation/widgets/images/local_thumb_provider.dart deleted file mode 100644 index 11b2f2b08e..0000000000 --- a/mobile/lib/presentation/widgets/images/local_thumb_provider.dart +++ /dev/null @@ -1,95 +0,0 @@ -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/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 AssetMediaRepository _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_image_provider.dart b/mobile/lib/presentation/widgets/images/remote_image_provider.dart new file mode 100644 index 0000000000..14d13a08d8 --- /dev/null +++ b/mobile/lib/presentation/widgets/images/remote_image_provider.dart @@ -0,0 +1,142 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/painting.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:immich_mobile/domain/models/setting.model.dart'; +import 'package:immich_mobile/domain/services/setting.service.dart'; +import 'package:immich_mobile/providers/image/cache/image_loader.dart'; +import 'package:immich_mobile/providers/image/cache/remote_image_cache_manager.dart'; +import 'package:immich_mobile/utils/image_url_builder.dart'; + +class RemoteThumbProvider extends ImageProvider { + final String assetId; + final CacheManager? cacheManager; + + const RemoteThumbProvider({ + required this.assetId, + this.cacheManager, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + RemoteThumbProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? RemoteImageCacheManager(); + 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; +} + +class RemoteFullImageProvider extends ImageProvider { + final String assetId; + final CacheManager? cacheManager; + + const RemoteFullImageProvider({ + required this.assetId, + this.cacheManager, + }); + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + RemoteFullImageProvider key, + ImageDecoderCallback decode, + ) { + final cache = cacheManager ?? RemoteImageCacheManager(); + final chunkEvents = StreamController(); + return MultiImageStreamCompleter( + codec: _codec(key, cache, decode, chunkEvents), + scale: 1.0, + chunkEvents: chunkEvents.stream, + ); + } + + Stream _codec( + RemoteFullImageProvider key, + CacheManager cache, + ImageDecoderCallback decode, + StreamController chunkController, + ) async* { + yield await ImageLoader.loadImageFromCache( + getPreviewUrlForRemoteId(key.assetId), + cache: cache, + decode: decode, + chunkEvents: chunkController, + ); + + if (AppSetting.get(Setting.loadOriginal)) { + yield await ImageLoader.loadImageFromCache( + getOriginalUrlForRemoteId(key.assetId), + cache: cache, + decode: decode, + chunkEvents: chunkController, + ); + } + await chunkController.close(); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is RemoteFullImageProvider) { + return assetId == other.assetId; + } + + return false; + } + + @override + int get hashCode => assetId.hashCode; +} diff --git a/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart b/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart deleted file mode 100644 index f9388ea5d6..0000000000 --- a/mobile/lib/presentation/widgets/images/remote_thumb_provider.dart +++ /dev/null @@ -1,80 +0,0 @@ -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; - - const 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/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index bdf0baa3ca..f54c32dac1 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -1,7 +1,6 @@ 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/image_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'; @@ -25,49 +24,12 @@ class Thumbnail extends StatelessWidget { final Size size; final BoxFit fit; - static ImageProvider imageProvider({ - BaseAsset? asset, - String? remoteId, - Size size = const Size.square(256), - }) { - assert( - asset != null || remoteId != null, - 'Either asset or remoteId must be provided', - ); - - if (remoteId != null) { - return RemoteThumbProvider( - assetId: remoteId, - height: size.height, - width: size.width, - ); - } - - if (asset is LocalAsset) { - return LocalThumbProvider( - asset: asset, - height: size.height, - width: size.width, - ); - } - - if (asset is RemoteAsset) { - 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 RemoteAsset ? (asset as RemoteAsset).thumbHash : null; final provider = - imageProvider(asset: asset, remoteId: remoteId, size: size); + getThumbnailImageProvider(asset: asset, remoteId: remoteId, size: size); return OctoImage.fromSet( image: provider, diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index f243fb1130..571d1c5412 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -59,10 +59,13 @@ class ThumbnailTile extends ConsumerWidget { child: Stack( children: [ Positioned.fill( - child: Thumbnail( - asset: asset, - fit: fit, - size: size, + child: Hero( + tag: asset.heroTag, + child: Thumbnail( + asset: asset, + fit: fit, + size: size, + ), ), ), if (asset.isVideo) diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 4de9eaad38..3fbba803db 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -1,5 +1,6 @@ import 'dart:math' as math; +import 'package:auto_route/auto_route.dart'; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; @@ -12,6 +13,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart' import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; class FixedSegment extends Segment { final double tileHeight; @@ -35,50 +37,24 @@ class FixedSegment extends Segment { @override double indexToLayoutOffset(int index) { - index -= gridIndex; - if (index < 0) { - return startOffset; - } - return gridOffset + (mainAxisExtend * index); + final relativeIndex = index - gridIndex; + return relativeIndex < 0 + ? startOffset + : gridOffset + (mainAxisExtend * relativeIndex); } @override int getMinChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= gridOffset; - if (!scrollOffset.isFinite || scrollOffset < 0) { - return firstIndex; - } - final rowsAbove = (scrollOffset / mainAxisExtend).floor(); - return gridIndex + rowsAbove; + final adjustedOffset = scrollOffset - gridOffset; + if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex; + return gridIndex + (adjustedOffset / mainAxisExtend).floor(); } @override int getMaxChildIndexForScrollOffset(double scrollOffset) { - scrollOffset -= gridOffset; - if (!scrollOffset.isFinite || scrollOffset < 0) { - return firstIndex; - } - final firstRowBelow = (scrollOffset / mainAxisExtend).ceil(); - return gridIndex + firstRowBelow - 1; - } - - void _handleOnTap(WidgetRef ref, BaseAsset asset) { - final multiSelectState = ref.read(multiSelectProvider); - if (!multiSelectState.isEnabled) { - return; - } - - ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); - } - - void _handleOnLongPress(WidgetRef ref, BaseAsset asset) { - final multiSelectState = ref.read(multiSelectProvider); - if (multiSelectState.isEnabled) { - return; - } - - ref.read(hapticFeedbackProvider.notifier).heavyImpact(); - ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + final adjustedOffset = scrollOffset - gridOffset; + if (!adjustedOffset.isFinite || adjustedOffset < 0) return firstIndex; + return gridIndex + (adjustedOffset / mainAxisExtend).ceil() - 1; } @override @@ -97,132 +73,128 @@ class FixedSegment extends Segment { ); } - return _buildRow(firstAssetIndex + assetIndex, numberOfAssets); - } - - Widget _buildRow(int assetIndex, int count) => RepaintBoundary( - child: Consumer( - builder: (ctx, ref, _) { - final isScrubbing = - ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); - final timelineService = ref.read(timelineServiceProvider); - - // Create stable callback references to prevent unnecessary rebuilds - onTap(BaseAsset asset) => _handleOnTap(ref, asset); - onLongPress(BaseAsset asset) => _handleOnLongPress(ref, asset); - - // 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, - baseAssetIndex: assetIndex, - onTap: onTap, - onLongPress: onLongPress, - ); - } - - // 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, - baseAssetIndex: assetIndex, - onTap: onTap, - onLongPress: onLongPress, - ); - }, - ); - }, - ), - ); - - Widget _buildAssetRow( - BuildContext context, - List assets, { - required void Function(BaseAsset) onTap, - required void Function(BaseAsset) onLongPress, - required int baseAssetIndex, - }) => - FixedTimelineRow( - dimension: tileHeight, - spacing: spacing, - textDirection: Directionality.of(context), - children: List.generate( - assets.length, - (i) => _AssetTileWidget( - key: ValueKey(_generateUniqueKey(assets[i], baseAssetIndex + i)), - asset: assets[i], - onTap: onTap, - onLongPress: onLongPress, - ), - ), - ); - - /// Generates a unique key for an asset that handles different asset types - /// and prevents duplicate keys even when assets have the same name/timestamp - String _generateUniqueKey(BaseAsset asset, int assetIndex) { - // Try to get the most unique identifier based on asset type - if (asset is RemoteAsset) { - // For remote/merged assets, use the remote ID which is globally unique - return 'asset_${asset.id}'; - } else if (asset is LocalAsset) { - // For local assets, use the local ID which should be unique per device - return 'local_${asset.id}'; - } else { - // Fallback for any other BaseAsset implementation - // Use checksum if available for additional uniqueness - final checksum = asset.checksum; - if (checksum != null && checksum.isNotEmpty) { - return 'checksum_${checksum.hashCode}'; - } else { - // Last resort: use global asset index + object hash for uniqueness - return 'fallback_${assetIndex}_${asset.hashCode}_${asset.createdAt.microsecondsSinceEpoch}'; - } - } + return _FixedSegmentRow( + assetIndex: firstAssetIndex + assetIndex, + assetCount: numberOfAssets, + tileHeight: tileHeight, + spacing: spacing, + ); } } -class _AssetTileWidget extends StatelessWidget { +class _FixedSegmentRow extends ConsumerWidget { + final int assetIndex; + final int assetCount; + final double tileHeight; + final double spacing; + + const _FixedSegmentRow({ + required this.assetIndex, + required this.assetCount, + required this.tileHeight, + required this.spacing, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isScrubbing = + ref.watch(timelineStateProvider.select((s) => s.isScrubbing)); + final timelineService = ref.read(timelineServiceProvider); + + if (isScrubbing) { + return _buildPlaceholder(context); + } + + if (timelineService.hasRange(assetIndex, assetCount)) { + return _buildAssetRow( + context, + timelineService.getAssets(assetIndex, assetCount), + ); + } + + return FutureBuilder>( + future: timelineService.loadAssets(assetIndex, assetCount), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return _buildPlaceholder(context); + } + return _buildAssetRow(context, snapshot.requireData); + }, + ); + } + + Widget _buildPlaceholder(BuildContext context) { + return SegmentBuilder.buildPlaceholder( + context, + assetCount, + size: Size.square(tileHeight), + spacing: spacing, + ); + } + + Widget _buildAssetRow(BuildContext context, List assets) { + return FixedTimelineRow( + dimension: tileHeight, + spacing: spacing, + textDirection: Directionality.of(context), + children: [ + for (int i = 0; i < assets.length; i++) + _AssetTileWidget( + key: ValueKey(assets[i].heroTag), + asset: assets[i], + assetIndex: assetIndex + i, + ), + ], + ); + } +} + +class _AssetTileWidget extends ConsumerWidget { final BaseAsset asset; - final void Function(BaseAsset) onTap; - final void Function(BaseAsset) onLongPress; + final int assetIndex; const _AssetTileWidget({ super.key, required this.asset, - required this.onTap, - required this.onLongPress, + required this.assetIndex, }); + void _handleOnTap( + BuildContext ctx, + WidgetRef ref, + int assetIndex, + BaseAsset asset, + ) { + final multiSelectState = ref.read(multiSelectProvider); + if (!multiSelectState.isEnabled) { + ctx.pushRoute( + AssetViewerRoute( + initialIndex: assetIndex, + timelineService: ref.read(timelineServiceProvider), + ), + ); + return; + } + + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } + + void _handleOnLongPress(WidgetRef ref, BaseAsset asset) { + final multiSelectState = ref.read(multiSelectProvider); + if (multiSelectState.isEnabled) { + return; + } + + ref.read(hapticFeedbackProvider.notifier).heavyImpact(); + ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset); + } + @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return RepaintBoundary( child: GestureDetector( - onTap: () => onTap(asset), - onLongPress: () => onLongPress(asset), + onTap: () => _handleOnTap(context, ref, assetIndex, asset), + onLongPress: () => _handleOnLongPress(ref, asset), child: ThumbnailTile(asset), ), ); diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 28a6bda278..860af134ae 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; @@ -7,6 +8,12 @@ final localAssetRepository = Provider( (ref) => DriftLocalAssetRepository(ref.watch(driftProvider)), ); -final remoteAssetRepository = Provider( - (ref) => DriftRemoteAssetRepository(ref.watch(driftProvider)), +final remoteAssetRepositoryProvider = Provider( + (ref) => RemoteAssetRepository(ref.watch(driftProvider)), +); + +final assetServiceProvider = Provider( + (ref) => AssetService( + remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), + ), ); diff --git a/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart new file mode 100644 index 0000000000..48cf190bbc --- /dev/null +++ b/mobile/lib/providers/infrastructure/asset_viewer/current_asset.provider.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; + +final currentAssetNotifier = + NotifierProvider(CurrentAssetNotifier.new); + +class CurrentAssetNotifier extends Notifier { + @override + BaseAsset build() { + throw UnimplementedError( + 'An asset must be set before using the currentAssetProvider.', + ); + } + + void setAsset(BaseAsset asset) { + state = asset; + } +} + +final currentAssetExifProvider = FutureProvider( + (ref) { + final currentAsset = ref.watch(currentAssetNotifier); + return ref.watch(assetServiceProvider).getExif(currentAsset); + }, +); diff --git a/mobile/lib/providers/infrastructure/exif.provider.dart b/mobile/lib/providers/infrastructure/exif.provider.dart index af4bb933ec..59ad632927 100644 --- a/mobile/lib/providers/infrastructure/exif.provider.dart +++ b/mobile/lib/providers/infrastructure/exif.provider.dart @@ -8,7 +8,3 @@ part 'exif.provider.g.dart'; @Riverpod(keepAlive: true) IsarExifRepository exifRepository(Ref ref) => IsarExifRepository(ref.watch(isarProvider)); - -final remoteExifRepository = Provider( - (ref) => DriftRemoteExifRepository(ref.watch(driftProvider)), -); diff --git a/mobile/lib/providers/infrastructure/storage.provider.dart b/mobile/lib/providers/infrastructure/storage.provider.dart index f9ac10b461..5bbbe51497 100644 --- a/mobile/lib/providers/infrastructure/storage.provider.dart +++ b/mobile/lib/providers/infrastructure/storage.provider.dart @@ -2,5 +2,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart'; final storageRepositoryProvider = Provider( - (ref) => StorageRepository(), + (ref) => const StorageRepository(), ); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 31e1d715a1..dae2dcdbfb 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/models/folder/recursive_folder.model.dart'; @@ -70,6 +71,7 @@ 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/presentation/pages/dev/remote_timeline.page.dart'; import 'package:immich_mobile/presentation/pages/drift_album.page.dart'; +import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/gallery_permission.provider.dart'; import 'package:immich_mobile/routing/auth_guard.dart'; @@ -371,6 +373,18 @@ class AppRouter extends RootStackRouter { page: RemoteTimelineRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: AssetViewerRoute.page, + guards: [_authGuard, _duplicateGuard], + type: RouteType.custom( + customRouteBuilder: (context, child, page) => PageRouteBuilder( + fullscreenDialog: page.fullscreenDialog, + settings: page, + pageBuilder: (_, __, ___) => child, + opaque: false, + ), + ), + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 2ea10491b3..348cea656e 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -403,6 +403,58 @@ class ArchiveRoute extends PageRouteInfo { ); } +/// generated route for +/// [AssetViewerPage] +class AssetViewerRoute extends PageRouteInfo { + AssetViewerRoute({ + Key? key, + required int initialIndex, + required TimelineService timelineService, + List? children, + }) : super( + AssetViewerRoute.name, + args: AssetViewerRouteArgs( + key: key, + initialIndex: initialIndex, + timelineService: timelineService, + ), + initialChildren: children, + ); + + static const String name = 'AssetViewerRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return AssetViewerPage( + key: args.key, + initialIndex: args.initialIndex, + timelineService: args.timelineService, + ); + }, + ); +} + +class AssetViewerRouteArgs { + const AssetViewerRouteArgs({ + this.key, + required this.initialIndex, + required this.timelineService, + }); + + final Key? key; + + final int initialIndex; + + final TimelineService timelineService; + + @override + String toString() { + return 'AssetViewerRouteArgs{key: $key, initialIndex: $initialIndex, timelineService: $timelineService}'; + } +} + /// generated route for /// [BackupAlbumSelectionPage] class BackupAlbumSelectionRoute extends PageRouteInfo { diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart index d1aabf3fb6..2f4c8cc926 100644 --- a/mobile/lib/services/action.service.dart +++ b/mobile/lib/services/action.service.dart @@ -2,10 +2,8 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; -import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/exif.provider.dart'; import 'package:immich_mobile/repositories/asset_api.repository.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/widgets/common/location_picker.dart'; @@ -15,20 +13,17 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; final actionServiceProvider = Provider( (ref) => ActionService( ref.watch(assetApiRepositoryProvider), - ref.watch(remoteAssetRepository), - ref.watch(remoteExifRepository), + ref.watch(remoteAssetRepositoryProvider), ), ); class ActionService { final AssetApiRepository _assetApiRepository; - final DriftRemoteAssetRepository _remoteAssetRepository; - final DriftRemoteExifRepository _remoteExifRepository; + final RemoteAssetRepository _remoteAssetRepository; const ActionService( this._assetApiRepository, this._remoteAssetRepository, - this._remoteExifRepository, ); Future shareLink(List remoteIds, BuildContext context) async { @@ -109,7 +104,7 @@ class ActionService { ) async { LatLng? initialLatLng; if (remoteIds.length == 1) { - final exif = await _remoteExifRepository.get(remoteIds[0]); + final exif = await _remoteAssetRepository.getExif(remoteIds[0]); if (exif?.latitude != null && exif?.longitude != null) { initialLatLng = LatLng(exif!.latitude!, exif.longitude!); diff --git a/mobile/lib/utils/cache/custom_image_cache.dart b/mobile/lib/utils/cache/custom_image_cache.dart index dcb8dacb0d..8c70472765 100644 --- a/mobile/lib/utils/cache/custom_image_cache.dart +++ b/mobile/lib/utils/cache/custom_image_cache.dart @@ -1,4 +1,6 @@ import 'package:flutter/painting.dart'; +import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart'; +import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_image_provider.dart'; import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'; @@ -37,10 +39,12 @@ final class CustomImageCache implements ImageCache { /// Gets the cache for the given key /// [_large] is used for [ImmichLocalImageProvider] and [ImmichRemoteImageProvider] /// [_small] is used for [ImmichLocalThumbnailProvider] and [ImmichRemoteThumbnailProvider] - ImageCache _cacheForKey(Object key) => - (key is ImmichLocalImageProvider || key is ImmichRemoteImageProvider) - ? _large - : _small; + ImageCache _cacheForKey(Object key) => (key is ImmichLocalImageProvider || + key is ImmichRemoteImageProvider || + key is LocalFullImageProvider || + key is RemoteFullImageProvider) + ? _large + : _small; @override bool containsKey(Object key) { diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 50218eaffd..bde50f3a90 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -73,6 +73,9 @@ String getThumbnailUrlForRemoteId( return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${type.value}'; } +String getPreviewUrlForRemoteId(final String id) => + '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail?size=${AssetMediaSize.preview}'; + String getPlaybackUrlForRemoteId(final String id) { return '${Store.get(StoreKey.serverEndpoint)}/assets/$id/video/playback?'; } diff --git a/mobile/lib/widgets/photo_view/photo_view.dart b/mobile/lib/widgets/photo_view/photo_view.dart index f72d1e298f..0c1a4f4855 100644 --- a/mobile/lib/widgets/photo_view/photo_view.dart +++ b/mobile/lib/widgets/photo_view/photo_view.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; - import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart'; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart'; import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart'; @@ -16,6 +15,11 @@ export 'src/photo_view_computed_scale.dart'; export 'src/photo_view_scale_state.dart'; export 'src/utils/photo_view_hero_attributes.dart'; +typedef PhotoViewControllerCallback = PhotoViewControllerBase Function(); +typedef PhotoViewControllerCallbackBuilder = void Function( + PhotoViewControllerCallback photoViewMethod, +); + /// A [StatefulWidget] that contains all the photo view rendering elements. /// /// Sample code to use within an image: @@ -239,8 +243,11 @@ class PhotoView extends StatefulWidget { this.wantKeepAlive = false, this.gaplessPlayback = false, this.heroAttributes, + this.onPageBuild, + this.controllerCallbackBuilder, this.scaleStateChangedCallback, this.enableRotation = false, + this.semanticLabel, this.controller, this.scaleStateController, this.maxScale, @@ -260,6 +267,7 @@ class PhotoView extends StatefulWidget { this.tightMode, this.filterQuality, this.disableGestures, + this.disableScaleGestures, this.errorBuilder, this.enablePanAlways, }) : child = null, @@ -278,6 +286,8 @@ class PhotoView extends StatefulWidget { this.backgroundDecoration, this.wantKeepAlive = false, this.heroAttributes, + this.onPageBuild, + this.controllerCallbackBuilder, this.scaleStateChangedCallback, this.enableRotation = false, this.controller, @@ -298,9 +308,11 @@ class PhotoView extends StatefulWidget { this.gestureDetectorBehavior, this.tightMode, this.filterQuality, + this.disableScaleGestures, this.disableGestures, this.enablePanAlways, - }) : errorBuilder = null, + }) : semanticLabel = null, + errorBuilder = null, imageProvider = null, gaplessPlayback = false, loadingBuilder = null, @@ -325,6 +337,11 @@ class PhotoView extends StatefulWidget { /// `true` -> keeps the state final bool wantKeepAlive; + /// A Semantic description of the image. + /// + /// Used to provide a description of the image to TalkBack on Android, and VoiceOver on iOS. + final String? semanticLabel; + /// This is used to continue showing the old image (`true`), or briefly show /// nothing (`false`), when the `imageProvider` changes. By default it's set /// to `false`. @@ -338,6 +355,12 @@ class PhotoView extends StatefulWidget { /// by default it is `MediaQuery.of(context).size`. final Size? customSize; + // Called when a new PhotoView widget is built + final ValueChanged? onPageBuild; + + // Called from the parent during page change to get the new controller + final PhotoViewControllerCallbackBuilder? controllerCallbackBuilder; + /// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in. final ValueChanged? scaleStateChangedCallback; @@ -419,6 +442,9 @@ class PhotoView extends StatefulWidget { // Useful when custom gesture detector is used in child widget. final bool? disableGestures; + /// Mirror to [PhotoView.disableGestures] + final bool? disableScaleGestures; + /// Enable pan the widget even if it's smaller than the hole parent widget. /// Useful when you want to drag a widget without restrictions. final bool? enablePanAlways; @@ -452,6 +478,7 @@ class _PhotoViewState extends State if (widget.controller == null) { _controlledController = true; _controller = PhotoViewController(); + widget.onPageBuild?.call(_controller); } else { _controlledController = false; _controller = widget.controller!; @@ -466,6 +493,8 @@ class _PhotoViewState extends State } _scaleStateController.outputScaleStateStream.listen(scaleStateListener); + // Pass a ref to the method back to the gallery so it can fetch the controller on page changes + widget.controllerCallbackBuilder?.call(_controllerGetter); } @override @@ -474,6 +503,7 @@ class _PhotoViewState extends State if (!_controlledController) { _controlledController = true; _controller = PhotoViewController(); + widget.onPageBuild?.call(_controller); } } else { _controlledController = false; @@ -509,6 +539,8 @@ class _PhotoViewState extends State } } + PhotoViewControllerBase _controllerGetter() => _controller; + @override Widget build(BuildContext context) { super.build(context); @@ -547,6 +579,7 @@ class _PhotoViewState extends State tightMode: widget.tightMode, filterQuality: widget.filterQuality, disableGestures: widget.disableGestures, + disableScaleGestures: widget.disableScaleGestures, enablePanAlways: widget.enablePanAlways, child: widget.child, ) @@ -554,6 +587,7 @@ class _PhotoViewState extends State imageProvider: widget.imageProvider!, loadingBuilder: widget.loadingBuilder, backgroundDecoration: backgroundDecoration, + semanticLabel: widget.semanticLabel, gaplessPlayback: widget.gaplessPlayback, heroAttributes: widget.heroAttributes, scaleStateChangedCallback: widget.scaleStateChangedCallback, @@ -577,6 +611,7 @@ class _PhotoViewState extends State tightMode: widget.tightMode, filterQuality: widget.filterQuality, disableGestures: widget.disableGestures, + disableScaleGestures: widget.disableScaleGestures, errorBuilder: widget.errorBuilder, enablePanAlways: widget.enablePanAlways, index: widget.index, @@ -626,6 +661,7 @@ typedef PhotoViewImageDragStartCallback = Function( BuildContext context, DragStartDetails details, PhotoViewControllerValue controllerValue, + PhotoViewScaleStateController scaleStateController, ); /// A type definition for a callback when the user drags diff --git a/mobile/lib/widgets/photo_view/photo_view_gallery.dart b/mobile/lib/widgets/photo_view/photo_view_gallery.dart index 26c292d678..cf026288fb 100644 --- a/mobile/lib/widgets/photo_view/photo_view_gallery.dart +++ b/mobile/lib/widgets/photo_view/photo_view_gallery.dart @@ -4,13 +4,14 @@ import 'package:immich_mobile/widgets/photo_view/photo_view.dart' show LoadingBuilder, PhotoView, + PhotoViewControllerCallback, + PhotoViewImageDragEndCallback, + PhotoViewImageDragStartCallback, + PhotoViewImageDragUpdateCallback, + PhotoViewImageLongPressStartCallback, + PhotoViewImageScaleEndCallback, PhotoViewImageTapDownCallback, PhotoViewImageTapUpCallback, - PhotoViewImageDragStartCallback, - PhotoViewImageDragEndCallback, - PhotoViewImageDragUpdateCallback, - PhotoViewImageScaleEndCallback, - PhotoViewImageLongPressStartCallback, ScaleStateCycle; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart'; import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart'; @@ -19,7 +20,10 @@ import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart'; /// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery] -typedef PhotoViewGalleryPageChangedCallback = void Function(int index); +typedef PhotoViewGalleryPageChangedCallback = void Function( + int index, + PhotoViewControllerBase? controller, +); /// A type definition for a [Function] that defines a page in [PhotoViewGallery.build] typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function( @@ -114,12 +118,14 @@ class PhotoViewGallery extends StatefulWidget { this.reverse = false, this.pageController, this.onPageChanged, + this.onPageBuild, this.scaleStateChangedCallback, this.enableRotation = false, this.scrollPhysics, this.scrollDirection = Axis.horizontal, this.customSize, this.allowImplicitScrolling = false, + this.enablePanAlways = false, }) : itemCount = null, builder = null; @@ -137,12 +143,14 @@ class PhotoViewGallery extends StatefulWidget { this.reverse = false, this.pageController, this.onPageChanged, + this.onPageBuild, this.scaleStateChangedCallback, this.enableRotation = false, this.scrollPhysics, this.scrollDirection = Axis.horizontal, this.customSize, this.allowImplicitScrolling = false, + this.enablePanAlways = false, }) : pageOptions = null, assert(itemCount != null), assert(builder != null); @@ -168,6 +176,9 @@ class PhotoViewGallery extends StatefulWidget { /// Mirror to [PhotoView.wantKeepAlive] final bool wantKeepAlive; + /// Mirror to [PhotoView.enablePanAlways] + final bool enablePanAlways; + /// Mirror to [PhotoView.gaplessPlayback] final bool gaplessPlayback; @@ -180,6 +191,9 @@ class PhotoViewGallery extends StatefulWidget { /// An callback to be called on a page change final PhotoViewGalleryPageChangedCallback? onPageChanged; + /// Mirror to [PhotoView.onPageBuild] + final ValueChanged? onPageBuild; + /// Mirror to [PhotoView.scaleStateChangedCallback] final ValueChanged? scaleStateChangedCallback; @@ -206,6 +220,7 @@ class PhotoViewGallery extends StatefulWidget { class _PhotoViewGalleryState extends State { late final PageController _controller = widget.pageController ?? PageController(); + PhotoViewControllerCallback? _getController; void scaleStateChangedCallback(PhotoViewScaleState scaleState) { if (widget.scaleStateChangedCallback != null) { @@ -224,6 +239,14 @@ class _PhotoViewGalleryState extends State { return widget.pageOptions!.length; } + void _getControllerCallbackBuilder(PhotoViewControllerCallback method) { + _getController = method; + } + + void _onPageChange(int page) { + widget.onPageChanged?.call(page, _getController?.call()); + } + @override Widget build(BuildContext context) { // Enable corner hit test @@ -232,7 +255,7 @@ class _PhotoViewGalleryState extends State { child: PageView.builder( reverse: widget.reverse, controller: _controller, - onPageChanged: widget.onPageChanged, + onPageChanged: _onPageChange, itemCount: itemCount, itemBuilder: _buildItem, scrollDirection: widget.scrollDirection, @@ -255,6 +278,8 @@ class _PhotoViewGalleryState extends State { controller: pageOption.controller, scaleStateController: pageOption.scaleStateController, customSize: widget.customSize, + onPageBuild: widget.onPageBuild, + controllerCallbackBuilder: _getControllerCallbackBuilder, scaleStateChangedCallback: scaleStateChangedCallback, enableRotation: widget.enableRotation, initialScale: pageOption.initialScale, @@ -273,7 +298,9 @@ class _PhotoViewGalleryState extends State { filterQuality: pageOption.filterQuality, basePosition: pageOption.basePosition, disableGestures: pageOption.disableGestures, + disableScaleGestures: pageOption.disableScaleGestures, heroAttributes: pageOption.heroAttributes, + enablePanAlways: widget.enablePanAlways, child: pageOption.child, ) : PhotoView( @@ -282,8 +309,11 @@ class _PhotoViewGalleryState extends State { imageProvider: pageOption.imageProvider, loadingBuilder: widget.loadingBuilder, backgroundDecoration: widget.backgroundDecoration, + semanticLabel: pageOption.semanticLabel, wantKeepAlive: widget.wantKeepAlive, controller: pageOption.controller, + onPageBuild: widget.onPageBuild, + controllerCallbackBuilder: _getControllerCallbackBuilder, scaleStateController: pageOption.scaleStateController, customSize: widget.customSize, gaplessPlayback: widget.gaplessPlayback, @@ -305,6 +335,8 @@ class _PhotoViewGalleryState extends State { filterQuality: pageOption.filterQuality, basePosition: pageOption.basePosition, disableGestures: pageOption.disableGestures, + disableScaleGestures: pageOption.disableScaleGestures, + enablePanAlways: widget.enablePanAlways, errorBuilder: pageOption.errorBuilder, heroAttributes: pageOption.heroAttributes, ); @@ -334,6 +366,7 @@ class PhotoViewGalleryPageOptions { Key? key, required this.imageProvider, this.heroAttributes, + this.semanticLabel, this.minScale, this.maxScale, this.initialScale, @@ -351,6 +384,7 @@ class PhotoViewGalleryPageOptions { this.gestureDetectorBehavior, this.tightMode, this.filterQuality, + this.disableScaleGestures, this.disableGestures, this.errorBuilder, }) : child = null, @@ -360,6 +394,7 @@ class PhotoViewGalleryPageOptions { const PhotoViewGalleryPageOptions.customChild({ required this.child, this.childSize, + this.semanticLabel, this.heroAttributes, this.minScale, this.maxScale, @@ -378,6 +413,7 @@ class PhotoViewGalleryPageOptions { this.gestureDetectorBehavior, this.tightMode, this.filterQuality, + this.disableScaleGestures, this.disableGestures, }) : errorBuilder = null, imageProvider = null; @@ -388,6 +424,9 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.heroAttributes] final PhotoViewHeroAttributes? heroAttributes; + /// Mirror to [PhotoView.semanticLabel] + final String? semanticLabel; + /// Mirror to [PhotoView.minScale] final dynamic minScale; @@ -445,6 +484,9 @@ class PhotoViewGalleryPageOptions { /// Mirror to [PhotoView.disableGestures] final bool? disableGestures; + /// Mirror to [PhotoView.disableGestures] + final bool? disableScaleGestures; + /// Quality levels for image filters. final FilterQuality? filterQuality; diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart index e26708bb41..37d1c78de1 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller.dart @@ -37,6 +37,13 @@ abstract class PhotoViewControllerBase { /// Closes streams and removes eventual listeners. void dispose(); + void positionAnimationBuilder(void Function(Offset)? value); + void scaleAnimationBuilder(void Function(double)? value); + void rotationAnimationBuilder(void Function(double)? value); + + /// Animates multiple fields of the state + void animateMultiple({Offset? position, double? scale, double? rotation}); + /// Add a listener that will ignore updates made internally /// /// Since it is made for internal use, it is not performatic to use more than one @@ -147,12 +154,31 @@ class PhotoViewController late StreamController _outputCtrl; + late void Function(Offset)? _animatePosition; + late void Function(double)? _animateScale; + late void Function(double)? _animateRotation; + @override Stream get outputStateStream => _outputCtrl.stream; @override late PhotoViewControllerValue prevValue; + @override + void positionAnimationBuilder(void Function(Offset)? value) { + _animatePosition = value; + } + + @override + void scaleAnimationBuilder(void Function(double)? value) { + _animateScale = value; + } + + @override + void rotationAnimationBuilder(void Function(double)? value) { + _animateRotation = value; + } + @override void reset() { value = initial; @@ -172,6 +198,21 @@ class PhotoViewController _valueNotifier.removeIgnorableListener(callback); } + @override + void animateMultiple({Offset? position, double? scale, double? rotation}) { + if (position != null && _animatePosition != null) { + _animatePosition!(position); + } + + if (scale != null && _animateScale != null) { + _animateScale!(scale); + } + + if (rotation != null && _animateRotation != null) { + _animateRotation!(rotation); + } + } + @override void dispose() { _outputCtrl.close(); diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart index 968ac652e7..e2e668199a 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_controller_delegate.dart @@ -111,6 +111,16 @@ mixin PhotoViewControllerDelegate on State { ); } + PhotoViewScaleState getScaleStateFromNewScale(double newScale) { + PhotoViewScaleState newScaleState = PhotoViewScaleState.initial; + if (scale != scaleBoundaries.initialScale) { + newScaleState = (newScale > scaleBoundaries.initialScale) + ? PhotoViewScaleState.zoomedIn + : PhotoViewScaleState.zoomedOut; + } + return newScaleState; + } + void updateScaleStateFromNewScale(double newScale) { PhotoViewScaleState newScaleState = PhotoViewScaleState.initial; if (scale != scaleBoundaries.initialScale) { diff --git a/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart b/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart index 16021ceab1..dea8be1a0f 100644 --- a/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart +++ b/mobile/lib/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart @@ -26,6 +26,8 @@ class PhotoViewScaleStateController { StreamController.broadcast() ..sink.add(PhotoViewScaleState.initial); + bool _hasZoomedOutManually = false; + /// The output for state/value updates Stream get outputScaleStateStream => _outputScaleStateCtrl.stream; @@ -42,10 +44,20 @@ class PhotoViewScaleStateController { return; } + if (newValue == PhotoViewScaleState.zoomedOut) { + _hasZoomedOutManually = true; + } + + if (newValue == PhotoViewScaleState.initial) { + _hasZoomedOutManually = false; + } + prevScaleState = _scaleStateNotifier.value; _scaleStateNotifier.value = newValue; } + bool get hasZoomedOutManually => _hasZoomedOutManually; + /// Checks if its actual value is different than previousValue bool get hasChanged => prevScaleState != scaleState; @@ -71,6 +83,15 @@ class PhotoViewScaleStateController { if (_scaleStateNotifier.value == newValue) { return; } + + if (newValue == PhotoViewScaleState.zoomedOut) { + _hasZoomedOutManually = true; + } + + if (newValue == PhotoViewScaleState.initial) { + _hasZoomedOutManually = false; + } + prevScaleState = _scaleStateNotifier.value; _scaleStateNotifier.updateIgnoring(newValue); } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart index bb892737f6..bc97da1e06 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_core.dart @@ -29,6 +29,7 @@ class PhotoViewCore extends StatefulWidget { super.key, required this.imageProvider, required this.backgroundDecoration, + required this.semanticLabel, required this.gaplessPlayback, required this.heroAttributes, required this.enableRotation, @@ -48,6 +49,7 @@ class PhotoViewCore extends StatefulWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + required this.disableScaleGestures, required this.enablePanAlways, }) : customChild = null; @@ -73,12 +75,15 @@ class PhotoViewCore extends StatefulWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + required this.disableScaleGestures, required this.enablePanAlways, - }) : imageProvider = null, + }) : semanticLabel = null, + imageProvider = null, gaplessPlayback = false; final Decoration? backgroundDecoration; final ImageProvider? imageProvider; + final String? semanticLabel; final bool? gaplessPlayback; final PhotoViewHeroAttributes? heroAttributes; final bool enableRotation; @@ -103,6 +108,7 @@ class PhotoViewCore extends StatefulWidget { final HitTestBehavior? gestureDetectorBehavior; final bool tightMode; final bool disableGestures; + final bool disableScaleGestures; final bool enablePanAlways; final FilterQuality filterQuality; @@ -120,6 +126,7 @@ class PhotoViewCoreState extends State TickerProviderStateMixin, PhotoViewControllerDelegate, HitCornersDetector { + Offset? _normalizedPosition; double? _scaleBefore; double? _rotationBefore; @@ -152,32 +159,33 @@ class PhotoViewCoreState extends State void onScaleStart(ScaleStartDetails details) { _rotationBefore = controller.rotation; _scaleBefore = scale; + _normalizedPosition = details.focalPoint - controller.position; _scaleAnimationController.stop(); _positionAnimationController.stop(); _rotationAnimationController.stop(); } + bool _shouldAllowPanRotate() => switch (scaleStateController.scaleState) { + PhotoViewScaleState.zoomedIn => + scaleStateController.hasZoomedOutManually, + _ => true, + }; + void onScaleUpdate(ScaleUpdateDetails details) { - final centeredFocalPoint = Offset( - details.focalPoint.dx - scaleBoundaries.outerSize.width / 2, - details.focalPoint.dy - scaleBoundaries.outerSize.height / 2, - ); final double newScale = _scaleBefore! * details.scale; - final double scaleDelta = newScale / scale; - final Offset newPosition = - (controller.position + details.focalPointDelta) * scaleDelta - - centeredFocalPoint * (scaleDelta - 1); + Offset delta = details.focalPoint - _normalizedPosition!; updateScaleStateFromNewScale(newScale); + final panEnabled = widget.enablePanAlways && _shouldAllowPanRotate(); + final rotationEnabled = widget.enableRotation && _shouldAllowPanRotate(); + updateMultiple( scale: newScale, - position: widget.enablePanAlways - ? newPosition - : clampPosition(position: newPosition), - rotation: - widget.enableRotation ? _rotationBefore! + details.rotation : null, - rotationFocusPoint: widget.enableRotation ? details.focalPoint : null, + position: + panEnabled ? delta : clampPosition(position: delta * details.scale), + rotation: rotationEnabled ? _rotationBefore! + details.rotation : null, + rotationFocusPoint: rotationEnabled ? details.focalPoint : null, ); } @@ -189,6 +197,16 @@ class PhotoViewCoreState extends State widget.onScaleEnd?.call(context, details, controller.value); + final scaleState = getScaleStateFromNewScale(scale); + if (scaleState == PhotoViewScaleState.zoomedOut) { + scaleStateController.scaleState = PhotoViewScaleState.originalSize; + } else if (scaleState == PhotoViewScaleState.zoomedIn) { + animateRotation(controller.rotation, 0); + if (_shouldAllowPanRotate()) { + animatePosition(controller.position, Offset.zero); + } + } + //animate back to maxScale if gesture exceeded the maxScale specified if (s > maxScale) { final double scaleComebackRatio = maxScale / s; @@ -232,6 +250,9 @@ class PhotoViewCoreState extends State } void animateScale(double from, double to) { + if (!mounted) { + return; + } _scaleAnimation = Tween( begin: from, end: to, @@ -242,6 +263,9 @@ class PhotoViewCoreState extends State } void animatePosition(Offset from, Offset to) { + if (!mounted) { + return; + } _positionAnimation = Tween(begin: from, end: to) .animate(_positionAnimationController); _positionAnimationController @@ -250,6 +274,9 @@ class PhotoViewCoreState extends State } void animateRotation(double from, double to) { + if (!mounted) { + return; + } _rotationAnimation = Tween(begin: from, end: to) .animate(_rotationAnimationController); _rotationAnimationController @@ -271,11 +298,28 @@ class PhotoViewCoreState extends State } } + void _animateControllerPosition(Offset position) { + animatePosition(controller.position, position); + } + + void _animateControllerScale(double scale) { + if (controller.scale != null) { + animateScale(controller.scale!, scale); + } + } + + void _animateControllerRotation(double rotation) { + animateRotation(controller.rotation, rotation); + } + @override void initState() { super.initState(); initDelegate(); addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate); + controller.positionAnimationBuilder(_animateControllerPosition); + controller.scaleAnimationBuilder(_animateControllerScale); + controller.rotationAnimationBuilder(_animateControllerRotation); cachedScaleBoundaries = widget.scaleBoundaries; @@ -341,7 +385,7 @@ class PhotoViewCoreState extends State basePosition, useImageScale, ), - child: _buildHero(), + child: _buildHero(_buildChild()), ); final child = Container( @@ -363,18 +407,29 @@ class PhotoViewCoreState extends State } return PhotoViewGestureDetector( - onDoubleTap: nextScaleState, - onScaleStart: onScaleStart, - onScaleUpdate: onScaleUpdate, - onScaleEnd: onScaleEnd, + disableScaleGestures: widget.disableScaleGestures, + onDoubleTap: widget.disableScaleGestures ? null : onDoubleTap, + onScaleStart: widget.disableScaleGestures ? null : onScaleStart, + onScaleUpdate: widget.disableScaleGestures ? null : onScaleUpdate, + onScaleEnd: widget.disableScaleGestures ? null : onScaleEnd, onDragStart: widget.onDragStart != null - ? (details) => widget.onDragStart!(context, details, value) + ? (details) => widget.onDragStart!( + context, + details, + widget.controller.value, + widget.scaleStateController, + ) : null, onDragEnd: widget.onDragEnd != null - ? (details) => widget.onDragEnd!(context, details, value) + ? (details) => + widget.onDragEnd!(context, details, widget.controller.value) : null, onDragUpdate: widget.onDragUpdate != null - ? (details) => widget.onDragUpdate!(context, details, value) + ? (details) => widget.onDragUpdate!( + context, + details, + widget.controller.value, + ) : null, hitDetector: this, onTapUp: widget.onTapUp != null @@ -395,7 +450,7 @@ class PhotoViewCoreState extends State ); } - Widget _buildHero() { + Widget _buildHero(Widget child) { return heroAttributes != null ? Hero( tag: heroAttributes!.tag, @@ -403,16 +458,20 @@ class PhotoViewCoreState extends State flightShuttleBuilder: heroAttributes!.flightShuttleBuilder, placeholderBuilder: heroAttributes!.placeholderBuilder, transitionOnUserGestures: heroAttributes!.transitionOnUserGestures, - child: _buildChild(), + child: child, ) - : _buildChild(); + : child; } Widget _buildChild() { return widget.hasCustomChild ? widget.customChild! : Image( + key: widget.heroAttributes?.tag != null + ? ObjectKey(widget.heroAttributes!.tag) + : null, image: widget.imageProvider!, + semanticLabel: widget.semanticLabel, gaplessPlayback: widget.gaplessPlayback ?? false, filterQuality: widget.filterQuality, width: scaleBoundaries.childSize.width * scale, @@ -442,6 +501,7 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate { final double offsetX = halfWidth * (basePosition.x + 1); final double offsetY = halfHeight * (basePosition.y + 1); + return Offset(offsetX, offsetY); } diff --git a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart index 2eef5e6742..93fd1526da 100644 --- a/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart +++ b/mobile/lib/widgets/photo_view/src/core/photo_view_gesture_detector.dart @@ -21,6 +21,7 @@ class PhotoViewGestureDetector extends StatelessWidget { this.onTapUp, this.onTapDown, this.behavior, + this.disableScaleGestures = false, }); final GestureDoubleTapCallback? onDoubleTap; @@ -43,6 +44,8 @@ class PhotoViewGestureDetector extends StatelessWidget { final HitTestBehavior? behavior; + final bool disableScaleGestures; + @override Widget build(BuildContext context) { final scope = PhotoViewGestureDetectorScope.of(context); @@ -96,9 +99,11 @@ class PhotoViewGestureDetector extends StatelessWidget { ), (PhotoViewGestureRecognizer instance) { instance + ..dragStartBehavior = DragStartBehavior.start ..onStart = onScaleStart ..onUpdate = onScaleUpdate - ..onEnd = onScaleEnd; + ..onEnd = onScaleEnd + ..disableScaleGestures = disableScaleGestures; }, ); @@ -124,10 +129,12 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { this.validateAxis, this.touchSlopFactor = 1, PointerDeviceKind? kind, + this.disableScaleGestures = false, }) : super(supportedDevices: null); final HitCornersDetector? hitDetector; final Axis? validateAxis; final double touchSlopFactor; + bool disableScaleGestures; Map _pointerLocations = {}; @@ -155,7 +162,7 @@ class PhotoViewGestureRecognizer extends ScaleGestureRecognizer { @override void handleEvent(PointerEvent event) { - if (validateAxis != null) { + if (validateAxis != null && !disableScaleGestures) { bool didChangeConfiguration = false; if (event is PointerMoveEvent) { if (!event.synthesized) { diff --git a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart index 57496f3777..d4afe85d2b 100644 --- a/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart +++ b/mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart @@ -11,6 +11,7 @@ class ImageWrapper extends StatefulWidget { required this.imageProvider, required this.loadingBuilder, required this.backgroundDecoration, + required this.semanticLabel, required this.gaplessPlayback, required this.heroAttributes, required this.scaleStateChangedCallback, @@ -34,6 +35,7 @@ class ImageWrapper extends StatefulWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + this.disableScaleGestures, required this.errorBuilder, required this.enablePanAlways, required this.index, @@ -43,6 +45,7 @@ class ImageWrapper extends StatefulWidget { final LoadingBuilder? loadingBuilder; final ImageErrorWidgetBuilder? errorBuilder; final BoxDecoration backgroundDecoration; + final String? semanticLabel; final bool gaplessPlayback; final PhotoViewHeroAttributes? heroAttributes; final ValueChanged? scaleStateChangedCallback; @@ -66,6 +69,7 @@ class ImageWrapper extends StatefulWidget { final bool? tightMode; final FilterQuality? filterQuality; final bool? disableGestures; + final bool? disableScaleGestures; final bool? enablePanAlways; final int index; @@ -193,6 +197,7 @@ class _ImageWrapperState extends State { return PhotoViewCore( imageProvider: widget.imageProvider, backgroundDecoration: widget.backgroundDecoration, + semanticLabel: widget.semanticLabel, gaplessPlayback: widget.gaplessPlayback, enableRotation: widget.enableRotation, heroAttributes: widget.heroAttributes, @@ -212,6 +217,7 @@ class _ImageWrapperState extends State { tightMode: widget.tightMode ?? false, filterQuality: widget.filterQuality ?? FilterQuality.none, disableGestures: widget.disableGestures ?? false, + disableScaleGestures: widget.disableScaleGestures ?? false, enablePanAlways: widget.enablePanAlways ?? false, ); } @@ -266,6 +272,7 @@ class CustomChildWrapper extends StatelessWidget { required this.tightMode, required this.filterQuality, required this.disableGestures, + this.disableScaleGestures, required this.enablePanAlways, }); @@ -296,6 +303,7 @@ class CustomChildWrapper extends StatelessWidget { final HitTestBehavior? gestureDetectorBehavior; final bool? tightMode; final FilterQuality? filterQuality; + final bool? disableScaleGestures; final bool? disableGestures; final bool? enablePanAlways; @@ -330,6 +338,7 @@ class CustomChildWrapper extends StatelessWidget { tightMode: tightMode ?? false, filterQuality: filterQuality ?? FilterQuality.none, disableGestures: disableGestures ?? false, + disableScaleGestures: disableScaleGestures ?? false, enablePanAlways: enablePanAlways ?? false, ); }