From a0afea04d8b9474001d2da1bcc8088965b3e48da Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Wed, 23 Oct 2024 02:30:46 +0530 Subject: [PATCH] more refactors and logs page handling --- mobile-v2/analysis_options.yaml | 67 +- mobile-v2/assets/i18n/strings.i18n.json | 6 +- mobile-v2/ios/Podfile.lock | 12 +- .../ios/Runner.xcodeproj/project.pbxproj | 18 + .../domain/interfaces/album.interface.dart | 8 +- .../interfaces/album_asset.interface.dart | 10 +- .../interfaces/album_etag.interface.dart | 6 +- .../domain/interfaces/asset.interface.dart | 14 +- .../interfaces/device_album.interface.dart | 6 +- .../interfaces/device_asset.interface.dart | 4 +- .../device_asset_hash.interface.dart | 6 +- .../lib/domain/interfaces/log.interface.dart | 10 +- .../domain/interfaces/store.interface.dart | 10 +- .../lib/domain/interfaces/user.interface.dart | 6 +- mobile-v2/lib/domain/models/asset.model.dart | 16 +- .../lib/domain/models/render_list.model.dart | 20 +- .../domain/repositories/album.repository.dart | 12 +- .../repositories/album_asset.repository.dart | 12 +- .../repositories/album_etag.repository.dart | 10 +- .../repositories/api/sync_api.repository.dart | 14 +- .../domain/repositories/asset.repository.dart | 28 +- .../repositories/database.repository.dart | 4 +- .../repositories/device_album.repository.dart | 8 +- .../repositories/device_asset.repository.dart | 22 +- .../device_asset_hash.repository.dart | 13 +- .../domain/repositories/log.repository.dart | 25 +- .../repositories/renderlist.repository.dart | 16 +- .../domain/repositories/store.repository.dart | 22 +- .../domain/repositories/user.repository.dart | 50 +- .../domain/services/asset_sync.service.dart | 6 +- .../lib/domain/services/hash.service.dart | 24 +- .../domain/utils/drift_model_converters.dart | 14 +- mobile-v2/lib/immich_app.dart | 12 +- mobile-v2/lib/main.dart | 6 +- mobile-v2/lib/platform/messages.dart | 6 +- .../common/immich_navigation_rail.dart | 1172 +++++++++++++++++ .../skeletonized_future_builder.widget.dart | 62 + .../components/common/user_avatar.widget.dart | 56 + .../components/grid/draggable_scrollbar.dart | 36 +- .../grid/immich_asset_grid.state.dart | 16 +- .../grid/immich_asset_grid.widget.dart | 66 +- .../grid/immich_asset_grid_header.widget.dart | 4 +- .../grid/immich_asset_render_grid.widget.dart | 46 + .../components/image/cache/cache_manager.dart | 4 +- .../components/image/cache/image_loader.dart | 7 +- .../immich_cached_network_image.widget.dart | 17 + .../components/image/immich_image.widget.dart | 14 +- .../components/image/immich_logo.widget.dart | 15 +- .../image/immich_thumbnail.widget.dart | 2 +- .../provider/immich_local_image_provider.dart | 6 +- .../immich_local_thumbnail_provider.dart | 6 +- .../components/image/transparent_image.dart | 68 + .../input/password_form_field.widget.dart | 16 +- .../components/input/switch_list.widget.dart | 31 +- .../input/text_form_field.widget.dart | 8 +- .../adaptive_route_appbar.widget.dart | 48 +- .../adaptive_route_wrapper.widget.dart | 2 +- .../adaptive_scaffold_body.widget.dart | 10 +- .../modules/home/pages/home.page.dart | 55 +- .../modules/login/pages/login.page.dart | 24 +- .../login/states/login_page.state.dart | 2 +- .../login/widgets/login_form.widget.dart | 34 +- .../modules/logs/pages/log.page.dart | 12 - .../modules/logs/pages/logs.page.dart | 201 +++ .../models/settings_section.model.dart | 6 +- .../settings/pages/about_settings.page.dart | 4 +- .../settings/pages/advance_settings.page.dart | 2 +- .../settings/pages/general_settings.page.dart | 2 +- .../modules/settings/pages/settings.page.dart | 15 +- .../router/pages/splash_screen.page.dart | 5 +- .../router/pages/tab_controller.page.dart | 101 +- mobile-v2/lib/presentation/router/router.dart | 7 +- .../presentation/states/app_theme.state.dart | 2 +- .../states/gallery_permission.state.dart | 3 +- .../lib/presentation/theme/app_colors.dart | 6 +- .../lib/presentation/theme/app_theme.dart | 112 +- .../presentation/theme/app_typography.dart | 10 +- mobile-v2/lib/service_locator.dart | 16 +- mobile-v2/lib/utils/collection_util.dart | 42 +- .../lib/utils/constants/size_constants.dart | 12 +- .../utils/extensions/iterable.extension.dart | 2 +- mobile-v2/lib/utils/immich_api_client.dart | 10 +- .../lib/utils/immich_image_url_helper.dart | 7 +- mobile-v2/lib/utils/isolate_helper.dart | 8 +- mobile-v2/lib/utils/log_manager.dart | 18 +- mobile-v2/lib/utils/openapi_patching.dart | 4 +- mobile-v2/lib/utils/snackbar_manager.dart | 2 +- mobile-v2/pubspec.lock | 8 + mobile-v2/pubspec.yaml | 1 + mobile/lib/pages/common/app_log.page.dart | 2 +- 90 files changed, 2386 insertions(+), 584 deletions(-) create mode 100644 mobile-v2/lib/presentation/components/common/immich_navigation_rail.dart create mode 100644 mobile-v2/lib/presentation/components/common/skeletonized_future_builder.widget.dart create mode 100644 mobile-v2/lib/presentation/components/common/user_avatar.widget.dart create mode 100644 mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart create mode 100644 mobile-v2/lib/presentation/components/image/immich_cached_network_image.widget.dart create mode 100644 mobile-v2/lib/presentation/components/image/transparent_image.dart delete mode 100644 mobile-v2/lib/presentation/modules/logs/pages/log.page.dart create mode 100644 mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart diff --git a/mobile-v2/analysis_options.yaml b/mobile-v2/analysis_options.yaml index 2a922cbbf1..fb84821f73 100644 --- a/mobile-v2/analysis_options.yaml +++ b/mobile-v2/analysis_options.yaml @@ -18,51 +18,68 @@ dart_code_metrics: - recommended rules: # Common - - avoid-accessing-collections-by-constant-index + - arguments-ordering: + last: + - child - avoid-accessing-other-classes-private-members - - avoid-cascade-after-if-null + - avoid-assigning-to-static-field + - avoid-assignments-as-conditions + - avoid-async-call-in-sync-function - avoid-collapsible-if - - avoid-collection-methods-with-unrelated-types - - avoid-double-slash-imports - - avoid-duplicate-cascades - - avoid-duplicate-patterns - - avoid-generics-shadowing + - avoid-collection-equality-checks + - avoid-collection-mutating-methods + - avoid-complex-loop-conditions + - avoid-declaring-call-method + - avoid-extensions-on-records + - avoid-function-type-in-records - avoid-global-state + - avoid-inverted-boolean-checks + - avoid-late-final-reassignment + - avoid-local-functions + - avoid-negated-conditions + - avoid-nested-streams-and-futures + - avoid-referencing-subclasses + - binary-expression-operand-order + - move-variable-outside-iteration + - prefer-abstract-final-static-class + - prefer-early-return + - prefer-first + - prefer-immediate-return + - prefer-last + - prefer-simpler-boolean-expressions + - prefer-type-over-var + - use-existing-variable # Flutter - - always-remove-listener - avoid-border-all - - avoid-empty-setstate - avoid-expanded-as-spacer - - avoid-incomplete-copy-with - avoid-inherited-widget-in-initstate - avoid-late-context - - avoid-recursive-widget-calls - avoid-returning-widgets - avoid-shrink-wrap-in-lists - avoid-single-child-column-or-row - - avoid-state-constructors - avoid-stateless-widget-initialized-fields - - avoid-unnecessary-overrides-in-state - - avoid-unnecessary-stateful-widgets - avoid-wrapping-in-padding - - dispose-fields - prefer-const-border-radius + - prefer-correct-callback-field-name: false - prefer-correct-edge-insets-constructor - - prefer-dedicated-media-query-methods - prefer-define-hero-tag - prefer-extracting-callbacks + - prefer-for-loop-in-children + - prefer-match-file-name: false + - prefer-single-widget-per-file: + ignore-private-widgets: true + exclude: + - lib/presentation/**/*.page.dart - prefer-sliver-prefix - prefer-text-rich - prefer-using-list-view - - proper-super-calls - - use-setstate-synchronously - - prefer-match-file-name: false - - avoid-passing-self-as-argument: - exclude: - - lib/domain/repositories/** - - prefer-single-widget-per-file: - ignore-private-widgets: true - - prefer-correct-callback-field-name: false + - prefer-widget-private-members: + ignore-static: true + # get-it + - avoid-functions-in-register-singleton + # bloc + - avoid-empty-build-when + - avoid-passing-bloc-to-bloc custom_lint: rules: diff --git a/mobile-v2/assets/i18n/strings.i18n.json b/mobile-v2/assets/i18n/strings.i18n.json index 08c7883cb8..09358cfc61 100644 --- a/mobile-v2/assets/i18n/strings.i18n.json +++ b/mobile-v2/assets/i18n/strings.i18n.json @@ -37,6 +37,10 @@ } }, "logs": { - "title": "Logs" + "title": "Logs", + "no_logs": "No logs available" + }, + "common": { + "loading": "Loading" } } \ No newline at end of file diff --git a/mobile-v2/ios/Podfile.lock b/mobile-v2/ios/Podfile.lock index a4bc96193b..10e5b36bf6 100644 --- a/mobile-v2/ios/Podfile.lock +++ b/mobile-v2/ios/Podfile.lock @@ -9,6 +9,8 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter - photo_manager (2.0.0): - Flutter - FlutterMacOS @@ -42,6 +44,7 @@ DEPENDENCIES: - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) - sqflite (from `.symlinks/plugins/sqflite/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) @@ -62,6 +65,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" sqflite: @@ -72,11 +77,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_web_auth_2: 051cf9f5dc366f31b5dcc4e2952c2b954767be8a - package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c + flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb diff --git a/mobile-v2/ios/Runner.xcodeproj/project.pbxproj b/mobile-v2/ios/Runner.xcodeproj/project.pbxproj index ffcf774081..8bf2cb7656 100644 --- a/mobile-v2/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile-v2/ios/Runner.xcodeproj/project.pbxproj @@ -193,6 +193,7 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 25B7EF9822CD7FF54B86E39D /* [CP] Embed Pods Frameworks */, + 4D9CA4C7FEB0AD3C30F4C600 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -297,6 +298,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 4D9CA4C7FEB0AD3C30F4C600 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/mobile-v2/lib/domain/interfaces/album.interface.dart b/mobile-v2/lib/domain/interfaces/album.interface.dart index cdd55d499a..fc6497c815 100644 --- a/mobile-v2/lib/domain/interfaces/album.interface.dart +++ b/mobile-v2/lib/domain/interfaces/album.interface.dart @@ -4,14 +4,14 @@ import 'package:immich_mobile/domain/models/album.model.dart'; abstract interface class IAlbumRepository { /// Inserts a new album into the DB or updates if existing and returns the updated data - FutureOr upsert(Album album); + Future upsert(Album album); /// Fetch all albums - FutureOr> getAll({bool localOnly, bool remoteOnly}); + Future> getAll({bool localOnly, bool remoteOnly}); /// Removes album with the given [id] - FutureOr deleteId(int id); + Future deleteId(int id); /// Removes all albums - FutureOr deleteAll(); + Future deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/album_asset.interface.dart b/mobile-v2/lib/domain/interfaces/album_asset.interface.dart index 6d8e31255b..edea058b5e 100644 --- a/mobile-v2/lib/domain/interfaces/album_asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/album_asset.interface.dart @@ -4,17 +4,17 @@ import 'package:immich_mobile/domain/models/asset.model.dart'; abstract interface class IAlbumToAssetRepository { /// Link a list of assetIds to the given albumId - FutureOr addAssetIds(int albumId, Iterable assetIds); + Future addAssetIds(int albumId, Iterable assetIds); /// Returns assets that are only part of the given album and nothing else - FutureOr> getAssetIdsOnlyInAlbum(int albumId); + Future> getAssetIdsOnlyInAlbum(int albumId); /// Returns the assets for the given [albumId] - FutureOr> getAssetsForAlbum(int albumId); + Future> getAssetsForAlbum(int albumId); /// Removes album with the given [albumId] - FutureOr deleteAlbumId(int albumId); + Future deleteAlbumId(int albumId); /// Removes all album to asset mappings - FutureOr deleteAll(); + Future deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/album_etag.interface.dart b/mobile-v2/lib/domain/interfaces/album_etag.interface.dart index 0e496158d7..1afbd7976c 100644 --- a/mobile-v2/lib/domain/interfaces/album_etag.interface.dart +++ b/mobile-v2/lib/domain/interfaces/album_etag.interface.dart @@ -4,11 +4,11 @@ import 'package:immich_mobile/domain/models/album_etag.model.dart'; abstract interface class IAlbumETagRepository { /// Inserts or updates the album etag for the given [albumId] - FutureOr upsert(AlbumETag albumETag); + Future upsert(AlbumETag albumETag); /// Fetches the album etag for the given [albumId] - FutureOr get(int albumId); + Future get(int albumId); /// Removes all album eTags - FutureOr deleteAll(); + Future deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/asset.interface.dart b/mobile-v2/lib/domain/interfaces/asset.interface.dart index fa43a3790b..1a2ad3b49b 100644 --- a/mobile-v2/lib/domain/interfaces/asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/asset.interface.dart @@ -4,23 +4,23 @@ import 'package:immich_mobile/domain/models/asset.model.dart'; abstract interface class IAssetRepository { /// Batch upsert asset - FutureOr upsertAll(Iterable assets); + Future upsertAll(Iterable assets); /// Removes assets with the [localIds] - FutureOr> getForLocalIds(Iterable localIds); + Future> getForLocalIds(Iterable localIds); /// Removes assets with the [remoteIds] - FutureOr> getForRemoteIds(Iterable remoteIds); + Future> getForRemoteIds(Iterable remoteIds); /// Get assets with the [hashes] - FutureOr> getForHashes(Iterable hashes); + Future> getForHashes(Iterable hashes); /// Fetch assets from the [offset] with the [limit] - FutureOr> getAll({int? offset, int? limit}); + Future> getAll({int? offset, int? limit}); /// Removes assets with the given [ids] - FutureOr deleteIds(Iterable ids); + Future deleteIds(Iterable ids); /// Removes all assets - FutureOr deleteAll(); + Future deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/device_album.interface.dart b/mobile-v2/lib/domain/interfaces/device_album.interface.dart index 0d54b5878e..f89e630e99 100644 --- a/mobile-v2/lib/domain/interfaces/device_album.interface.dart +++ b/mobile-v2/lib/domain/interfaces/device_album.interface.dart @@ -5,13 +5,13 @@ import 'package:immich_mobile/domain/models/asset.model.dart'; abstract interface class IDeviceAlbumRepository { /// Fetches all [Album] from device - FutureOr> getAll(); + Future> getAll(); /// Returns the number of asset in the album - FutureOr getAssetCount(String albumId); + Future getAssetCount(String albumId); /// Fetches assets belong to the albumId - FutureOr> getAssetsForAlbum( + Future> getAssetsForAlbum( String albumId, { int start = 0, int end = 0x7fffffffffffffff, diff --git a/mobile-v2/lib/domain/interfaces/device_asset.interface.dart b/mobile-v2/lib/domain/interfaces/device_asset.interface.dart index bce5c7e20f..9f1f44f3fe 100644 --- a/mobile-v2/lib/domain/interfaces/device_asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/device_asset.interface.dart @@ -8,10 +8,10 @@ import 'package:immich_mobile/utils/constants/globals.dart'; abstract interface class IDeviceAssetRepository { /// Fetches the [File] for the given [assetId] - FutureOr getOriginalFile(String assetId); + Future getOriginalFile(String assetId); /// Fetches the thumbnail for the given [assetId] - FutureOr getThumbnail( + Future getThumbnail( String assetId, { int width = kGridThumbnailSize, int height = kGridThumbnailSize, diff --git a/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart b/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart index 5fa7b083f5..3ff52177a6 100644 --- a/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart +++ b/mobile-v2/lib/domain/interfaces/device_asset_hash.interface.dart @@ -4,11 +4,11 @@ import 'package:immich_mobile/domain/models/device_asset_hash.model.dart'; abstract interface class IDeviceAssetToHashRepository { /// Add a new device asset to hash entry - FutureOr upsertAll(Iterable assetHash); + Future upsertAll(Iterable assetHash); // Gets the asset with the local ID from the device - FutureOr> getForIds(Iterable localIds); + Future> getForIds(Iterable localIds); /// Removes assets with the given [ids] - FutureOr deleteIds(Iterable ids); + Future deleteIds(Iterable ids); } diff --git a/mobile-v2/lib/domain/interfaces/log.interface.dart b/mobile-v2/lib/domain/interfaces/log.interface.dart index 82742a5bd2..1bb39a0feb 100644 --- a/mobile-v2/lib/domain/interfaces/log.interface.dart +++ b/mobile-v2/lib/domain/interfaces/log.interface.dart @@ -4,17 +4,17 @@ import 'package:immich_mobile/domain/models/log.model.dart'; abstract interface class ILogRepository { /// Inserts a new log into the DB - FutureOr create(LogMessage log); + Future create(LogMessage log); /// Bulk insert logs into DB - FutureOr createAll(Iterable log); + Future createAll(Iterable log); /// Fetches all logs - FutureOr> getAll(); + Future> getAll(); /// Clears all logs - FutureOr deleteAll(); + Future deleteAll(); /// Truncates the logs to the most recent [limit]. Defaults to recent 250 logs - FutureOr truncate({int limit = 250}); + Future truncate({int limit = 250}); } diff --git a/mobile-v2/lib/domain/interfaces/store.interface.dart b/mobile-v2/lib/domain/interfaces/store.interface.dart index 7408be68fc..698f65d52d 100644 --- a/mobile-v2/lib/domain/interfaces/store.interface.dart +++ b/mobile-v2/lib/domain/interfaces/store.interface.dart @@ -13,15 +13,15 @@ abstract class IStoreConverter { } abstract interface class IStoreRepository { - FutureOr upsert(StoreKey key, T value); + Future upsert(StoreKey key, T value); - FutureOr get(StoreKey key); + Future get(StoreKey key); - FutureOr tryGet(StoreKey key); + Future tryGet(StoreKey key); Stream watch(StoreKey key); - FutureOr delete(StoreKey key); + Future delete(StoreKey key); - FutureOr deleteAll(); + Future deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/user.interface.dart b/mobile-v2/lib/domain/interfaces/user.interface.dart index e051091c5c..60a8d8991d 100644 --- a/mobile-v2/lib/domain/interfaces/user.interface.dart +++ b/mobile-v2/lib/domain/interfaces/user.interface.dart @@ -4,11 +4,11 @@ import 'package:immich_mobile/domain/models/user.model.dart'; abstract interface class IUserRepository { /// Insert user - FutureOr upsert(User user); + Future upsert(User user); /// Fetches user - FutureOr getForId(String userId); + Future getForId(String userId); /// Removes all users - FutureOr deleteAll(); + Future deleteAll(); } diff --git a/mobile-v2/lib/domain/models/asset.model.dart b/mobile-v2/lib/domain/models/asset.model.dart index dbeed82c72..12739cd1c8 100644 --- a/mobile-v2/lib/domain/models/asset.model.dart +++ b/mobile-v2/lib/domain/models/asset.model.dart @@ -71,8 +71,8 @@ class Asset { createdTime: createdTime ?? this.createdTime, modifiedTime: modifiedTime ?? this.modifiedTime, duration: duration ?? this.duration, - localId: localId != null ? localId() : this.localId, - remoteId: remoteId != null ? remoteId() : this.remoteId, + localId: localId == null ? this.localId : localId(), + remoteId: remoteId == null ? this.remoteId : remoteId(), livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId, ); } @@ -89,20 +89,20 @@ class Asset { if (newAsset.modifiedTime.isAfter(existingAsset.modifiedTime)) { return newAsset.copyWith( id: newAsset.id ?? existingAsset.id, + height: newAsset.height ?? existingAsset.height, + width: newAsset.width ?? existingAsset.width, + createdTime: oldestCreationTime, localId: () => existingAsset.localId ?? newAsset.localId, remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, - width: newAsset.width ?? existingAsset.width, - height: newAsset.height ?? existingAsset.height, - createdTime: oldestCreationTime, ); } return existingAsset.copyWith( + height: existingAsset.height ?? newAsset.height, + width: existingAsset.width ?? newAsset.width, + createdTime: oldestCreationTime, localId: () => existingAsset.localId ?? newAsset.localId, remoteId: () => existingAsset.remoteId ?? newAsset.remoteId, - width: existingAsset.width ?? newAsset.width, - height: existingAsset.height ?? newAsset.height, - createdTime: oldestCreationTime, ); } diff --git a/mobile-v2/lib/domain/models/render_list.model.dart b/mobile-v2/lib/domain/models/render_list.model.dart index 82c911d7cb..361eb4a600 100644 --- a/mobile-v2/lib/domain/models/render_list.model.dart +++ b/mobile-v2/lib/domain/models/render_list.model.dart @@ -4,8 +4,9 @@ import 'package:immich_mobile/domain/models/render_list_element.model.dart'; class RenderList { final List elements; late final int totalCount; + final DateTime modifiedTime; - RenderList({required this.elements}) { + RenderList({required this.elements, required this.modifiedTime}) { final lastAssetElement = elements.whereType().lastOrNull; if (lastAssetElement == null) { @@ -16,6 +17,21 @@ class RenderList { } factory RenderList.empty() { - return RenderList(elements: []); + return RenderList(elements: [], modifiedTime: DateTime.now()); } + + @override + String toString() => + 'RenderList(totalCount: $totalCount, modifiedTime: $modifiedTime)'; + + @override + bool operator ==(covariant RenderList other) { + if (identical(this, other)) return true; + + return other.totalCount == totalCount && + other.modifiedTime.isAtSameMomentAs(modifiedTime); + } + + @override + int get hashCode => totalCount.hashCode ^ modifiedTime.hashCode; } diff --git a/mobile-v2/lib/domain/repositories/album.repository.dart b/mobile-v2/lib/domain/repositories/album.repository.dart index cf2eb4b5d8..6256a3f81a 100644 --- a/mobile-v2/lib/domain/repositories/album.repository.dart +++ b/mobile-v2/lib/domain/repositories/album.repository.dart @@ -13,7 +13,7 @@ class AlbumRepository with LogMixin implements IAlbumRepository { const AlbumRepository({required DriftDatabaseRepository db}) : _db = db; @override - FutureOr upsert(Album album) async { + Future upsert(Album album) async { try { final albumData = _toEntity(album); final data = await _db.album.insertReturningOrNull( @@ -30,7 +30,7 @@ class AlbumRepository with LogMixin implements IAlbumRepository { } @override - FutureOr> getAll({ + Future> getAll({ bool localOnly = false, bool remoteOnly = false, }) async { @@ -49,12 +49,12 @@ class AlbumRepository with LogMixin implements IAlbumRepository { } @override - FutureOr deleteId(int id) async { + Future deleteId(int id) async { await _db.album.deleteWhere((row) => row.id.equals(id)); } @override - FutureOr deleteAll() async { + Future deleteAll() async { await _db.album.deleteAll(); } } @@ -62,11 +62,11 @@ class AlbumRepository with LogMixin implements IAlbumRepository { AlbumCompanion _toEntity(Album album) { return AlbumCompanion.insert( id: Value.absentIfNull(album.id), - localId: Value(album.localId), - remoteId: Value(album.remoteId), name: album.name, modifiedTime: Value(album.modifiedTime), thumbnailAssetId: Value(album.thumbnailAssetId), + localId: Value(album.localId), + remoteId: Value(album.remoteId), ); } diff --git a/mobile-v2/lib/domain/repositories/album_asset.repository.dart b/mobile-v2/lib/domain/repositories/album_asset.repository.dart index 7253092d40..12f1d4b81b 100644 --- a/mobile-v2/lib/domain/repositories/album_asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/album_asset.repository.dart @@ -15,7 +15,7 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository { : _db = db; @override - FutureOr addAssetIds(int albumId, Iterable assetIds) async { + Future addAssetIds(int albumId, Iterable assetIds) async { try { await _db.albumToAsset.insertAll( assetIds.map( @@ -33,14 +33,14 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository { } @override - FutureOr> getAssetIdsOnlyInAlbum(int albumId) async { + Future> getAssetIdsOnlyInAlbum(int albumId) async { final assetId = _db.asset.id; final query = _db.asset.selectOnly() ..addColumns([assetId]) ..join([ innerJoin( _db.albumToAsset, - _db.albumToAsset.assetId.equalsExp(_db.asset.id) & + _db.albumToAsset.assetId.equalsExp(assetId) & _db.asset.remoteId.isNull(), useColumns: false, ), @@ -55,7 +55,7 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository { } @override - FutureOr> getAssetsForAlbum(int albumId) async { + Future> getAssetsForAlbum(int albumId) async { final query = _db.asset.select().join([ innerJoin( _db.albumToAsset, @@ -72,12 +72,12 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository { } @override - FutureOr deleteAlbumId(int albumId) async { + Future deleteAlbumId(int albumId) async { await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId)); } @override - FutureOr deleteAll() async { + Future deleteAll() async { await _db.albumToAsset.deleteAll(); } } diff --git a/mobile-v2/lib/domain/repositories/album_etag.repository.dart b/mobile-v2/lib/domain/repositories/album_etag.repository.dart index 511bdec5a4..746b62f306 100644 --- a/mobile-v2/lib/domain/repositories/album_etag.repository.dart +++ b/mobile-v2/lib/domain/repositories/album_etag.repository.dart @@ -13,7 +13,7 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository { const AlbumETagRepository({required DriftDatabaseRepository db}) : _db = db; @override - FutureOr upsert(AlbumETag albumETag) async { + Future upsert(AlbumETag albumETag) async { try { final entity = _toEntity(albumETag); await _db.albumETag.insertOne( @@ -28,14 +28,14 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository { } @override - FutureOr get(int albumId) async { + Future get(int albumId) async { final query = _db.albumETag.select() ..where((r) => r.albumId.equals(albumId)); return await query.map(_toModel).getSingleOrNull(); } @override - FutureOr deleteAll() async { + Future deleteAll() async { await _db.albumETag.deleteAll(); } } @@ -43,17 +43,17 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository { AlbumETagCompanion _toEntity(AlbumETag albumETag) { return AlbumETagCompanion.insert( id: Value.absentIfNull(albumETag.id), - modifiedTime: Value(albumETag.modifiedTime), albumId: albumETag.albumId, + modifiedTime: Value(albumETag.modifiedTime), assetCount: Value(albumETag.assetCount), ); } AlbumETag _toModel(AlbumETagData albumETag) { return AlbumETag( + id: albumETag.id, albumId: albumETag.albumId, assetCount: albumETag.assetCount, modifiedTime: albumETag.modifiedTime, - id: albumETag.id, ); } diff --git a/mobile-v2/lib/domain/repositories/api/sync_api.repository.dart b/mobile-v2/lib/domain/repositories/api/sync_api.repository.dart index 72fcb1ce46..becba6120f 100644 --- a/mobile-v2/lib/domain/repositories/api/sync_api.repository.dart +++ b/mobile-v2/lib/domain/repositories/api/sync_api.repository.dart @@ -32,16 +32,16 @@ class SyncApiRepository with LogMixin implements ISyncApiRepository { } Asset _fromAssetResponseDto(AssetResponseDto dto) => Asset( - remoteId: dto.id, - createdTime: dto.fileCreatedAt, - duration: dto.duration.tryParseInt() ?? 0, + name: dto.originalFileName, + hash: dto.checksum, height: dto.exifInfo?.exifImageHeight?.toInt(), width: dto.exifInfo?.exifImageWidth?.toInt(), - hash: dto.checksum, - name: dto.originalFileName, - livePhotoVideoId: dto.livePhotoVideoId, - modifiedTime: dto.fileModifiedAt, type: _toAssetType(dto.type), + createdTime: dto.fileCreatedAt, + modifiedTime: dto.fileModifiedAt, + duration: dto.duration.tryParseInt() ?? 0, + remoteId: dto.id, + livePhotoVideoId: dto.livePhotoVideoId, ); AssetType _toAssetType(AssetTypeEnum type) => switch (type) { diff --git a/mobile-v2/lib/domain/repositories/asset.repository.dart b/mobile-v2/lib/domain/repositories/asset.repository.dart index 73ca0a101a..fa2b4abe5c 100644 --- a/mobile-v2/lib/domain/repositories/asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/asset.repository.dart @@ -16,16 +16,16 @@ class AssetRepository with LogMixin implements IAssetRepository { @override Future upsertAll(Iterable assets) async { try { - await _db.batch((batch) { - final rows = assets.map(_toEntity); - for (final row in rows) { - batch.insert( - _db.asset, - row, - onConflict: DoUpdate((_) => row, target: [_db.asset.hash]), - ); - } - }); + await _db.txn(() async => await _db.batch((batch) { + final rows = assets.map(_toEntity); + for (final row in rows) { + batch.insert( + _db.asset, + row, + onConflict: DoUpdate((_) => row, target: [_db.asset.hash]), + ); + } + })); return true; } catch (e, s) { @@ -85,7 +85,7 @@ class AssetRepository with LogMixin implements IAssetRepository { } @override - FutureOr deleteIds(Iterable ids) async { + Future deleteIds(Iterable ids) async { await _db.asset.deleteWhere((row) => row.id.isIn(ids)); } } @@ -93,16 +93,16 @@ class AssetRepository with LogMixin implements IAssetRepository { AssetCompanion _toEntity(Asset asset) { return AssetCompanion.insert( id: Value.absentIfNull(asset.id), - localId: Value(asset.localId), - remoteId: Value(asset.remoteId), name: asset.name, hash: asset.hash, height: Value(asset.height), width: Value(asset.width), type: asset.type, createdTime: asset.createdTime, - duration: Value(asset.duration), modifiedTime: Value(asset.modifiedTime), + duration: Value(asset.duration), + localId: Value(asset.localId), + remoteId: Value(asset.remoteId), livePhotoVideoId: Value(asset.livePhotoVideoId), ); } diff --git a/mobile-v2/lib/domain/repositories/database.repository.dart b/mobile-v2/lib/domain/repositories/database.repository.dart index ec49d9ef56..146355aac1 100644 --- a/mobile-v2/lib/domain/repositories/database.repository.dart +++ b/mobile-v2/lib/domain/repositories/database.repository.dart @@ -44,6 +44,8 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) => m.createAll(), + // ignore: no-empty-block + onUpgrade: (m, from, to) async {}, beforeOpen: (details) async { if (kDebugMode) { await validateDatabaseSchema(); @@ -52,8 +54,6 @@ class DriftDatabaseRepository extends $DriftDatabaseRepository await customStatement('PRAGMA journal_mode = WAL'); await customStatement('PRAGMA foreign_keys = ON'); }, - // ignore: no-empty-block - onUpgrade: (m, from, to) async {}, ); @override diff --git a/mobile-v2/lib/domain/repositories/device_album.repository.dart b/mobile-v2/lib/domain/repositories/device_album.repository.dart index f9ecfd98ab..2c47f0b180 100644 --- a/mobile-v2/lib/domain/repositories/device_album.repository.dart +++ b/mobile-v2/lib/domain/repositories/device_album.repository.dart @@ -52,17 +52,17 @@ class DeviceAlbumRepository with LogMixin implements IDeviceAlbumRepository { return await AssetPathEntity.fromId( albumId, filterOption: FilterOptionGroup( - containsPathModified: true, - orders: orderByModificationDate - ? [const OrderOption(type: OrderOptionType.updateDate)] - : [], imageOption: const FilterOption(needTitle: true), videoOption: const FilterOption(needTitle: true), + containsPathModified: true, updateTimeCond: DateTimeCond( min: modifiedFrom ?? DateTime.utc(-271820), max: modifiedUntil ?? DateTime.utc(275760), ignore: modifiedFrom != null || modifiedUntil != null, ), + orders: orderByModificationDate + ? [const OrderOption(type: OrderOptionType.updateDate)] + : [], ), ); } diff --git a/mobile-v2/lib/domain/repositories/device_asset.repository.dart b/mobile-v2/lib/domain/repositories/device_asset.repository.dart index c609b89d20..fbdca5aee3 100644 --- a/mobile-v2/lib/domain/repositories/device_asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/device_asset.repository.dart @@ -16,25 +16,23 @@ class DeviceAssetRepository @override Future toAsset(ph.AssetEntity entity) async { - var asset = Asset( - hash: '', + return Asset( name: await entity.titleAsync, - type: _toAssetType(entity.type), - createdTime: entity.createDateTime, - modifiedTime: entity.modifiedDateTime, - duration: entity.duration, + hash: '', height: entity.height, width: entity.width, + type: _toAssetType(entity.type), + createdTime: entity.createDateTime.year == 1970 + ? entity.modifiedDateTime + : entity.createDateTime, + modifiedTime: entity.modifiedDateTime, + duration: entity.duration, localId: entity.id, ); - if (asset.createdTime.year == 1970) { - asset = asset.copyWith(createdTime: asset.modifiedTime); - } - return asset; } @override - FutureOr getOriginalFile(String localId) async { + Future getOriginalFile(String localId) async { try { final entity = await ph.AssetEntity.fromId(localId); if (entity == null) { @@ -49,7 +47,7 @@ class DeviceAssetRepository } @override - FutureOr getThumbnail( + Future getThumbnail( String assetId, { int width = kGridThumbnailSize, int height = kGridThumbnailSize, diff --git a/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart b/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart index 5d3c873016..627a1375e2 100644 --- a/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart +++ b/mobile-v2/lib/domain/repositories/device_asset_hash.repository.dart @@ -16,12 +16,13 @@ class DeviceAssetToHashRepository : _db = db; @override - FutureOr upsertAll(Iterable assetHash) async { + Future upsertAll(Iterable assetHash) async { try { - await _db.batch((batch) => batch.insertAllOnConflictUpdate( - _db.deviceAssetToHash, - assetHash.map(_toEntity), - )); + await _db.txn(() async => + await _db.batch((batch) => batch.insertAllOnConflictUpdate( + _db.deviceAssetToHash, + assetHash.map(_toEntity), + ))); return true; } catch (e, s) { @@ -38,7 +39,7 @@ class DeviceAssetToHashRepository } @override - FutureOr deleteIds(Iterable ids) async { + Future deleteIds(Iterable ids) async { await _db.deviceAssetToHash.deleteWhere((row) => row.id.isIn(ids)); } } diff --git a/mobile-v2/lib/domain/repositories/log.repository.dart b/mobile-v2/lib/domain/repositories/log.repository.dart index fdb26269d0..213ad374eb 100644 --- a/mobile-v2/lib/domain/repositories/log.repository.dart +++ b/mobile-v2/lib/domain/repositories/log.repository.dart @@ -14,7 +14,10 @@ class LogRepository implements ILogRepository { @override Future> getAll() async { - return await _db.managers.logs.map(_toModel).get(); + return await _db.managers.logs + .orderBy((o) => o.createdAt.desc()) + .map(_toModel) + .get(); } @override @@ -23,14 +26,14 @@ class LogRepository implements ILogRepository { if (totalCount > limit) { final rowsToDelete = totalCount - limit; await _db.managers.logs - .orderBy((o) => o.createdAt.desc()) + .orderBy((o) => o.createdAt.asc()) .limit(rowsToDelete) .delete(); } } @override - FutureOr create(LogMessage log) async { + Future create(LogMessage log) async { try { await _db.logs.insertOne(_toEntity(log)); return true; @@ -41,11 +44,11 @@ class LogRepository implements ILogRepository { } @override - FutureOr createAll(Iterable logs) async { + Future createAll(Iterable logs) async { try { - await _db.batch((b) { - b.insertAll(_db.logs, logs.map(_toEntity)); - }); + await _db.txn(() async => await _db.batch((b) { + b.insertAll(_db.logs, logs.map(_toEntity)); + })); return true; } catch (e) { debugPrint("Error while adding a log to the DB - $e"); @@ -54,7 +57,7 @@ class LogRepository implements ILogRepository { } @override - FutureOr deleteAll() async { + Future deleteAll() async { try { await _db.logs.deleteAll(); return true; @@ -70,8 +73,8 @@ LogsCompanion _toEntity(LogMessage log) { content: log.content, level: log.level, createdAt: Value(log.createdAt), - error: Value(log.error), logger: Value(log.logger), + error: Value(log.error), stack: Value(log.stack), ); } @@ -79,10 +82,10 @@ LogsCompanion _toEntity(LogMessage log) { LogMessage _toModel(Log log) { return LogMessage( content: log.content, - createdAt: log.createdAt, level: log.level, - error: log.error, + createdAt: log.createdAt, logger: log.logger, + error: log.error, stack: log.stack, ); } diff --git a/mobile-v2/lib/domain/repositories/renderlist.repository.dart b/mobile-v2/lib/domain/repositories/renderlist.repository.dart index f042a93ec6..3b901f012e 100644 --- a/mobile-v2/lib/domain/repositories/renderlist.repository.dart +++ b/mobile-v2/lib/domain/repositories/renderlist.repository.dart @@ -15,22 +15,30 @@ class RenderListRepository with LogMixin implements IRenderListRepository { Stream watchAll() { final assetCountExp = _db.asset.id.count(); final createdTimeExp = _db.asset.createdTime; - final monthYearExp = _db.asset.createdTime.strftime('%m-%Y'); + final modifiedTimeExp = _db.asset.modifiedTime.max(); + final monthYearExp = createdTimeExp.strftime('%m-%Y'); final query = _db.asset.selectOnly() - ..addColumns([assetCountExp, createdTimeExp]) + ..addColumns([assetCountExp, createdTimeExp, modifiedTimeExp]) ..groupBy([monthYearExp]) ..orderBy([OrderingTerm.desc(createdTimeExp)]); int lastAssetOffset = 0; + DateTime recentModifiedTime = DateTime(1); return query .expand((row) { final createdTime = row.read(createdTimeExp)!; final assetCount = row.read(assetCountExp)!; + final modifiedTime = row.read(modifiedTimeExp)!; final assetOffset = lastAssetOffset; lastAssetOffset += assetCount; + // Get the recent modifed time. This is used to prevent unnecessary grid updates + if (modifiedTime.isAfter(recentModifiedTime)) { + recentModifiedTime = modifiedTime; + } + return [ RenderListMonthHeaderElement(date: createdTime), RenderListAssetElement( @@ -44,7 +52,9 @@ class RenderListRepository with LogMixin implements IRenderListRepository { .map((elements) { // Resets the value in closure so the watch refresh will work properly lastAssetOffset = 0; - return RenderList(elements: elements); + final modified = recentModifiedTime; + recentModifiedTime = DateTime(1); + return RenderList(elements: elements, modifiedTime: modified); }); } } diff --git a/mobile-v2/lib/domain/repositories/store.repository.dart b/mobile-v2/lib/domain/repositories/store.repository.dart index f14648c55b..03e4edd4a1 100644 --- a/mobile-v2/lib/domain/repositories/store.repository.dart +++ b/mobile-v2/lib/domain/repositories/store.repository.dart @@ -13,7 +13,7 @@ class StoreRepository with LogMixin implements IStoreRepository { const StoreRepository({required DriftDatabaseRepository db}) : _db = db; @override - FutureOr tryGet(StoreKey key) async { + Future tryGet(StoreKey key) async { final storeData = await _db.managers.store .filter((s) => s.id.equals(key.id)) .getSingleOrNull(); @@ -21,7 +21,7 @@ class StoreRepository with LogMixin implements IStoreRepository { } @override - FutureOr get(StoreKey key) async { + Future get(StoreKey key) async { final value = await tryGet(key); if (value == null) { throw StoreKeyNotFoundException(key); @@ -30,16 +30,16 @@ class StoreRepository with LogMixin implements IStoreRepository { } @override - FutureOr upsert(StoreKey key, T value) async { + Future upsert(StoreKey key, T value) async { try { final storeValue = key.converter.toPrimitive(value); final intValue = (key.type == int) ? storeValue as int : null; final stringValue = (key.type == String) ? storeValue as String : null; - await _db.into(_db.store).insertOnConflictUpdate(StoreCompanion.insert( - id: Value(key.id), - intValue: Value(intValue), - stringValue: Value(stringValue), - )); + await _db.store.insertOnConflictUpdate(StoreCompanion.insert( + id: Value(key.id), + intValue: Value(intValue), + stringValue: Value(stringValue), + )); return true; } catch (e, s) { log.e("Cannot set store value - ${key.name}; id - ${key.id}", e, s); @@ -48,7 +48,7 @@ class StoreRepository with LogMixin implements IStoreRepository { } @override - FutureOr delete(StoreKey key) async { + Future delete(StoreKey key) async { await _db.managers.store.filter((s) => s.id.equals(key.id)).delete(); } @@ -61,11 +61,11 @@ class StoreRepository with LogMixin implements IStoreRepository { } @override - FutureOr deleteAll() async { + Future deleteAll() async { await _db.managers.store.delete(); } - FutureOr _getValueFromStoreData( + Future _getValueFromStoreData( StoreKey key, StoreData? data, ) async { diff --git a/mobile-v2/lib/domain/repositories/user.repository.dart b/mobile-v2/lib/domain/repositories/user.repository.dart index a12e73ca39..6a7bcff746 100644 --- a/mobile-v2/lib/domain/repositories/user.repository.dart +++ b/mobile-v2/lib/domain/repositories/user.repository.dart @@ -13,7 +13,7 @@ class UserRepository with LogMixin implements IUserRepository { const UserRepository({required DriftDatabaseRepository db}) : _db = db; @override - FutureOr getForId(String userId) async { + Future getForId(String userId) async { return await _db.managers.user .filter((f) => f.id.equals(userId)) .map(_toModel) @@ -21,23 +21,23 @@ class UserRepository with LogMixin implements IUserRepository { } @override - FutureOr upsert(User user) async { + Future upsert(User user) async { try { - await _db.into(_db.user).insertOnConflictUpdate( - UserCompanion.insert( - id: user.id, - name: user.name, - email: user.email, - profileImagePath: user.profileImagePath, - avatarColor: user.avatarColor, - inTimeline: Value(user.inTimeline), - isAdmin: Value(user.isAdmin), - memoryEnabled: Value(user.memoryEnabled), - quotaSizeInBytes: Value(user.quotaSizeInBytes), - quotaUsageInBytes: Value(user.quotaSizeInBytes), - updatedAt: Value(user.updatedAt), - ), - ); + await _db.user.insertOnConflictUpdate( + UserCompanion.insert( + id: user.id, + updatedAt: Value(user.updatedAt), + name: user.name, + email: user.email, + isAdmin: Value(user.isAdmin), + quotaSizeInBytes: Value(user.quotaSizeInBytes), + quotaUsageInBytes: Value(user.quotaSizeInBytes), + inTimeline: Value(user.inTimeline), + profileImagePath: user.profileImagePath, + memoryEnabled: Value(user.memoryEnabled), + avatarColor: user.avatarColor, + ), + ); return true; } catch (e, s) { log.e("Cannot insert User into table - $user", e, s); @@ -46,7 +46,7 @@ class UserRepository with LogMixin implements IUserRepository { } @override - FutureOr deleteAll() async { + Future deleteAll() async { await _db.user.deleteAll(); } } @@ -54,15 +54,15 @@ class UserRepository with LogMixin implements IUserRepository { User _toModel(UserData user) { return User( id: user.id, - email: user.email, - avatarColor: user.avatarColor, - inTimeline: user.inTimeline, - isAdmin: user.isAdmin, - memoryEnabled: user.memoryEnabled, + updatedAt: user.updatedAt, name: user.name, - profileImagePath: user.profileImagePath, + email: user.email, + isAdmin: user.isAdmin, quotaSizeInBytes: user.quotaSizeInBytes, quotaUsageInBytes: user.quotaUsageInBytes, - updatedAt: user.updatedAt, + inTimeline: user.inTimeline, + profileImagePath: user.profileImagePath, + memoryEnabled: user.memoryEnabled, + avatarColor: user.avatarColor, ); } diff --git a/mobile-v2/lib/domain/services/asset_sync.service.dart b/mobile-v2/lib/domain/services/asset_sync.service.dart index 44362c0629..98f21bf5b1 100644 --- a/mobile-v2/lib/domain/services/asset_sync.service.dart +++ b/mobile-v2/lib/domain/services/asset_sync.service.dart @@ -51,9 +51,9 @@ class AssetSyncService with LogMixin { ); final assetsFromServer = await syncApiRepo.getFullSyncForUser( + lastId: lastAssetId, limit: chunkSize, updatedUntil: updatedTill, - lastId: lastAssetId, userId: user.id, ); if (assetsFromServer == null) { @@ -92,8 +92,8 @@ class AssetSyncService with LogMixin { final (toAdd, toUpdate, toRemove) = await _diffAssets( newAssets, existingAssets, - compare: compare, isRemoteSync: isRemoteSync, + compare: compare, ); final assetsToAdd = toAdd.followedBy(toUpdate); @@ -111,7 +111,7 @@ class AssetSyncService with LogMixin { }) async { // fast paths for trivial cases: reduces memory usage during initial sync etc. if (newAssets.isEmpty && inDb.isEmpty) { - return const ([], [], []); + return ([], [], []); } else if (newAssets.isEmpty && isRemoteSync == null) { // remove all from database return (const [], const [], inDb); diff --git a/mobile-v2/lib/domain/services/hash.service.dart b/mobile-v2/lib/domain/services/hash.service.dart index c84c99640a..2fb85838c1 100644 --- a/mobile-v2/lib/domain/services/hash.service.dart +++ b/mobile-v2/lib/domain/services/hash.service.dart @@ -88,7 +88,8 @@ class HashService with LogMixin { } assert(hashesInDB.isEmpty, "All hashes should be processed at this point"); - _assetHashRepository.deleteIds(orphanedHashes.map((e) => e.id!).toList()); + await _assetHashRepository + .deleteIds(orphanedHashes.map((e) => e.id!).toList()); return hashedAssets; } @@ -103,21 +104,21 @@ class HashService with LogMixin { final hashedAssets = []; for (final (index, hash) in hashes.indexed) { - // ignore: avoid-unsafe-collection-methods - final asset = toBeHashed.elementAt(index).asset; - if (hash?.length == 20) { + final asset = toBeHashed.elementAtOrNull(index)?.asset; + if (asset != null && hash?.length == 20) { hashedAssets.add(asset.copyWith(hash: base64.encode(hash!))); } else { - log.w("Failed to hash file ${asset.localId ?? ''}, skipping"); + log.w("Failed to hash file ${asset?.localId ?? ''}, skipping"); } } // Store the cache for future retrieval - _assetHashRepository.upsertAll(hashedAssets.map((a) => DeviceAssetToHash( - localId: a.localId!, - hash: a.hash, - modifiedTime: a.modifiedTime, - ))); + await _assetHashRepository + .upsertAll(hashedAssets.map((a) => DeviceAssetToHash( + localId: a.localId!, + hash: a.hash, + modifiedTime: a.modifiedTime, + ))); log.v("Hashed ${hashedAssets.length}/${toBeHashed.length} assets"); return hashedAssets; @@ -127,8 +128,7 @@ class HashService with LogMixin { /// Files that could not be hashed will have a `null` value Future> _hashFiles(List paths) async { try { - final hashes = await _hostService.digestFiles(paths); - return hashes; + return await _hostService.digestFiles(paths); } catch (e, s) { log.e("Error occured while hashing assets", e, s); } diff --git a/mobile-v2/lib/domain/utils/drift_model_converters.dart b/mobile-v2/lib/domain/utils/drift_model_converters.dart index 3b9f9d08c5..a2702f78c6 100644 --- a/mobile-v2/lib/domain/utils/drift_model_converters.dart +++ b/mobile-v2/lib/domain/utils/drift_model_converters.dart @@ -1,21 +1,21 @@ import 'package:immich_mobile/domain/entities/asset.entity.drift.dart'; import 'package:immich_mobile/domain/models/asset.model.dart'; -class DriftModelConverters { +abstract final class DriftModelConverters { static Asset toAssetModel(AssetData asset) { return Asset( id: asset.id, - localId: asset.localId, - remoteId: asset.remoteId, name: asset.name, - type: asset.type, hash: asset.hash, - createdTime: asset.createdTime, - modifiedTime: asset.modifiedTime, height: asset.height, width: asset.width, - livePhotoVideoId: asset.livePhotoVideoId, + type: asset.type, + createdTime: asset.createdTime, + modifiedTime: asset.modifiedTime, duration: asset.duration, + localId: asset.localId, + remoteId: asset.remoteId, + livePhotoVideoId: asset.livePhotoVideoId, ); } } diff --git a/mobile-v2/lib/immich_app.dart b/mobile-v2/lib/immich_app.dart index bc538ec89b..863f3681c7 100644 --- a/mobile-v2/lib/immich_app.dart +++ b/mobile-v2/lib/immich_app.dart @@ -26,14 +26,14 @@ class _ImAppState extends State with WidgetsBindingObserver { builder: (_, appTheme, __) => _AppThemeBuilder( theme: appTheme, builder: (ctx, lightTheme, darkTheme) => MaterialApp.router( - debugShowCheckedModeBanner: false, - locale: TranslationProvider.of(ctx).flutterLocale, - supportedLocales: AppLocaleUtils.supportedLocales, - localizationsDelegates: GlobalMaterialLocalizations.delegates, + scaffoldMessengerKey: kScafMessengerKey, + routerConfig: di().config(), theme: lightTheme, darkTheme: darkTheme, - routerConfig: di().config(), - scaffoldMessengerKey: kScafMessengerKey, + locale: TranslationProvider.of(ctx).flutterLocale, + localizationsDelegates: GlobalMaterialLocalizations.delegates, + supportedLocales: AppLocaleUtils.supportedLocales, + debugShowCheckedModeBanner: false, ), ), ), diff --git a/mobile-v2/lib/main.dart b/mobile-v2/lib/main.dart index b4f5c64caf..57cb5a2d56 100644 --- a/mobile-v2/lib/main.dart +++ b/mobile-v2/lib/main.dart @@ -6,17 +6,17 @@ import 'package:immich_mobile/utils/log_manager.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -void main() { +void main() async { WidgetsFlutterBinding.ensureInitialized(); // DI Injection ServiceLocator.configureServices(); // Init logging - LogManager.I.init(); + await LogManager.I.init(); LogManager.setGlobalErrorCallbacks(); // Init localization LocaleSettings.useDeviceLocale(); // Clear photo_manager cache - PhotoManager.clearFileCache(); + await PhotoManager.clearFileCache(); runApp(const ImApp()); } diff --git a/mobile-v2/lib/platform/messages.dart b/mobile-v2/lib/platform/messages.dart index 8e55c1f929..953a1d4da6 100644 --- a/mobile-v2/lib/platform/messages.dart +++ b/mobile-v2/lib/platform/messages.dart @@ -3,12 +3,12 @@ import 'package:pigeon/pigeon.dart'; @ConfigurePigeon(PigeonOptions( dartOut: 'lib/platform/messages.g.dart', - dartOptions: DartOptions(), + swiftOut: 'ios/Runner/Platform/Messages.g.swift', + swiftOptions: SwiftOptions(), kotlinOut: 'android/app/src/main/kotlin/com/alextran/immich/platform/Messages.g.kt', kotlinOptions: KotlinOptions(), - swiftOut: 'ios/Runner/Platform/Messages.g.swift', - swiftOptions: SwiftOptions(), + dartOptions: DartOptions(), )) @HostApi() abstract class ImHostService { diff --git a/mobile-v2/lib/presentation/components/common/immich_navigation_rail.dart b/mobile-v2/lib/presentation/components/common/immich_navigation_rail.dart new file mode 100644 index 0000000000..f6cc7fdf02 --- /dev/null +++ b/mobile-v2/lib/presentation/components/common/immich_navigation_rail.dart @@ -0,0 +1,1172 @@ +// ignore_for_file: type=lint. + +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +const double _kCircularIndicatorDiameter = 56; +const double _kIndicatorHeight = 32; + +/// A Material Design widget that is meant to be displayed at the left or right of an +/// app to navigate between a small number of views, typically between three and +/// five. +/// +/// {@youtube 560 315 https://www.youtube.com/watch?v=y9xchtVTtqQ} +/// +/// The navigation rail is meant for layouts with wide viewports, such as a +/// desktop web or tablet landscape layout. For smaller layouts, like mobile +/// portrait, a [BottomNavigationBar] should be used instead. +/// +/// A navigation rail is usually used as the first or last element of a [Row] +/// which defines the app's [Scaffold] body. +/// +/// The appearance of all of the [NavigationRail]s within an app can be +/// specified with [NavigationRailTheme]. The default values for null theme +/// properties are based on the [Theme]'s [ThemeData.textTheme], +/// [ThemeData.iconTheme], and [ThemeData.colorScheme]. +/// +/// Adaptive layouts can build different instances of the [Scaffold] in order to +/// have a navigation rail for more horizontal layouts and a bottom navigation +/// bar for more vertical layouts. See +/// [the adaptive_scaffold.dart sample](https://github.com/flutter/samples/blob/main/experimental/web_dashboard/lib/src/widgets/third_party/adaptive_scaffold.dart) +/// for an example. +/// +/// {@tool dartpad} +/// This example shows a [NavigationRail] used within a Scaffold with 3 +/// [NavigationRailDestination]s. The main content is separated by a divider +/// (although elevation on the navigation rail can be used instead). The +/// `_selectedIndex` is updated by the `onDestinationSelected` callback. +/// +/// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This sample shows the creation of [NavigationRail] widget used within a Scaffold with 3 +/// [NavigationRailDestination]s, as described in: https://m3.material.io/components/navigation-rail/overview +/// +/// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [Scaffold], which can display the navigation rail within a [Row] of the +/// [Scaffold.body] slot. +/// * [NavigationRailDestination], which is used as a model to create tappable +/// destinations in the navigation rail. +/// * [BottomNavigationBar], which is a similar navigation widget that's laid +/// out horizontally. +/// * +/// * +class ImNavigationRail extends StatefulWidget { + /// Creates a Material Design navigation rail. + /// + /// The value of [destinations] must be a list of two or more + /// [NavigationRailDestination] values. + /// + /// If [elevation] is specified, it must be non-negative. + /// + /// If [minWidth] is specified, it must be non-negative, and if + /// [minExtendedWidth] is specified, it must be non-negative and greater than + /// [minWidth]. + /// + /// The [extended] argument can only be set to true when the [labelType] is + /// null or [NavigationRailLabelType.none]. + /// + /// If [backgroundColor], [elevation], [groupAlignment], [labelType], + /// [unselectedLabelTextStyle], [selectedLabelTextStyle], + /// [unselectedIconTheme], or [selectedIconTheme] are null, then their + /// [NavigationRailThemeData] values will be used. If the corresponding + /// [NavigationRailThemeData] property is null, then the navigation rail + /// defaults are used. See the individual properties for more information. + /// + /// Typically used within a [Row] that defines the [Scaffold.body] property. + const ImNavigationRail({ + super.key, + this.backgroundColor, + this.extended = false, + this.leading, + this.trailing, + required this.destinations, + required this.selectedIndex, + this.onDestinationSelected, + this.elevation, + this.groupAlignment, + this.labelType, + this.unselectedLabelTextStyle, + this.selectedLabelTextStyle, + this.unselectedIconTheme, + this.selectedIconTheme, + this.minWidth, + this.minExtendedWidth, + this.useIndicator, + this.indicatorColor, + this.indicatorShape, + }) : assert(destinations.length >= 2), + assert(selectedIndex == null || + (0 <= selectedIndex && selectedIndex < destinations.length)), + assert(elevation == null || elevation > 0), + assert(minWidth == null || minWidth > 0), + assert(minExtendedWidth == null || minExtendedWidth > 0), + assert((minWidth == null || minExtendedWidth == null) || + minExtendedWidth >= minWidth), + assert(!extended || + (labelType == null || labelType == NavigationRailLabelType.none)); + + /// Sets the color of the Container that holds all of the [NavigationRail]'s + /// contents. + /// + /// The default value is [NavigationRailThemeData.backgroundColor]. If + /// [NavigationRailThemeData.backgroundColor] is null, then the default value + /// is based on [ColorScheme.surface] of [ThemeData.colorScheme]. + final Color? backgroundColor; + + /// Indicates that the [NavigationRail] should be in the extended state. + /// + /// The extended state has a wider rail container, and the labels are + /// positioned next to the icons. [minExtendedWidth] can be used to set the + /// minimum width of the rail when it is in this state. + /// + /// The rail will implicitly animate between the extended and normal state. + /// + /// If the rail is going to be in the extended state, then the [labelType] + /// must be set to [NavigationRailLabelType.none]. + /// + /// The default value is false. + final bool extended; + + /// The leading widget in the rail that is placed above the destinations. + /// + /// It is placed at the top of the rail, above the [destinations]. Its + /// location is not affected by [groupAlignment]. + /// + /// This is commonly a [FloatingActionButton], but may also be a non-button, + /// such as a logo. + /// + /// The default value is null. + final Widget? leading; + + /// The trailing widget in the rail that is placed below the destinations. + /// + /// The trailing widget is placed below the last [NavigationRailDestination]. + /// It's location is affected by [groupAlignment]. + /// + /// This is commonly a list of additional options or destinations that is + /// usually only rendered when [extended] is true. + /// + /// The default value is null. + final Widget? trailing; + + /// Defines the appearance of the button items that are arrayed within the + /// navigation rail. + /// + /// The value must be a list of two or more [NavigationRailDestination] + /// values. + final List destinations; + + /// The index into [destinations] for the current selected + /// [NavigationRailDestination] or null if no destination is selected. + final int? selectedIndex; + + /// Called when one of the [destinations] is selected. + /// + /// The stateful widget that creates the navigation rail needs to keep + /// track of the index of the selected [NavigationRailDestination] and call + /// `setState` to rebuild the navigation rail with the new [selectedIndex]. + final ValueChanged? onDestinationSelected; + + /// The rail's elevation or z-coordinate. + /// + /// If [Directionality] is [intl.TextDirection.LTR], the inner side is the + /// right side, and if [Directionality] is [intl.TextDirection.RTL], it is + /// the left side. + /// + /// The default value is 0. + final double? elevation; + + /// The vertical alignment for the group of [destinations] within the rail. + /// + /// The [NavigationRailDestination]s are grouped together with the [trailing] + /// widget, between the [leading] widget and the bottom of the rail. + /// + /// The value must be between -1.0 and 1.0. + /// + /// If [groupAlignment] is -1.0, then the items are aligned to the top. If + /// [groupAlignment] is 0.0, then the items are aligned to the center. If + /// [groupAlignment] is 1.0, then the items are aligned to the bottom. + /// + /// The default is -1.0. + /// + /// See also: + /// * [Alignment.y] + /// + final double? groupAlignment; + + /// Defines the layout and behavior of the labels for the default, unextended + /// [NavigationRail]. + /// + /// When a navigation rail is [extended], the labels are always shown. + /// + /// The default value is [NavigationRailThemeData.labelType]. If + /// [NavigationRailThemeData.labelType] is null, then the default value is + /// [NavigationRailLabelType.none]. + /// + /// See also: + /// + /// * [NavigationRailLabelType] for information on the meaning of different + /// types. + final NavigationRailLabelType? labelType; + + /// The [TextStyle] of a destination's label when it is unselected. + /// + /// When one of the [destinations] is selected the [selectedLabelTextStyle] + /// will be used instead. + /// + /// The default value is based on the [Theme]'s [TextTheme.bodyLarge]. The + /// default color is based on the [Theme]'s [ColorScheme.onSurface]. + /// + /// Properties from this text style, or + /// [NavigationRailThemeData.unselectedLabelTextStyle] if this is null, are + /// merged into the defaults. + final TextStyle? unselectedLabelTextStyle; + + /// The [TextStyle] of a destination's label when it is selected. + /// + /// When a [NavigationRailDestination] is not selected, + /// [unselectedLabelTextStyle] will be used. + /// + /// The default value is based on the [TextTheme.bodyLarge] of + /// [ThemeData.textTheme]. The default color is based on the [Theme]'s + /// [ColorScheme.primary]. + /// + /// Properties from this text style, + /// or [NavigationRailThemeData.selectedLabelTextStyle] if this is null, are + /// merged into the defaults. + final TextStyle? selectedLabelTextStyle; + + /// The visual properties of the icon in the unselected destination. + /// + /// If this field is not provided, or provided with any null properties, then + /// a copy of the [IconThemeData.fallback] with a custom [NavigationRail] + /// specific color will be used. + /// + /// The default value is the [Theme]'s [ThemeData.iconTheme] with a color + /// of the [Theme]'s [ColorScheme.onSurface] with an opacity of 0.64. + /// Properties from this icon theme, or + /// [NavigationRailThemeData.unselectedIconTheme] if this is null, are + /// merged into the defaults. + final IconThemeData? unselectedIconTheme; + + /// The visual properties of the icon in the selected destination. + /// + /// When a [NavigationRailDestination] is not selected, + /// [unselectedIconTheme] will be used. + /// + /// The default value is the [Theme]'s [ThemeData.iconTheme] with a color + /// of the [Theme]'s [ColorScheme.primary]. Properties from this icon theme, + /// or [NavigationRailThemeData.selectedIconTheme] if this is null, are + /// merged into the defaults. + final IconThemeData? selectedIconTheme; + + /// The smallest possible width for the rail regardless of the destination's + /// icon or label size. + /// + /// The default is 72. + /// + /// This value also defines the min width and min height of the destinations. + /// + /// To make a compact rail, set this to 56 and use + /// [NavigationRailLabelType.none]. + final double? minWidth; + + /// The final width when the animation is complete for setting [extended] to + /// true. + /// + /// This is only used when [extended] is set to true. + /// + /// The default value is 256. + final double? minExtendedWidth; + + /// If `true`, adds a rounded [NavigationIndicator] behind the selected + /// destination's icon. + /// + /// The indicator's shape will be circular if [labelType] is + /// [NavigationRailLabelType.none], or a [StadiumBorder] if [labelType] is + /// [NavigationRailLabelType.all] or [NavigationRailLabelType.selected]. + /// + /// If `null`, defaults to [NavigationRailThemeData.useIndicator]. If that is + /// `null`, defaults to [ThemeData.useMaterial3]. + final bool? useIndicator; + + /// Overrides the default value of [NavigationRail]'s selection indicator color, + /// when [useIndicator] is true. + /// + /// If this is null, [NavigationRailThemeData.indicatorColor] is used. If + /// that is null, defaults to [ColorScheme.secondaryContainer]. + final Color? indicatorColor; + + /// Overrides the default value of [NavigationRail]'s selection indicator shape, + /// when [useIndicator] is true. + /// + /// If this is null, [NavigationRailThemeData.indicatorShape] is used. If + /// that is null, defaults to [StadiumBorder]. + final ShapeBorder? indicatorShape; + + /// Returns the animation that controls the [NavigationRail.extended] state. + /// + /// This can be used to synchronize animations in the [leading] or [trailing] + /// widget, such as an animated menu or a [FloatingActionButton] animation. + /// + /// {@tool dartpad} + /// This example shows how to use this animation to create a [FloatingActionButton] + /// that animates itself between the normal and extended states of the + /// [NavigationRail]. + /// + /// An instance of `MyNavigationRailFab` is created for [NavigationRail.leading]. + /// Pressing the FAB button toggles the "extended" state of the [NavigationRail]. + /// + /// ** See code in examples/api/lib/material/navigation_rail/navigation_rail.extended_animation.0.dart ** + /// {@end-tool} + static Animation extendedAnimation(BuildContext context) { + return context + .dependOnInheritedWidgetOfExactType<_ExtendedNavigationRailAnimation>()! + .animation; + } + + @override + State createState() => _ImNavigationRailState(); +} + +class _ImNavigationRailState extends State + with TickerProviderStateMixin { + late List _destinationControllers; + late List> _destinationAnimations; + late AnimationController _extendedController; + late CurvedAnimation _extendedAnimation; + + @override + void initState() { + super.initState(); + _initControllers(); + } + + @override + void dispose() { + _disposeControllers(); + super.dispose(); + } + + @override + void didUpdateWidget(ImNavigationRail oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.extended != oldWidget.extended) { + if (widget.extended) { + _extendedController.forward(); + } else { + _extendedController.reverse(); + } + } + + // No animated segue if the length of the items list changes. + if (widget.destinations.length != oldWidget.destinations.length) { + _resetState(); + return; + } + + if (widget.selectedIndex != oldWidget.selectedIndex) { + if (oldWidget.selectedIndex != null) { + _destinationControllers[oldWidget.selectedIndex!].reverse(); + } + if (widget.selectedIndex != null) { + _destinationControllers[widget.selectedIndex!].forward(); + } + } + } + + @override + Widget build(BuildContext context) { + final NavigationRailThemeData navigationRailTheme = + NavigationRailTheme.of(context); + final NavigationRailThemeData defaults = Theme.of(context).useMaterial3 + ? _NavigationRailDefaultsM3(context) + : _NavigationRailDefaultsM2(context); + final MaterialLocalizations localizations = + MaterialLocalizations.of(context); + + final Color backgroundColor = widget.backgroundColor ?? + navigationRailTheme.backgroundColor ?? + defaults.backgroundColor!; + final double elevation = widget.elevation ?? + navigationRailTheme.elevation ?? + defaults.elevation!; + final double minWidth = + widget.minWidth ?? navigationRailTheme.minWidth ?? defaults.minWidth!; + final double minExtendedWidth = widget.minExtendedWidth ?? + navigationRailTheme.minExtendedWidth ?? + defaults.minExtendedWidth!; + final TextStyle unselectedLabelTextStyle = + widget.unselectedLabelTextStyle ?? + navigationRailTheme.unselectedLabelTextStyle ?? + defaults.unselectedLabelTextStyle!; + final TextStyle selectedLabelTextStyle = widget.selectedLabelTextStyle ?? + navigationRailTheme.selectedLabelTextStyle ?? + defaults.selectedLabelTextStyle!; + final IconThemeData unselectedIconTheme = widget.unselectedIconTheme ?? + navigationRailTheme.unselectedIconTheme ?? + defaults.unselectedIconTheme!; + final IconThemeData selectedIconTheme = widget.selectedIconTheme ?? + navigationRailTheme.selectedIconTheme ?? + defaults.selectedIconTheme!; + final double groupAlignment = widget.groupAlignment ?? + navigationRailTheme.groupAlignment ?? + defaults.groupAlignment!; + final NavigationRailLabelType labelType = widget.labelType ?? + navigationRailTheme.labelType ?? + defaults.labelType!; + final bool useIndicator = widget.useIndicator ?? + navigationRailTheme.useIndicator ?? + defaults.useIndicator!; + final Color? indicatorColor = widget.indicatorColor ?? + navigationRailTheme.indicatorColor ?? + defaults.indicatorColor; + final ShapeBorder? indicatorShape = widget.indicatorShape ?? + navigationRailTheme.indicatorShape ?? + defaults.indicatorShape; + + // For backwards compatibility, in M2 the opacity of the unselected icons needs + // to be set to the default if it isn't in the given theme. This can be removed + // when Material 3 is the default. + final IconThemeData effectiveUnselectedIconTheme = + Theme.of(context).useMaterial3 + ? unselectedIconTheme + : unselectedIconTheme.copyWith( + opacity: unselectedIconTheme.opacity ?? + defaults.unselectedIconTheme!.opacity, + ); + + final bool isRTLDirection = Directionality.of(context) == TextDirection.rtl; + + return _ExtendedNavigationRailAnimation( + animation: _extendedAnimation, + child: Semantics( + explicitChildNodes: true, + child: Material( + elevation: elevation, + color: backgroundColor, + child: SafeArea( + left: !isRTLDirection, + right: isRTLDirection, + child: Column( + children: [ + SizedBox(height: 8.0), + if (widget.leading != null) ...[ + widget.leading!, + _verticalSpacer + ], + Expanded( + child: Align( + alignment: Alignment(0, groupAlignment), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + for (int i = 0; i < widget.destinations.length; i += 1) + _RailDestination( + minWidth: minWidth, + minExtendedWidth: minExtendedWidth, + icon: widget.selectedIndex == i + ? widget.destinations[i].selectedIcon + : widget.destinations[i].icon, + label: widget.destinations[i].label, + destinationAnimation: _destinationAnimations[i], + extendedTransitionAnimation: _extendedAnimation, + labelType: labelType, + selected: widget.selectedIndex == i, + iconTheme: widget.selectedIndex == i + ? selectedIconTheme + : effectiveUnselectedIconTheme, + labelTextStyle: widget.selectedIndex == i + ? selectedLabelTextStyle + : unselectedLabelTextStyle, + onTap: () { + if (widget.onDestinationSelected != null) { + widget.onDestinationSelected!(i); + } + }, + indexLabel: localizations.tabLabel( + tabIndex: i + 1, + tabCount: widget.destinations.length, + ), + padding: widget.destinations[i].padding, + useIndicator: useIndicator, + indicatorColor: + useIndicator ? indicatorColor : null, + indicatorShape: + useIndicator ? indicatorShape : null, + disabled: widget.destinations[i].disabled, + ), + ], + ), + ), + ), + if (widget.trailing != null) ...[ + widget.trailing!, + _verticalSpacer, + ], + ], + ), + ), + ), + ), + ); + } + + void _disposeControllers() { + for (final AnimationController controller in _destinationControllers) { + controller.dispose(); + } + _extendedController.dispose(); + _extendedAnimation.dispose(); + } + + void _initControllers() { + _destinationControllers = List.generate( + widget.destinations.length, + (int index) { + return AnimationController( + duration: kThemeAnimationDuration, + vsync: this, + )..addListener(_rebuild); + }, + ); + _destinationAnimations = _destinationControllers + .map((AnimationController controller) => controller.view) + .toList(); + if (widget.selectedIndex != null) { + _destinationControllers[widget.selectedIndex!].value = 1.0; + } + _extendedController = AnimationController( + value: widget.extended ? 1.0 : 0.0, + duration: kThemeAnimationDuration, + vsync: this, + ); + _extendedAnimation = CurvedAnimation( + parent: _extendedController, + curve: Curves.easeInOut, + ); + _extendedController.addListener(() { + _rebuild(); + }); + } + + void _resetState() { + _disposeControllers(); + _initControllers(); + } + + void _rebuild() { + setState(() { + // Rebuilding when any of the controllers tick, i.e. when the items are + // animating. + }); + } +} + +class _RailDestination extends StatefulWidget { + const _RailDestination({ + required this.minWidth, + required this.minExtendedWidth, + required this.icon, + required this.label, + required this.destinationAnimation, + required this.extendedTransitionAnimation, + required this.labelType, + required this.selected, + required this.iconTheme, + required this.labelTextStyle, + required this.onTap, + required this.indexLabel, + this.padding, + required this.useIndicator, + this.indicatorColor, + this.indicatorShape, + this.disabled = false, + }); + + final double minWidth; + final double minExtendedWidth; + final Widget icon; + final Widget label; + final Animation destinationAnimation; + final NavigationRailLabelType labelType; + final bool selected; + final Animation extendedTransitionAnimation; + final IconThemeData iconTheme; + final TextStyle labelTextStyle; + final VoidCallback onTap; + final String indexLabel; + final EdgeInsetsGeometry? padding; + final bool useIndicator; + final Color? indicatorColor; + final ShapeBorder? indicatorShape; + final bool disabled; + + @override + State<_RailDestination> createState() => _RailDestinationState(); +} + +class _RailDestinationState extends State<_RailDestination> { + late CurvedAnimation _positionAnimation; + + @override + void initState() { + super.initState(); + _setPositionAnimation(); + } + + @override + void didUpdateWidget(_RailDestination oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.destinationAnimation != oldWidget.destinationAnimation) { + _positionAnimation.dispose(); + _setPositionAnimation(); + } + } + + void _setPositionAnimation() { + _positionAnimation = CurvedAnimation( + parent: ReverseAnimation(widget.destinationAnimation), + curve: Curves.easeInOut, + reverseCurve: Curves.easeInOut.flipped, + ); + } + + @override + void dispose() { + _positionAnimation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + assert( + widget.useIndicator || widget.indicatorColor == null, + '[NavigationRail.indicatorColor] does not have an effect when [NavigationRail.useIndicator] is false', + ); + + final ThemeData theme = Theme.of(context); + final TextDirection textDirection = Directionality.of(context); + final bool material3 = theme.useMaterial3; + final EdgeInsets destinationPadding = + (widget.padding ?? EdgeInsets.zero).resolve(textDirection); + Offset indicatorOffset; + bool applyXOffset = false; + + final Widget themedIcon = IconTheme( + data: widget.disabled + ? widget.iconTheme + .copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) + : widget.iconTheme, + child: widget.icon, + ); + final Widget styledLabel = DefaultTextStyle( + style: widget.disabled + ? widget.labelTextStyle + .copyWith(color: theme.colorScheme.onSurface.withOpacity(0.38)) + : widget.labelTextStyle, + child: widget.label, + ); + + Widget content; + + // The indicator height is fixed and equal to _kIndicatorHeight. + // When the icon height is larger than the indicator height the indicator + // vertical offset is used to vertically center the indicator. + final bool isLargeIconSize = widget.iconTheme.size != null && + widget.iconTheme.size! > _kIndicatorHeight; + final double indicatorVerticalOffset = + isLargeIconSize ? (widget.iconTheme.size! - _kIndicatorHeight) / 2 : 0; + + switch (widget.labelType) { + case NavigationRailLabelType.none: + // Split the destination spacing across the top and bottom to keep the icon centered. + final Widget? spacing = material3 + ? const SizedBox(height: _verticalDestinationSpacingM3 / 2) + : null; + indicatorOffset = Offset( + widget.minWidth / 2 + destinationPadding.left, + _verticalDestinationSpacingM3 / 2 + + destinationPadding.top + + indicatorVerticalOffset, + ); + final Widget iconPart = Column( + children: [ + if (spacing != null) spacing, + SizedBox( + width: widget.minWidth, + height: material3 ? null : widget.minWidth, + child: Center( + child: _AddIndicator( + addIndicator: widget.useIndicator, + isCircular: !material3, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, + indicatorAnimation: widget.destinationAnimation, + child: themedIcon, + ), + ), + ), + if (spacing != null) spacing, + ], + ); + if (widget.extendedTransitionAnimation.value == 0) { + content = Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: Stack( + children: [ + iconPart, + // For semantics when label is not showing, + SizedBox.shrink( + child: Visibility.maintain( + visible: false, + child: widget.label, + ), + ), + ], + ), + ); + } else { + final Animation labelFadeAnimation = widget + .extendedTransitionAnimation + .drive(CurveTween(curve: const Interval(0.0, 0.25))); + applyXOffset = true; + content = Padding( + padding: widget.padding ?? EdgeInsets.zero, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: lerpDouble( + widget.minWidth, + widget.minExtendedWidth, + widget.extendedTransitionAnimation.value, + )!, + ), + child: ClipRect( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + iconPart, + Flexible( + child: Align( + alignment: AlignmentDirectional.centerStart, + widthFactor: widget.extendedTransitionAnimation.value, + heightFactor: 1.0, + child: FadeTransition( + opacity: labelFadeAnimation, + alwaysIncludeSemantics: true, + child: styledLabel, + ), + ), + ), + SizedBox( + width: _horizontalDestinationPadding * + widget.extendedTransitionAnimation.value, + ), + ], + ), + ), + ), + ); + } + case NavigationRailLabelType.selected: + final double appearingAnimationValue = 1 - _positionAnimation.value; + final double verticalPadding = lerpDouble( + _verticalDestinationPaddingNoLabel, + _verticalDestinationPaddingWithLabel, + appearingAnimationValue, + )!; + final Interval interval = widget.selected + ? const Interval(0.25, 0.75) + : const Interval(0.75, 1.0); + final Animation labelFadeAnimation = + widget.destinationAnimation.drive(CurveTween(curve: interval)); + final double minHeight = material3 ? 0 : widget.minWidth; + final Widget topSpacing = + SizedBox(height: material3 ? 0 : verticalPadding); + final Widget labelSpacing = SizedBox( + height: material3 + ? lerpDouble( + 0, + _verticalIconLabelSpacingM3, + appearingAnimationValue, + )! + : 0, + ); + final Widget bottomSpacing = SizedBox( + height: material3 ? _verticalDestinationSpacingM3 : verticalPadding, + ); + final double indicatorHorizontalPadding = + (destinationPadding.left / 2) - (destinationPadding.right / 2); + final double indicatorVerticalPadding = destinationPadding.top; + indicatorOffset = Offset( + widget.minWidth / 2 + indicatorHorizontalPadding, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + if (widget.minWidth < _NavigationRailDefaultsM2(context).minWidth!) { + indicatorOffset = Offset( + widget.minWidth / 2 + _horizontalDestinationSpacingM3, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + } + content = Container( + padding: widget.padding ?? + const EdgeInsets.symmetric( + horizontal: _horizontalDestinationPadding, + ), + constraints: BoxConstraints( + minWidth: widget.minWidth, + minHeight: minHeight, + ), + child: ClipRect( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + topSpacing, + _AddIndicator( + addIndicator: widget.useIndicator, + isCircular: false, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, + indicatorAnimation: widget.destinationAnimation, + child: themedIcon, + ), + labelSpacing, + Align( + alignment: Alignment.topCenter, + widthFactor: 1.0, + heightFactor: appearingAnimationValue, + child: FadeTransition( + opacity: labelFadeAnimation, + alwaysIncludeSemantics: true, + child: styledLabel, + ), + ), + bottomSpacing, + ], + ), + ), + ); + case NavigationRailLabelType.all: + final double minHeight = material3 ? 0 : widget.minWidth; + final Widget topSpacing = SizedBox( + height: material3 ? 0 : _verticalDestinationPaddingWithLabel, + ); + final Widget labelSpacing = + SizedBox(height: material3 ? _verticalIconLabelSpacingM3 : 0); + final Widget bottomSpacing = SizedBox( + height: material3 + ? _verticalDestinationSpacingM3 + : _verticalDestinationPaddingWithLabel, + ); + final double indicatorHorizontalPadding = + (destinationPadding.left / 2) - (destinationPadding.right / 2); + final double indicatorVerticalPadding = destinationPadding.top; + indicatorOffset = Offset( + widget.minWidth / 2 + indicatorHorizontalPadding, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + if (widget.minWidth < _NavigationRailDefaultsM2(context).minWidth!) { + indicatorOffset = Offset( + widget.minWidth / 2 + _horizontalDestinationSpacingM3, + indicatorVerticalPadding + indicatorVerticalOffset, + ); + } + content = Container( + padding: widget.padding ?? + const EdgeInsets.symmetric( + horizontal: _horizontalDestinationPadding, + ), + constraints: BoxConstraints( + minWidth: widget.minWidth, + minHeight: minHeight, + ), + child: Column( + children: [ + topSpacing, + _AddIndicator( + addIndicator: widget.useIndicator, + isCircular: false, + indicatorColor: widget.indicatorColor, + indicatorShape: widget.indicatorShape, + indicatorAnimation: widget.destinationAnimation, + child: themedIcon, + ), + labelSpacing, + styledLabel, + bottomSpacing, + ], + ), + ); + } + + final ColorScheme colors = theme.colorScheme; + final bool primaryColorAlphaModified = colors.primary.alpha < 255.0; + final Color effectiveSplashColor = primaryColorAlphaModified + ? colors.primary + : colors.primary.withOpacity(0.12); + final Color effectiveHoverColor = primaryColorAlphaModified + ? colors.primary + : colors.primary.withOpacity(0.04); + return Semantics( + container: true, + selected: widget.selected, + child: Stack( + children: [ + Material( + type: MaterialType.transparency, + child: _IndicatorInkWell( + onTap: widget.disabled ? null : widget.onTap, + customBorder: widget.indicatorShape, + borderRadius: + BorderRadius.all(Radius.circular(widget.minWidth / 2.0)), + splashColor: effectiveSplashColor, + hoverColor: effectiveHoverColor, + useMaterial3: material3, + indicatorOffset: indicatorOffset, + applyXOffset: applyXOffset, + textDirection: textDirection, + child: content, + ), + ), + Semantics(label: widget.indexLabel), + ], + ), + ); + } +} + +class _IndicatorInkWell extends InkResponse { + const _IndicatorInkWell({ + super.child, + super.onTap, + ShapeBorder? customBorder, + BorderRadius? borderRadius, + super.splashColor, + super.hoverColor, + required this.useMaterial3, + required this.indicatorOffset, + required this.applyXOffset, + required this.textDirection, + }) : super( + containedInkWell: true, + highlightShape: BoxShape.rectangle, + borderRadius: useMaterial3 ? null : borderRadius, + customBorder: useMaterial3 ? customBorder : null, + ); + + final bool useMaterial3; + + // The offset used to position Ink highlight. + final Offset indicatorOffset; + + // Whether the horizontal offset from indicatorOffset should be used to position Ink highlight. + // If true, Ink highlight uses the indicator horizontal offset. If false, Ink highlight is centered horizontally. + final bool applyXOffset; + + // The text direction used to adjust the indicator horizontal offset. + final TextDirection textDirection; + + @override + RectCallback? getRectCallback(RenderBox referenceBox) { + if (useMaterial3) { + final double boxWidth = referenceBox.size.width; + double indicatorHorizontalCenter = + applyXOffset ? indicatorOffset.dx : boxWidth / 2; + if (textDirection == TextDirection.rtl) { + indicatorHorizontalCenter = boxWidth - indicatorHorizontalCenter; + } + return () { + return Rect.fromLTWH( + indicatorHorizontalCenter - (_kCircularIndicatorDiameter / 2), + indicatorOffset.dy, + _kCircularIndicatorDiameter, + _kIndicatorHeight, + ); + }; + } + return null; + } +} + +/// When [addIndicator] is `true`, puts [child] center aligned in a [Stack] with +/// a [NavigationIndicator] behind it, otherwise returns [child]. +/// +/// When [isCircular] is true, the indicator will be a circle, otherwise the +/// indicator will be a stadium shape. +class _AddIndicator extends StatelessWidget { + const _AddIndicator({ + required this.addIndicator, + required this.isCircular, + required this.indicatorColor, + required this.indicatorShape, + required this.indicatorAnimation, + required this.child, + }); + + final bool addIndicator; + final bool isCircular; + final Color? indicatorColor; + final ShapeBorder? indicatorShape; + final Animation indicatorAnimation; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!addIndicator) { + return child; + } + final Widget indicator; + if (isCircular) { + indicator = NavigationIndicator( + animation: indicatorAnimation, + color: indicatorColor, + width: _kCircularIndicatorDiameter, + height: _kCircularIndicatorDiameter, + borderRadius: BorderRadius.circular(_kCircularIndicatorDiameter / 2), + ); + } else { + indicator = NavigationIndicator( + animation: indicatorAnimation, + color: indicatorColor, + width: _kCircularIndicatorDiameter, + shape: indicatorShape, + ); + } + + return Stack(alignment: Alignment.center, children: [indicator, child]); + } +} + +class _ExtendedNavigationRailAnimation extends InheritedWidget { + const _ExtendedNavigationRailAnimation({ + required this.animation, + required super.child, + }); + + final Animation animation; + + @override + bool updateShouldNotify(_ExtendedNavigationRailAnimation old) => + animation != old.animation; +} + +// There don't appear to be tokens for these values, but they are +// shown in the spec. +const double _horizontalDestinationPadding = 8.0; +const double _verticalDestinationPaddingNoLabel = 24.0; +const double _verticalDestinationPaddingWithLabel = 16.0; +const Widget _verticalSpacer = SizedBox(height: 20.0); +const double _verticalIconLabelSpacingM3 = 4.0; +const double _verticalDestinationSpacingM3 = 12.0; +const double _horizontalDestinationSpacingM3 = 12.0; + +// Hand coded defaults based on Material Design 2. +class _NavigationRailDefaultsM2 extends NavigationRailThemeData { + _NavigationRailDefaultsM2(BuildContext context) + : _theme = Theme.of(context), + _colors = Theme.of(context).colorScheme, + super( + elevation: 0, + groupAlignment: -1, + labelType: NavigationRailLabelType.none, + useIndicator: false, + minWidth: 72.0, + minExtendedWidth: 256, + ); + + final ThemeData _theme; + final ColorScheme _colors; + + @override + Color get backgroundColor => _colors.surface; + + @override + TextStyle get unselectedLabelTextStyle { + return _theme.textTheme.bodyLarge! + .copyWith(color: _colors.onSurface.withOpacity(0.64)); + } + + @override + TextStyle get selectedLabelTextStyle { + return _theme.textTheme.bodyLarge!.copyWith(color: _colors.primary); + } + + @override + IconThemeData get unselectedIconTheme { + return IconThemeData( + size: 24.0, + color: _colors.onSurface, + opacity: 0.64, + ); + } + + @override + IconThemeData get selectedIconTheme { + return IconThemeData(size: 24.0, color: _colors.primary, opacity: 1.0); + } +} + +// BEGIN GENERATED TOKEN PROPERTIES - NavigationRail + +// Do not edit by hand. The code between the "BEGIN GENERATED" and +// "END GENERATED" comments are generated from data in the Material +// Design token database by the script: +// dev/tools/gen_defaults/bin/gen_defaults.dart. + +class _NavigationRailDefaultsM3 extends NavigationRailThemeData { + _NavigationRailDefaultsM3(this.context) + : super( + elevation: 0.0, + groupAlignment: -1, + labelType: NavigationRailLabelType.none, + useIndicator: true, + minWidth: 80.0, + minExtendedWidth: 256, + ); + + final BuildContext context; + late final ColorScheme _colors = Theme.of(context).colorScheme; + late final TextTheme _textTheme = Theme.of(context).textTheme; + + @override + Color get backgroundColor => _colors.surface; + + @override + TextStyle get unselectedLabelTextStyle { + return _textTheme.labelMedium!.copyWith(color: _colors.onSurface); + } + + @override + TextStyle get selectedLabelTextStyle { + return _textTheme.labelMedium!.copyWith(color: _colors.onSurface); + } + + @override + IconThemeData get unselectedIconTheme { + return IconThemeData(size: 24.0, color: _colors.onSurfaceVariant); + } + + @override + IconThemeData get selectedIconTheme { + return IconThemeData(size: 24.0, color: _colors.onSecondaryContainer); + } + + @override + Color get indicatorColor => _colors.secondaryContainer; + + @override + ShapeBorder get indicatorShape => const StadiumBorder(); +} diff --git a/mobile-v2/lib/presentation/components/common/skeletonized_future_builder.widget.dart b/mobile-v2/lib/presentation/components/common/skeletonized_future_builder.widget.dart new file mode 100644 index 0000000000..b243ef4c67 --- /dev/null +++ b/mobile-v2/lib/presentation/components/common/skeletonized_future_builder.widget.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class SkeletonizedFutureBuilder extends StatelessWidget { + const SkeletonizedFutureBuilder({ + super.key, + required this.future, + required this.builder, + required this.loadingBuilder, + required this.errorBuilder, + this.emptyBuilder, + this.emptyWhen, + }) : assert( + (emptyBuilder == null && emptyWhen == null) || + (emptyBuilder != null && emptyWhen != null), + "Both emptyBuilder and emptyWhen should be provided"); + + /// Future to listen to + final Future future; + + /// Callback when data is available + final Widget Function(BuildContext context, T? snapshot) builder; + + /// Callback when future is loading. Expected a skeletonizer to be returned + final Widget Function(BuildContext context) loadingBuilder; + + /// Callback when future resulted in an error + final Widget Function(BuildContext context, Object? error) errorBuilder; + + /// Callback when data is available but is empty. Emptiness is determined based on the [emptyWhen] callback + final Widget Function(BuildContext context)? emptyBuilder; + + /// Predicate to call [emptyBuilder] when the [data] passes the filter + final bool Function(T? data)? emptyWhen; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: future, + builder: (ctx, snap) { + final Widget child; + if (snap.isWaiting) { + child = loadingBuilder(ctx); + } else { + if (snap.hasError) { + child = errorBuilder(ctx, snap.error); + } else { + final isEmpty = emptyWhen?.call(snap.data) ?? false; + child = isEmpty ? emptyBuilder!(ctx) : builder(ctx, snap.data); + } + } + + return Skeletonizer.zone( + enabled: snap.isWaiting, + enableSwitchAnimation: true, + child: child, + ); + }, + ); + } +} diff --git a/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart b/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart new file mode 100644 index 0000000000..a4fea77a79 --- /dev/null +++ b/mobile-v2/lib/presentation/components/common/user_avatar.widget.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; +import 'package:immich_mobile/presentation/components/image/immich_cached_network_image.widget.dart'; +import 'package:immich_mobile/presentation/components/image/transparent_image.dart'; +import 'package:immich_mobile/utils/immich_image_url_helper.dart'; + +class ImUserAvatar extends StatelessWidget { + final User user; + final double? dimension; + final double? radius; + + const ImUserAvatar({ + super.key, + required this.user, + this.dimension, + this.radius, + }); + + @override + Widget build(BuildContext context) { + bool isDarkTheme = Theme.of(context).brightness == Brightness.dark; + + final textIcon = Text( + user.name[0].toUpperCase(), + style: TextStyle( + color: isDarkTheme && user.avatarColor == UserAvatarColor.primary + ? Colors.black + : Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ); + + return CircleAvatar( + backgroundColor: user.avatarColor.toColor(), + radius: radius, + child: user.profileImagePath.isEmpty + ? textIcon + : ClipOval( + child: ImCachedNetworkImage( + imageUrl: ImImageUrlHelper.getUserAvatarUrl(user), + cacheKey: user.profileImagePath, + height: dimension, + width: dimension, + fit: BoxFit.cover, + placeholder: (_, __) => Image.memory( + kTransparentImage, + semanticLabel: 'Transparent', + ), + fadeInDuration: const Duration(milliseconds: 300), + errorWidget: (_, error, stackTrace) => SizedBox.square(), + ), + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart b/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart index be723903bd..c252ee0c96 100644 --- a/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart +++ b/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart @@ -98,11 +98,11 @@ class DraggableScrollbar extends StatefulWidget { required BoxConstraints? labelConstraints, required bool alwaysVisibleScrollThumb, }) { - var scrollThumbAndLabel = labelText == null + Widget scrollThumbAndLabel = labelText == null ? scrollThumb : Row( - mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, children: [ _ScrollLabel( animation: labelAnimation, @@ -145,8 +145,8 @@ class DraggableScrollbar extends StatefulWidget { color: backgroundColor, borderRadius: BorderRadius.only( topLeft: Radius.circular(height), - bottomLeft: Radius.circular(height), topRight: const Radius.circular(4.0), + bottomLeft: Radius.circular(height), bottomRight: const Radius.circular(4.0), ), child: Container( @@ -195,9 +195,9 @@ class _ScrollLabel extends StatelessWidget { color: backgroundColor, borderRadius: const BorderRadius.all(Radius.circular(16.0)), child: Container( - constraints: constraints ?? _defaultConstraints, - padding: const EdgeInsets.symmetric(horizontal: 15), alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 15), + constraints: constraints ?? _defaultConstraints, child: child, ), ), @@ -231,8 +231,8 @@ class _DraggableScrollbarState extends State _currentItem = 0; _thumbAnimationController = AnimationController( - vsync: this, duration: widget.scrollbarAnimationDuration, + vsync: this, ); _thumbAnimation = CurvedAnimation( @@ -241,8 +241,8 @@ class _DraggableScrollbarState extends State ); _labelAnimationController = AnimationController( - vsync: this, duration: widget.scrollbarAnimationDuration, + vsync: this, ); _labelAnimation = CurvedAnimation( @@ -291,16 +291,16 @@ class _DraggableScrollbarState extends State onVerticalDragEnd: _onVerticalDragEnd, child: Container( alignment: Alignment.topRight, - margin: EdgeInsets.only(top: _barOffset), padding: widget.padding, + margin: EdgeInsets.only(top: _barOffset), child: widget.scrollThumbBuilder( widget.backgroundColor, widget.foregroundColor, _thumbAnimation, _labelAnimation, widget.heightScrollThumb, - labelText: labelText, labelConstraints: widget.labelConstraints, + labelText: labelText, ), ), ), @@ -356,7 +356,7 @@ class _DraggableScrollbarState extends State _thumbAnimationController.forward(); } - final lastItemPos = itemPos; + final lastItemPos = _itemPos; if (lastItemPos < widget.maxItemCount) { _currentItem = lastItemPos; } @@ -378,7 +378,7 @@ class _DraggableScrollbarState extends State widget.scrollStateListener(true); } - int get itemIndex { + int get _itemIndex { int index = 0; double minDiff = 1000; for (final pos in _positions) { @@ -391,21 +391,21 @@ class _DraggableScrollbarState extends State return index; } - int get itemPos => + int get _itemPos => ((_barOffset / (_barMaxScrollExtent)) * widget.maxItemCount).toInt(); void _jumpToBarPos() { - final lastItemPos = itemPos; + final lastItemPos = _itemPos; if (lastItemPos > widget.maxItemCount - 1) { return; } - _currentItem = itemIndex; + _currentItem = _itemIndex; widget.controller.sliverController.jumpToIndex(lastItemPos); } Timer? _dragHaltTimer; - int lastTimerPos = 0; + int _lastTimerPos = 0; void _onVerticalDragUpdate(DragUpdateDetails details) { setState(() { @@ -418,9 +418,9 @@ class _DraggableScrollbarState extends State _barOffset = clampDouble(_barOffset, _barMinScrollExtent, _barMaxScrollExtent); - final lastItemPos = itemPos; - if (lastItemPos != lastTimerPos) { - lastTimerPos = lastItemPos; + final lastItemPos = _itemPos; + if (lastItemPos != _lastTimerPos) { + _lastTimerPos = lastItemPos; _dragHaltTimer?.cancel(); widget.scrollStateListener(true); diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart index 0504e1673b..d8f593da93 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart @@ -26,6 +26,17 @@ class AssetGridState { renderList: renderList ?? this.renderList, ); } + + @override + bool operator ==(covariant AssetGridState other) { + if (identical(this, other)) return true; + + return other.renderList == renderList && + other.isDragScrolling == isDragScrolling; + } + + @override + int get hashCode => renderList.hashCode ^ isDragScrolling.hashCode; } class AssetGridCubit extends Cubit { @@ -43,6 +54,9 @@ class AssetGridCubit extends Cubit { super(AssetGridState.empty()) { _renderListSubscription = _renderListProvider.renderStreamProvider().listen((renderList) { + if (renderList == state.renderList) { + return; + } _bufOffset = 0; _buf = []; emit(state.copyWith(renderList: renderList)); @@ -87,8 +101,8 @@ class AssetGridCubit extends Cubit { // load the calculated batch (start:start+len) from the DB and put it into the buffer _buf = await _renderListProvider.renderAssetProvider( - offset: start, limit: len, + offset: start, ); _bufOffset = start; diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart index 12f3dfe432..232b9862a1 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart @@ -13,6 +13,7 @@ import 'package:intl/intl.dart'; import 'package:material_symbols_icons/symbols.dart'; part 'immich_asset_grid_header.widget.dart'; +part 'immich_asset_render_grid.widget.dart'; class ImAssetGrid extends StatefulWidget { /// The padding for the grid @@ -76,7 +77,6 @@ class _ImAssetGridState extends State { } final grid = FlutterListView( - controller: _controller, delegate: FlutterListViewDelegate( (_, sectionIndex) { // ignore: avoid-unsafe-collection-methods @@ -89,70 +89,46 @@ class _ImAssetGridState extends State { RenderListMonthHeaderElement() => _MonthHeader(text: section.header), RenderListDayHeaderElement() => Text(section.header), - RenderListAssetElement() => FutureBuilder( - future: context.read().loadAssets( - section.assetOffset, - section.assetCount, - ), - builder: (_, assetsSnap) { - final assets = assetsSnap.data; - return GridView.builder( - physics: const NeverScrollableScrollPhysics(), - shrinkWrap: true, - addAutomaticKeepAlives: false, - cacheExtent: 100, - padding: const EdgeInsets.all(0), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 3, - crossAxisSpacing: 3, - ), - itemBuilder: (_, i) { - final asset = assetsSnap.isWaiting || assets == null - ? null - : assets.elementAtOrNull(i); - return SizedBox.square( - dimension: 200, - // Show Placeholder when drag scrolled - child: asset == null || state.isDragScrolling - ? const ImImagePlaceholder() - : ImThumbnail(asset), - ); - }, - itemCount: section.assetCount, - ); - }, + RenderListAssetElement() => _StaticGrid( + section: section, + isDragging: state.isDragScrolling, ), }; }, childCount: elements.length, addAutomaticKeepAlives: false, ), + controller: _controller, ); final EdgeInsetsGeometry? padding; - if (widget.topPadding != null) { - padding = EdgeInsets.only(top: widget.topPadding!); - } else { + if (widget.topPadding == null) { padding = null; + } else { + padding = EdgeInsets.only(top: widget.topPadding!); } return DraggableScrollbar( - foregroundColor: context.colorScheme.onSurface, - backgroundColor: context.colorScheme.surfaceContainerHighest, - scrollStateListener: - context.read().setDragScrolling, controller: _controller, maxItemCount: elements.length, + scrollStateListener: + context.read().setDragScrolling, + backgroundColor: context.colorScheme.surfaceContainerHighest, + foregroundColor: context.colorScheme.onSurface, + padding: padding, + scrollbarAnimationDuration: Durations.medium2, + scrollbarTimeToFade: Durations.extralong4, labelTextBuilder: (int position) => _labelBuilder(elements, position), labelConstraints: const BoxConstraints(maxHeight: 36), - scrollbarAnimationDuration: Durations.medium2, - scrollbarTimeToFade: Durations.extralong4, - padding: padding, child: grid, ); }, + // no.of elements are not equal or is modified + buildWhen: (previous, current) => + (previous.renderList.elements.length != + current.renderList.elements.length) || + !previous.renderList.modifiedTime + .isAtSameMomentAs(current.renderList.modifiedTime), ); } diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart index 5285b235b2..e6969f60e5 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart @@ -10,8 +10,8 @@ class _HeaderText extends StatelessWidget { Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only( - top: 32.0, left: 16.0, + top: 32.0, right: 24.0, bottom: 16.0, ), @@ -40,9 +40,9 @@ class _MonthHeader extends StatelessWidget { return _HeaderText( text: text, style: context.textTheme.bodyLarge?.copyWith( + color: context.colorScheme.onSurface, fontSize: 24.0, fontWeight: FontWeight.w500, - color: context.colorScheme.onSurface, ), ); } diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart new file mode 100644 index 0000000000..c2f1597ed8 --- /dev/null +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_render_grid.widget.dart @@ -0,0 +1,46 @@ +part of 'immich_asset_grid.widget.dart'; + +class _StaticGrid extends StatelessWidget { + final RenderListAssetElement section; + final bool isDragging; + + const _StaticGrid({required this.section, required this.isDragging}); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: context.read().loadAssets( + section.assetOffset, + section.assetCount, + ), + builder: (_, assetsSnap) { + final assets = assetsSnap.data; + return GridView.builder( + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + padding: const EdgeInsets.all(0), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 3, + crossAxisSpacing: 3, + ), + itemBuilder: (_, i) { + final asset = assetsSnap.isWaiting || assets == null + ? null + : assets.elementAtOrNull(i); + return SizedBox.square( + dimension: 200, + // Show Placeholder when drag scrolled + child: asset == null || isDragging + ? const ImImagePlaceholder() + : ImThumbnail(asset), + ); + }, + itemCount: section.assetCount, + addAutomaticKeepAlives: false, + cacheExtent: 100, + ); + }, + ); + } +} diff --git a/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart b/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart index b414344d5d..85ff4ec94c 100644 --- a/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart +++ b/mobile-v2/lib/presentation/components/image/cache/cache_manager.dart @@ -14,8 +14,8 @@ class ImRemoteThumbnailCacheManager extends CacheManager { : super( Config( kCacheThumbnailsKey, - maxNrOfCacheObjects: kCacheMaxNrOfThumbnails, stalePeriod: const Duration(days: kCacheStalePeriod), + maxNrOfCacheObjects: kCacheMaxNrOfThumbnails, ), ); } @@ -33,8 +33,8 @@ class ImRemoteImageCacheManager extends CacheManager { : super( Config( kCacheFullImagesKey, - maxNrOfCacheObjects: kCacheMaxNrOfFullImages, stalePeriod: const Duration(days: kCacheStalePeriod), + maxNrOfCacheObjects: kCacheMaxNrOfFullImages, ), ); } diff --git a/mobile-v2/lib/presentation/components/image/cache/image_loader.dart b/mobile-v2/lib/presentation/components/image/cache/image_loader.dart index 3720ad7338..8175bf4695 100644 --- a/mobile-v2/lib/presentation/components/image/cache/image_loader.dart +++ b/mobile-v2/lib/presentation/components/image/cache/image_loader.dart @@ -19,7 +19,7 @@ class ImageLoadingException implements Exception { /// /// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart) /// for this wonderful implementation of their image loader -class ImageLoader { +abstract final class ImageLoader { static Future loadImageFromCache( String uri, { required CacheManager cache, @@ -28,8 +28,8 @@ class ImageLoader { }) async { final stream = cache.getFileStream( uri, - withProgress: chunkEvents != null, headers: di().headers, + withProgress: chunkEvents != null, ); await for (final result in stream) { @@ -44,8 +44,7 @@ class ImageLoader { } else if (result is FileInfo) { // We have the file final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path); - final decoded = await decode(buffer); - return decoded; + return await decode(buffer); } } diff --git a/mobile-v2/lib/presentation/components/image/immich_cached_network_image.widget.dart b/mobile-v2/lib/presentation/components/image/immich_cached_network_image.widget.dart new file mode 100644 index 0000000000..4c978905ac --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/immich_cached_network_image.widget.dart @@ -0,0 +1,17 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/immich_api_client.dart'; + +class ImCachedNetworkImage extends CachedNetworkImage { + ImCachedNetworkImage({ + super.key, + required super.imageUrl, + super.cacheKey, + super.height, + super.width, + super.fit, + super.placeholder, + super.fadeInDuration, + super.errorWidget, + }) : super(httpHeaders: di().headers); +} diff --git a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart index de17bb8f51..a1754d07c7 100644 --- a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart @@ -12,9 +12,9 @@ class ImImagePlaceholder extends StatelessWidget { @override Widget build(BuildContext context) { return Container( + color: context.colorScheme.surfaceContainerHighest, width: 200, height: 200, - color: context.colorScheme.surfaceContainerHighest, ); } } @@ -63,13 +63,8 @@ class ImImage extends StatelessWidget { @override Widget build(BuildContext context) { return OctoImage( - fadeInDuration: const Duration(milliseconds: 0), - fadeOutDuration: Durations.short4, - placeholderBuilder: (_) => placeholder, image: ImImage.imageProvider(asset: asset), - width: width, - height: height, - fit: BoxFit.cover, + placeholderBuilder: (_) => placeholder, errorBuilder: (_, error, stackTrace) { if (error is PlatformException && error.code == "The asset not found!") { @@ -86,6 +81,11 @@ class ImImage extends StatelessWidget { color: context.colorScheme.primary, ); }, + fadeOutDuration: Durations.short4, + fadeInDuration: Duration.zero, + width: width, + height: height, + fit: BoxFit.cover, ); } } diff --git a/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart b/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart index bff9baf4e1..dd106370ff 100644 --- a/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart @@ -4,13 +4,13 @@ import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; class ImLogo extends StatelessWidget { const ImLogo({ - this.width, + this.dimension, this.filterQuality = FilterQuality.high, super.key, }); - /// The width of the image. - final double? width; + /// The dimension of the image. + final double? dimension; /// The rendering quality final FilterQuality filterQuality; @@ -18,11 +18,12 @@ class ImLogo extends StatelessWidget { @override Widget build(BuildContext context) { return Image( - width: width, - filterQuality: filterQuality, - semanticLabel: 'Immich Logo', image: Assets.images.immichLogo.provider(), + semanticLabel: 'Immich Logo', + width: dimension, + height: dimension, isAntiAlias: true, + filterQuality: filterQuality, ); } } @@ -43,10 +44,10 @@ class ImLogoText extends StatelessWidget { @override Widget build(BuildContext context) { return Image( - semanticLabel: 'Immich Logo Text', image: (context.isDarkTheme ? Assets.images.immichTextDark.provider : Assets.images.immichTextLight.provider)(), + semanticLabel: 'Immich Logo Text', width: fontSize * 4, filterQuality: FilterQuality.high, ); diff --git a/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart b/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart index 93cc2c1a13..96c96d41b6 100644 --- a/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_thumbnail.widget.dart @@ -77,9 +77,9 @@ class _PadAlignedIcon extends StatelessWidget { alignment: alignment, child: Icon( icon, - color: Colors.white, size: 20, fill: (filled != null && filled!) ? 1 : null, + color: Colors.white, ), ), ); diff --git a/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart b/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart index 946321cc39..68a609ede8 100644 --- a/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart +++ b/mobile-v2/lib/presentation/components/image/provider/immich_local_image_provider.dart @@ -49,12 +49,12 @@ class ImLocalImageProvider extends ImageProvider { // Load a small thumbnail final thumbBytes = await di().getThumbnail(a.localId!); - if (thumbBytes != null) { + if (thumbBytes == null) { + debugPrint("Loading thumb for ${a.name} failed"); + } else { final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); final codec = await decode(buffer); yield codec; - } else { - debugPrint("Loading thumb for ${a.name} failed"); } if (asset.isImage) { diff --git a/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart b/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart index e602dcc485..6745724ee5 100644 --- a/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart +++ b/mobile-v2/lib/presentation/components/image/provider/immich_local_thumbnail_provider.dart @@ -56,12 +56,12 @@ class ImLocalThumbnailProvider extends ImageProvider { // Load a small thumbnail final thumbBytes = await di() .getThumbnail(a.localId!, width: 32, height: 32, quality: 75); - if (thumbBytes != null) { + if (thumbBytes == null) { + debugPrint("Loading thumb for ${a.name} failed"); + } else { final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes); final codec = await decode(buffer); yield codec; - } else { - debugPrint("Loading thumb for ${a.name} failed"); } final normalThumbBytes = await di() diff --git a/mobile-v2/lib/presentation/components/image/transparent_image.dart b/mobile-v2/lib/presentation/components/image/transparent_image.dart new file mode 100644 index 0000000000..0b599e004f --- /dev/null +++ b/mobile-v2/lib/presentation/components/image/transparent_image.dart @@ -0,0 +1,68 @@ +import 'dart:typed_data'; + +final Uint8List kTransparentImage = Uint8List.fromList([ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, +]); diff --git a/mobile-v2/lib/presentation/components/input/password_form_field.widget.dart b/mobile-v2/lib/presentation/components/input/password_form_field.widget.dart index ca32c9c3d4..83a86cbabb 100644 --- a/mobile-v2/lib/presentation/components/input/password_form_field.widget.dart +++ b/mobile-v2/lib/presentation/components/input/password_form_field.widget.dart @@ -40,33 +40,33 @@ class ImPasswordFormField extends StatefulWidget { } class _ImPasswordFormFieldState extends State { - final showPassword = ValueNotifier(false); + final _showPassword = ValueNotifier(false); @override void dispose() { - showPassword.dispose(); + _showPassword.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: showPassword, + valueListenable: _showPassword, builder: (_, showPass, child) => ImTextFormField( controller: widget.controller, + focusNode: widget.focusNode, onChanged: widget.onChanged, shouldObscure: !showPass, - hint: widget.hint, - label: widget.label, - focusNode: widget.focusNode, suffixIcon: IconButton( - onPressed: () => showPassword.value = !showPassword.value, + onPressed: () => _showPassword.value = !_showPassword.value, icon: Icon( - showPassword.value + _showPassword.value ? Symbols.visibility_off_rounded : Symbols.visibility_rounded, ), ), + label: widget.label, + hint: widget.hint, autoFillHints: const [AutofillHints.password], keyboardType: TextInputType.visiblePassword, textInputAction: widget.textInputAction, diff --git a/mobile-v2/lib/presentation/components/input/switch_list.widget.dart b/mobile-v2/lib/presentation/components/input/switch_list.widget.dart index a686f8fd1b..dcd2bc8aa9 100644 --- a/mobile-v2/lib/presentation/components/input/switch_list.widget.dart +++ b/mobile-v2/lib/presentation/components/input/switch_list.widget.dart @@ -29,18 +29,27 @@ class ImSwitchListTile extends StatefulWidget { class _ImSwitchListTileState extends State> { // Actual switch list state - late bool isEnabled; + late bool _isEnabled; final AppSettingService _appSettingService = di(); - Future set(bool enabled) async { - if (isEnabled == enabled) return; + Future _set(bool enabled) async { + if (_isEnabled == enabled) return; - final value = T != bool ? widget.toAppSetting!(enabled) : enabled as T; + final value = T == bool ? enabled as T : widget.toAppSetting!(enabled); if (value != null && await _appSettingService.upsert(widget.setting, value) && context.mounted) { setState(() { - isEnabled = enabled; + _isEnabled = enabled; + }); + } + } + + Future _initSetting() async { + final value = await _appSettingService.get(widget.setting); + if (context.mounted) { + setState(() { + _isEnabled = T == bool ? value as bool : widget.fromAppSetting!(value); }); } } @@ -48,20 +57,14 @@ class _ImSwitchListTileState extends State> { @override void initState() { super.initState(); - _appSettingService.get(widget.setting).then((value) { - if (context.mounted) { - setState(() { - isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool; - }); - } - }); + _initSetting().ignore(); } @override Widget build(BuildContext context) { return SwitchListTile( - value: isEnabled, - onChanged: (value) => set(value), + value: _isEnabled, + onChanged: (value) => unawaited(_set(value)), ); } } diff --git a/mobile-v2/lib/presentation/components/input/text_form_field.widget.dart b/mobile-v2/lib/presentation/components/input/text_form_field.widget.dart index 039ad08015..8db449d0b6 100644 --- a/mobile-v2/lib/presentation/components/input/text_form_field.widget.dart +++ b/mobile-v2/lib/presentation/components/input/text_form_field.widget.dart @@ -66,21 +66,21 @@ class ImTextFormField extends StatelessWidget { Widget build(BuildContext context) { return TextFormField( controller: controller, - onChanged: onChanged, focusNode: focusNode, - obscureText: shouldObscure, - validator: validator, decoration: InputDecoration( labelText: label, hintText: hint, suffixIcon: suffixIcon, ), - autofillHints: autoFillHints, keyboardType: keyboardType, textInputAction: textInputAction, readOnly: isDisabled, + obscureText: shouldObscure, + onChanged: onChanged, onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(), onFieldSubmitted: onSubmitted, + validator: validator, + autofillHints: autoFillHints, ); } } diff --git a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart index 9186cc3209..4754a1a7f1 100644 --- a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart +++ b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart @@ -1,36 +1,44 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; -class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget +class ImAdaptiveRouteAppBar extends StatelessWidget implements PreferredSizeWidget { - const ImAdaptiveRoutePrimaryAppBar({super.key}); + final String? title; + final bool isPrimary; - @override - Widget build(BuildContext context) { - return AppBar( - leading: BackButton(onPressed: () => context.router.root.maybePop()), - ); - } + /// Passed to [AppBar] actions + final List? actions; + + const ImAdaptiveRouteAppBar({ + super.key, + this.title, + this.isPrimary = true, + this.actions, + }); @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} - -// ignore: prefer-single-widget-per-file -class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget - implements PreferredSizeWidget { - const ImAdaptiveRouteSecondaryAppBar({super.key}); @override Widget build(BuildContext context) { + final Widget leading; + if (isPrimary) { + leading = BackButton( + onPressed: () => unawaited(context.router.root.maybePop()), + ); + } else { + leading = context.isTablet + ? CloseButton(onPressed: () => unawaited(context.maybePop())) + : BackButton(onPressed: () => unawaited(context.maybePop())); + } + return AppBar( - leading: context.isTablet - ? CloseButton(onPressed: () => context.maybePop()) - : BackButton(onPressed: () => context.maybePop()), + leading: leading, + title: title == null ? null : Text(title!), + actions: actions, ); } - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); } diff --git a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_wrapper.widget.dart b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_wrapper.widget.dart index 2c594cdaf0..7b4ed85597 100644 --- a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_wrapper.widget.dart +++ b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_wrapper.widget.dart @@ -27,7 +27,7 @@ class ImAdaptiveRouteWrapper extends StatelessWidget { return ImAdaptiveScaffoldBody( primaryBody: primaryBody, secondaryBody: - ctx.topRoute.name != primaryRoute ? (_) => child : null, + ctx.topRoute.name == primaryRoute ? null : (_) => child, bodyRatio: bodyRatio, ); } diff --git a/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart b/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart index bdf87d27bd..0f755fe072 100644 --- a/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart +++ b/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart @@ -21,14 +21,11 @@ class ImAdaptiveScaffoldBody extends StatelessWidget { @override Widget build(BuildContext context) { return AdaptiveLayout( - internalAnimations: false, - transitionDuration: Durations.medium2, - bodyRatio: bodyRatio, body: SlotLayout( config: { Breakpoints.standard: SlotLayout.from( - key: const Key('ImAdaptiveScaffold Body Standard'), builder: primaryBody, + key: const Key('ImAdaptiveScaffold Body Standard'), ), }, ), @@ -37,11 +34,14 @@ class ImAdaptiveScaffoldBody extends StatelessWidget { /// No secondary body in mobile layouts Breakpoints.small: SlotLayoutConfig.empty(), Breakpoints.mediumAndUp: SlotLayout.from( - key: const Key('ImAdaptiveScaffold Secondary Body Medium'), builder: secondaryBody, + key: const Key('ImAdaptiveScaffold Secondary Body Medium'), ), }, ), + bodyRatio: bodyRatio, + transitionDuration: Durations.medium2, + internalAnimations: false, ); } } diff --git a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart index 4eafbf643d..e92c09eedc 100644 --- a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart +++ b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart @@ -26,6 +26,8 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { + final gridHasPadding = !context.isTablet && _showAppBar.value; + return Scaffold( body: BlocProvider( create: (_) => AssetGridCubit( @@ -33,32 +35,35 @@ class _HomePageState extends State { ), child: Stack(children: [ ImAssetGrid( - topPadding: kToolbarHeight + context.mediaQueryPadding.top - 8, - ), - ValueListenableBuilder( - valueListenable: _showAppBar, - builder: (_, shouldShow, appBar) { - final Duration duration; - if (shouldShow) { - // Animate out app bar slower - duration = Durations.short3; - } else { - // Animate in app bar faster - duration = Durations.medium2; - } - return AnimatedPositioned( - duration: duration, - curve: Curves.easeOut, - left: 0, - right: 0, - top: shouldShow - ? 0 - : -(kToolbarHeight + context.mediaQueryPadding.top), - child: appBar!, - ); - }, - child: const ImAppBar(), + topPadding: gridHasPadding + ? kToolbarHeight + context.mediaQueryPadding.top - 8 + : null, ), + if (!context.isTablet) + ValueListenableBuilder( + valueListenable: _showAppBar, + builder: (_, shouldShow, appBar) { + final Duration duration; + if (shouldShow) { + // Animate out app bar slower + duration = Durations.short3; + } else { + // Animate in app bar faster + duration = Durations.medium2; + } + return AnimatedPositioned( + left: 0, + top: shouldShow + ? 0 + : -(kToolbarHeight + context.mediaQueryPadding.top), + right: 0, + curve: Curves.easeOut, + duration: duration, + child: appBar!, + ); + }, + child: const ImAppBar(), + ), ]), ), ); diff --git a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart index df693170a9..3cd618851f 100644 --- a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart +++ b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -55,15 +57,15 @@ class _LoginPageState extends State void _onLoginPageStateChange(BuildContext context, LoginPageState state) { if (state.isLoginSuccessful) { - context.replaceRoute(const TabControllerRoute()); + unawaited(context.replaceRoute(const TabControllerRoute())); } } @override Widget build(BuildContext context) { final PreferredSizeWidget? appBar; - late final Widget primaryBody; - late final Widget secondaryBody; + final Widget primaryBody; + final Widget secondaryBody; Widget rotatingLogo = GestureDetector( onDoubleTap: _populateDemoCredentials, @@ -73,7 +75,7 @@ class _LoginPageState extends State children: [ RotationTransition( turns: _animationController, - child: const ImLogo(width: 100), + child: const ImLogo(dimension: 100), ), const SizedGap.lh(), const ImLogoText(), @@ -104,7 +106,7 @@ class _LoginPageState extends State ), ), TextButton( - onPressed: () => context.navigateRoot(const LogsRoute()), + onPressed: () => unawaited(context.navigateRoot(const LogsRoute())), child: const Text('Logs'), ), ], @@ -122,7 +124,9 @@ class _LoginPageState extends State fontWeight: FontWeight.w500, ), child: InkWell( - onTap: () => launchUrl(Uri.parse(_serverUrlController.text)), + onTap: () => unawaited( + launchUrl(Uri.parse(_serverUrlController.text)), + ), child: Text( _serverUrlController.text, textAlign: TextAlign.center, @@ -157,12 +161,12 @@ class _LoginPageState extends State bottom, ]), ); + secondaryBody = const SizedBox.shrink(); } return BlocListener( listener: _onLoginPageStateChange, child: Scaffold( - resizeToAvoidBottomInset: false, appBar: appBar, body: SafeArea( child: ImAdaptiveScaffoldBody( @@ -170,6 +174,7 @@ class _LoginPageState extends State secondaryBody: (_) => secondaryBody, ), ), + resizeToAvoidBottomInset: false, ), ); } @@ -182,13 +187,14 @@ class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return AppBar( automaticallyImplyLeading: false, - scrolledUnderElevation: 0.0, actions: [ IconButton( - onPressed: () => context.navigateRoot(const SettingsRoute()), + onPressed: () => + unawaited(context.navigateRoot(const SettingsRoute())), icon: const Icon(Symbols.settings), ), ], + scrolledUnderElevation: 0.0, ); } diff --git a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart index d38bced92f..e147e7ae69 100644 --- a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart +++ b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart @@ -61,7 +61,7 @@ class LoginPageCubit extends Cubit with LogMixin { // Check for /.well-known/immich url = await loginService.resolveEndpoint(uri); - di().upsert(StoreKey.serverEndpoint, url); + await di().upsert(StoreKey.serverEndpoint, url); await di().handlePostUrlResolution(url); emit(state.copyWith(isServerValidated: true)); diff --git a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart index 05cf1966b2..2ae8398517 100644 --- a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart +++ b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart @@ -34,14 +34,14 @@ class LoginForm extends StatelessWidget { builder: (_, isServerValidated) => SingleChildScrollView( child: AnimatedSwitcher( duration: Durations.medium1, + layoutBuilder: (current, previous) => + current ?? (previous.lastOrNull ?? const SizedBox.shrink()), child: isServerValidated ? _CredentialsForm( emailController: emailController, passwordController: passwordController, ) : _ServerForm(controller: serverUrlController), - layoutBuilder: (current, previous) => - current ?? (previous.lastOrNull ?? const SizedBox.shrink()), ), ), ); @@ -75,13 +75,13 @@ class _ServerFormState extends State<_ServerForm> { child: BlocSelector( selector: (model) => model.isValidationInProgress, builder: (_, isValidationInProgress) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ImTextFormField( controller: widget.controller, - label: context.t.login.label.endpoint, validator: context.read().validateServerUrl, + label: context.t.login.label.endpoint, autoFillHints: const [AutofillHints.url], keyboardType: TextInputType.url, textInputAction: TextInputAction.go, @@ -89,10 +89,10 @@ class _ServerFormState extends State<_ServerForm> { ), const SizedGap.mh(), ImFilledButton( - label: context.t.login.label.next_button, icon: Symbols.arrow_forward_rounded, onPressed: () => unawaited(_validateForm(context)), isDisabled: isValidationInProgress, + label: context.t.login.label.next_button, ), const SizedGap.mh(), if (isValidationInProgress) const ImLoadingIndicator(), @@ -117,11 +117,11 @@ class _CredentialsForm extends StatefulWidget { } class _CredentialsFormState extends State<_CredentialsForm> { - final passwordFocusNode = FocusNode(); + final _passwordFocusNode = FocusNode(); @override void dispose() { - passwordFocusNode.dispose(); + _passwordFocusNode.dispose(); super.dispose(); } @@ -134,28 +134,27 @@ class _CredentialsFormState extends State<_CredentialsForm> { : ValueListenableBuilder( valueListenable: di(), builder: (_, state, __) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (state.features.hasPasswordLogin) ...[ ImTextFormField( controller: widget.emailController, label: context.t.login.label.email, - isDisabled: isValidationInProgress, textInputAction: TextInputAction.next, - onSubmitted: (_) => passwordFocusNode.requestFocus(), + isDisabled: isValidationInProgress, + onSubmitted: (_) => _passwordFocusNode.requestFocus(), ), const SizedGap.mh(), ImPasswordFormField( controller: widget.passwordController, + focusNode: _passwordFocusNode, label: context.t.login.label.password, - focusNode: passwordFocusNode, - isDisabled: isValidationInProgress, textInputAction: TextInputAction.go, + isDisabled: isValidationInProgress, ), const SizedGap.mh(), ImFilledButton( - label: context.t.login.label.login_button, icon: Symbols.login_rounded, onPressed: () => unawaited( context.read().passwordLogin( @@ -163,31 +162,32 @@ class _CredentialsFormState extends State<_CredentialsForm> { password: widget.passwordController.text, ), ), + label: context.t.login.label.login_button, ), // Divider when both password and oAuth login is enabled if (state.features.hasOAuthLogin) const Divider(), ], if (state.features.hasOAuthLogin) ImFilledButton( - label: state.config.oauthButtonText ?? - context.t.login.label.oauth_button, icon: Symbols.pin_rounded, onPressed: () => unawaited( context.read().oAuthLogin(), ), + label: state.config.oauthButtonText ?? + context.t.login.label.oauth_button, ), if (!state.features.hasPasswordLogin && !state.features.hasOAuthLogin) ImFilledButton( - label: context.t.login.label.login_disabled, isDisabled: true, + label: context.t.login.label.login_disabled, ), const SizedGap.sh(), ImTextButton( - label: context.t.login.label.back_button, icon: Symbols.arrow_back_rounded, onPressed: context.read().resetServerValidation, + label: context.t.login.label.back_button, ), ], ), diff --git a/mobile-v2/lib/presentation/modules/logs/pages/log.page.dart b/mobile-v2/lib/presentation/modules/logs/pages/log.page.dart deleted file mode 100644 index 1aaea8a514..0000000000 --- a/mobile-v2/lib/presentation/modules/logs/pages/log.page.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; - -@RoutePage() -class LogsPage extends StatelessWidget { - const LogsPage({super.key}); - - @override - Widget build(BuildContext context) { - return const Scaffold(body: Center(child: Text("Logs Page"))); - } -} diff --git a/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart b/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart new file mode 100644 index 0000000000..a3a255229f --- /dev/null +++ b/mobile-v2/lib/presentation/modules/logs/pages/logs.page.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:auto_route/auto_route.dart'; +import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/material.dart'; +import 'package:immich_mobile/domain/interfaces/log.interface.dart'; +import 'package:immich_mobile/domain/models/log.model.dart'; +import 'package:immich_mobile/i18n/strings.g.dart'; +import 'package:immich_mobile/presentation/components/common/gap.widget.dart'; +import 'package:immich_mobile/presentation/components/common/skeletonized_future_builder.widget.dart'; +import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart'; +import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart'; +import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/presentation/theme/app_typography.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/constants/size_constants.dart'; +import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; +import 'package:immich_mobile/utils/extensions/color.extension.dart'; +import 'package:immich_mobile/utils/log_manager.dart'; +import 'package:intl/intl.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +@RoutePage() +class LogsWrapperPage extends StatelessWidget { + const LogsWrapperPage({super.key}); + + @override + Widget build(BuildContext context) { + return ImAdaptiveRouteWrapper( + primaryRoute: LogsRoute.name, + primaryBody: (_) => const LogsPage(), + bodyRatio: RatioConstants.oneThird, + ); + } +} + +@RoutePage() +class LogsPage extends StatefulWidget { + const LogsPage({super.key}); + + @override + State createState() => _LogsPageState(); +} + +class _LogsPageState extends State { + void _onClearLogs() { + // refetch logs on clear + setState(() { + unawaited(LogManager.I.clearLogs()); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ImAdaptiveRouteAppBar( + title: context.t.logs.title, + isPrimary: true, + actions: [ + IconButton( + onPressed: _onClearLogs, + icon: Icon(Symbols.delete_rounded), + ), + ], + ), + body: SkeletonizedFutureBuilder( + future: di().getAll(), + builder: (_, data) => _LogList(logs: data!), + loadingBuilder: (_) => const _LogListShimmer(), + errorBuilder: (_, __) => const _LogListEmpty(), + emptyBuilder: (_) => const _LogListEmpty(), + emptyWhen: (data) => data == null || data.isEmpty, + ), + ); + } +} + +class _LogLevelIndicator extends StatelessWidget { + final LogLevel level; + + const _LogLevelIndicator({required this.level}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: switch (level) { + LogLevel.info => context.colorScheme.primary, + LogLevel.error || + LogLevel.wtf => + Colors.redAccent.harmonizeWith(context.colorScheme.primary), + LogLevel.warning => + Colors.orangeAccent.harmonizeWith(context.colorScheme.primary), + LogLevel.verbose || + LogLevel.debug => + Colors.grey.harmonizeWith(context.colorScheme.primary), + }, + shape: BoxShape.circle, + ), + width: 10, + height: 10, + ); + } +} + +class _LogList extends StatelessWidget { + final List logs; + + const _LogList({required this.logs}); + + /// Truncate the log message to a [maxLines]] number of lines + String _truncateLogMessage(String message) { + final msg = message.split("\n").firstOrNull; + return msg?.substring(0, 75.clamp(0, msg.length)) ?? message; + } + + Color _getTileColor(BuildContext context, LogLevel level) { + return switch (level) { + LogLevel.info => Colors.transparent, + LogLevel.error || LogLevel.wtf => Colors.redAccent + .harmonizeWith(context.colorScheme.primary) + .withOpacity(RatioConstants.halfQuarter), + LogLevel.warning => Colors.orangeAccent + .harmonizeWith(context.colorScheme.primary) + .withOpacity(RatioConstants.halfQuarter), + LogLevel.verbose || LogLevel.debug => context.colorScheme.primary + .harmonizeWith(context.colorScheme.primary) + .withOpacity(RatioConstants.halfQuarter), + }; + } + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemBuilder: (_, i) { + final log = logs[i]; + return ListTile( + leading: _LogLevelIndicator(level: log.level), + title: Text( + _truncateLogMessage(log.content), + style: AppTypography.bodyMedium, + ), + subtitle: Text( + "at ${DateFormat("HH:mm:ss.SSS").format(log.createdAt)} in ${log.logger ?? ""}", + style: AppTypography.bodyMedium.copyWith( + color: context.colorScheme.onSurface + .darken(amount: RatioConstants.oneThird), + ), + ), + trailing: const Icon(Symbols.arrow_forward_ios_rounded, size: 18), + dense: true, + visualDensity: VisualDensity.compact, + tileColor: _getTileColor(context, log.level), + minLeadingWidth: 10, + ); + }, + separatorBuilder: (_, __) => Divider(height: 0), + itemCount: logs.length, + ); + } +} + +class _LogListShimmer extends StatelessWidget { + const _LogListShimmer(); + + @override + Widget build(BuildContext context) { + return ListView.separated( + itemBuilder: (_, __) => ListTile( + leading: Bone.circle(size: 20), + title: Bone.text(words: 3), + subtitle: Bone.text(words: 1), + ), + separatorBuilder: (_, __) => Divider(height: 5, thickness: 0.5), + itemCount: 15, + ); + } +} + +class _LogListEmpty extends StatelessWidget { + const _LogListEmpty(); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Symbols.comments_disabled_rounded, + size: 50, + color: context.colorScheme.primary, + ), + const SizedGap.mh(), + Text(context.t.logs.no_logs), + ], + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart b/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart index 3d304b680e..6242de7054 100644 --- a/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart +++ b/mobile-v2/lib/presentation/modules/settings/models/settings_section.model.dart @@ -5,18 +5,18 @@ import 'package:material_symbols_icons/symbols.dart'; enum SettingSection { general._( - icon: Symbols.interests_rounded, labelKey: 'settings.sections.general', + icon: Symbols.interests_rounded, destination: GeneralSettingsRoute(), ), advance._( - icon: Symbols.build_rounded, labelKey: 'settings.sections.advance', + icon: Symbols.build_rounded, destination: AdvanceSettingsRoute(), ), about._( - icon: Symbols.help_rounded, labelKey: 'settings.sections.about', + icon: Symbols.help_rounded, destination: AboutSettingsRoute(), ); diff --git a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart index fc681f0f8c..42175434ec 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart @@ -12,14 +12,14 @@ class AboutSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: const ImAdaptiveRouteSecondaryAppBar(), + appBar: const ImAdaptiveRouteAppBar(isPrimary: false), body: ListTile( title: Text(context.t.settings.about.third_party_title), subtitle: Text(context.t.settings.about.third_party_sub_title), onTap: () => showLicensePage( context: context, applicationName: context.t.immich, - applicationIcon: const ImLogo(width: SizeConstants.xl), + applicationIcon: const ImLogo(dimension: SizeConstants.xl), ), ), ); diff --git a/mobile-v2/lib/presentation/modules/settings/pages/advance_settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/advance_settings.page.dart index 89dd35ab38..b9728542ea 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/advance_settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/advance_settings.page.dart @@ -9,7 +9,7 @@ class AdvanceSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { return const Scaffold( - appBar: ImAdaptiveRouteSecondaryAppBar(), + appBar: ImAdaptiveRouteAppBar(isPrimary: false), body: Center(child: Text('Advanced Settings')), ); } diff --git a/mobile-v2/lib/presentation/modules/settings/pages/general_settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/general_settings.page.dart index a4fe953bf9..7a1f7d26fa 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/general_settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/general_settings.page.dart @@ -9,7 +9,7 @@ class GeneralSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { return const Scaffold( - appBar: ImAdaptiveRouteSecondaryAppBar(), + appBar: ImAdaptiveRouteAppBar(isPrimary: false), body: Center(child: Text('General Settings')), ); } diff --git a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart index 25daab3ee5..fb4bd270c2 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; @@ -15,32 +17,31 @@ class SettingsWrapperPage extends StatelessWidget { @override Widget build(BuildContext context) { return ImAdaptiveRouteWrapper( - primaryBody: (_) => const SettingsPage(), primaryRoute: SettingsRoute.name, - bodyRatio: BodyRatioConstants.oneThird, + primaryBody: (_) => const SettingsPage(), + bodyRatio: RatioConstants.oneThird, ); } } @RoutePage() -// ignore: prefer-single-widget-per-file class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context) { return Scaffold( - appBar: const ImAdaptiveRoutePrimaryAppBar(), + appBar: const ImAdaptiveRouteAppBar(isPrimary: true), body: ListView.builder( - itemCount: SettingSection.values.length, itemBuilder: (_, index) { final section = SettingSection.values.elementAt(index); return ListTile( - title: Text(context.t[section.labelKey]), - onTap: () => context.navigateRoot(section.destination), leading: Icon(section.icon), + title: Text(context.t[section.labelKey]), + onTap: () => unawaited(context.navigateRoot(section.destination)), ); }, + itemCount: SettingSection.values.length, ), ); } diff --git a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart index a7cdca3d4c..bd258891ea 100644 --- a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart +++ b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart @@ -21,7 +21,6 @@ class SplashScreenWrapperPage extends AutoRouter implements AutoRouteWrapper { } @RoutePage() -// ignore: prefer-single-widget-per-file class SplashScreenPage extends StatefulWidget { const SplashScreenPage({super.key}); @@ -63,7 +62,7 @@ class _SplashScreenState extends State future: di.allReady(), builder: (_, snap) { if (snap.hasData) { - _tryLogin(); + unawaited(_tryLogin()); } else if (snap.hasError) { log.wtf( "Error while initializing the app", @@ -75,7 +74,7 @@ class _SplashScreenState extends State return Center( child: RotationTransition( turns: _animationController, - child: const ImLogo(width: 100), + child: const ImLogo(dimension: 100), ), ); }, diff --git a/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart b/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart index 7756c0c8d8..445b58c4c2 100644 --- a/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart +++ b/mobile-v2/lib/presentation/router/pages/tab_controller.page.dart @@ -2,7 +2,14 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:immich_mobile/i18n/strings.g.dart'; +import 'package:immich_mobile/presentation/components/common/immich_navigation_rail.dart'; +import 'package:immich_mobile/presentation/components/common/user_avatar.widget.dart'; +import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; import 'package:immich_mobile/presentation/router/router.dart'; +import 'package:immich_mobile/presentation/states/current_user.state.dart'; +import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/utils/constants/size_constants.dart'; +import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; import 'package:material_symbols_icons/symbols.dart'; @RoutePage() @@ -24,7 +31,7 @@ class TabControllerPage extends StatelessWidget { return PopScope( canPop: tabsRouter.activeIndex == 0, onPopInvokedWithResult: (didPop, _) => - !didPop ? tabsRouter.setActiveIndex(0) : null, + didPop ? null : tabsRouter.setActiveIndex(0), child: _TabControllerAdaptiveScaffold( body: (ctxx) => child, selectedIndex: tabsRouter.activeIndex, @@ -80,53 +87,117 @@ class _TabControllerAdaptiveScaffold extends StatelessWidget { return Scaffold( body: AdaptiveLayout( - // No animation on layout change - transitionDuration: Duration.zero, primaryNavigation: SlotLayout( config: { Breakpoints.mediumAndUp: SlotLayout.from( - key: const Key( - '_TabControllerAdaptiveScaffold Primary Navigation Medium', - ), - builder: (_) => AdaptiveScaffold.standardNavigationRail( - selectedIndex: selectedIndex, + builder: (_) => _ImNavigationRailBuilder( destinations: destinations .map((NavigationDestination destination) => AdaptiveScaffold.toRailDestination(destination)) .toList(), - onDestinationSelected: onSelectedIndexChange, + selectedIndex: selectedIndex, backgroundColor: navRailTheme.backgroundColor, + leading: ImUserAvatar( + user: di().value, + dimension: SizeConstants.m, + radius: SizeConstants.m, + ), + trailing: ImLogo(dimension: SizeConstants.xm), + onDestinationSelected: onSelectedIndexChange, selectedIconTheme: navRailTheme.selectedIconTheme, unselectedIconTheme: navRailTheme.unselectedIconTheme, selectedLabelTextStyle: navRailTheme.selectedLabelTextStyle, unSelectedLabelTextStyle: navRailTheme.unselectedLabelTextStyle, ), + key: const Key( + '_TabControllerAdaptiveScaffold Primary Navigation Medium', + ), ), }, ), body: SlotLayout( config: { Breakpoints.standard: SlotLayout.from( - key: const Key('_TabControllerAdaptiveScaffold Body'), builder: body, + key: const Key('_TabControllerAdaptiveScaffold Body'), ), }, ), + // No animation on layout change + transitionDuration: Duration.zero, ), bottomNavigationBar: SlotLayout( config: { Breakpoints.small: SlotLayout.from( + builder: (_) => AdaptiveScaffold.standardBottomNavigationBar( + destinations: destinations, + currentIndex: selectedIndex, + onDestinationSelected: onSelectedIndexChange, + ), key: const Key( '_TabControllerAdaptiveScaffold Bottom Navigation Small', ), - builder: (_) => AdaptiveScaffold.standardBottomNavigationBar( - currentIndex: selectedIndex, - destinations: destinations, - onDestinationSelected: onSelectedIndexChange, - ), ), }, ), ); } } + +class _ImNavigationRailBuilder extends StatelessWidget { + final List destinations; + final int? selectedIndex; + final Color? backgroundColor; + final Function(int)? onDestinationSelected; + final IconThemeData? selectedIconTheme; + final IconThemeData? unselectedIconTheme; + final TextStyle? selectedLabelTextStyle; + final TextStyle? unSelectedLabelTextStyle; + final Widget? leading; + final Widget? trailing; + + const _ImNavigationRailBuilder({ + required this.destinations, + this.selectedIndex, + this.backgroundColor, + this.leading, + this.trailing, + this.onDestinationSelected, + this.selectedIconTheme, + this.unselectedIconTheme, + this.selectedLabelTextStyle, + this.unSelectedLabelTextStyle, + }); + + @override + Widget build(BuildContext context) { + return Builder(builder: (BuildContext _) { + return SizedBox( + width: 72, + height: context.height, + child: LayoutBuilder( + builder: (BuildContext _, BoxConstraints constraints) { + return ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight( + child: ImNavigationRail( + backgroundColor: backgroundColor, + leading: leading, + trailing: trailing, + destinations: destinations, + selectedIndex: selectedIndex, + onDestinationSelected: onDestinationSelected, + labelType: NavigationRailLabelType.none, + unselectedLabelTextStyle: unSelectedLabelTextStyle, + selectedLabelTextStyle: selectedLabelTextStyle, + unselectedIconTheme: unselectedIconTheme, + selectedIconTheme: selectedIconTheme, + ), + ), + ); + }, + ), + ); + }); + } +} diff --git a/mobile-v2/lib/presentation/router/router.dart b/mobile-v2/lib/presentation/router/router.dart index 07476bb1f7..9931726c2a 100644 --- a/mobile-v2/lib/presentation/router/router.dart +++ b/mobile-v2/lib/presentation/router/router.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:immich_mobile/presentation/modules/home/pages/home.page.dart'; import 'package:immich_mobile/presentation/modules/library/pages/library.page.dart'; import 'package:immich_mobile/presentation/modules/login/pages/login.page.dart'; -import 'package:immich_mobile/presentation/modules/logs/pages/log.page.dart'; +import 'package:immich_mobile/presentation/modules/logs/pages/logs.page.dart'; import 'package:immich_mobile/presentation/modules/search/pages/search.page.dart'; import 'package:immich_mobile/presentation/modules/settings/pages/about_settings.page.dart'; import 'package:immich_mobile/presentation/modules/settings/pages/advance_settings.page.dart'; @@ -29,12 +29,15 @@ class AppRouter extends RootStackRouter { List get routes => [ AutoRoute( page: SplashScreenWrapperRoute.page, - initial: true, children: [ AutoRoute(page: SplashScreenRoute.page, initial: true), AutoRoute(page: LoginRoute.page), ], + initial: true, ), + AutoRoute(page: LogsWrapperRoute.page, children: [ + AutoRoute(page: LogsRoute.page), + ]), AutoRoute(page: LogsRoute.page), AutoRoute(page: TabControllerRoute.page, children: [ AutoRoute(page: HomeRoute.page), diff --git a/mobile-v2/lib/presentation/states/app_theme.state.dart b/mobile-v2/lib/presentation/states/app_theme.state.dart index 285b0f5cda..025a07e9b6 100644 --- a/mobile-v2/lib/presentation/states/app_theme.state.dart +++ b/mobile-v2/lib/presentation/states/app_theme.state.dart @@ -19,7 +19,7 @@ class AppThemeProvider extends ValueNotifier { @override void dispose() { - _appSettingSubscription.cancel(); + unawaited(_appSettingSubscription.cancel()); super.dispose(); } } diff --git a/mobile-v2/lib/presentation/states/gallery_permission.state.dart b/mobile-v2/lib/presentation/states/gallery_permission.state.dart index 47d3783a9b..61d5576758 100644 --- a/mobile-v2/lib/presentation/states/gallery_permission.state.dart +++ b/mobile-v2/lib/presentation/states/gallery_permission.state.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:device_info_plus/device_info_plus.dart'; @@ -17,7 +18,7 @@ enum GalleryPermissionStatus { class GalleryPermissionProvider extends ValueNotifier { GalleryPermissionProvider() : super(GalleryPermissionStatus.yetToRequest) { - checkPermission(); + unawaited(checkPermission()); } bool get hasPermission => value.isGranted || value.isLimited; diff --git a/mobile-v2/lib/presentation/theme/app_colors.dart b/mobile-v2/lib/presentation/theme/app_colors.dart index 1c725101ee..71d3d605d0 100644 --- a/mobile-v2/lib/presentation/theme/app_colors.dart +++ b/mobile-v2/lib/presentation/theme/app_colors.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; @immutable -abstract class AppColors { +abstract final class AppColors { const AppColors(); /// Blue color @@ -25,9 +25,9 @@ abstract class AppColors { onErrorContainer: Color(0xff410002), surface: Color(0xFFF0EFF4), onSurface: Color(0xff1a1b21), - onSurfaceVariant: Color(0xff444651), surfaceContainer: Color(0xfffefbff), surfaceContainerHighest: Color(0xffe0e2ef), + onSurfaceVariant: Color(0xff444651), outline: Color(0xff747782), outlineVariant: Color(0xffc4c6d3), shadow: Color(0xff000000), @@ -58,9 +58,9 @@ abstract class AppColors { onErrorContainer: Color(0xffffb4ab), surface: Color(0xFF15181C), onSurface: Color(0xffe2e2e9), - onSurfaceVariant: Color(0xffc2c6d2), surfaceContainer: Color(0xff1a1e22), surfaceContainerHighest: Color(0xff424852), + onSurfaceVariant: Color(0xffc2c6d2), outline: Color(0xff8c919c), outlineVariant: Color(0xff424751), shadow: Color(0xff000000), diff --git a/mobile-v2/lib/presentation/theme/app_theme.dart b/mobile-v2/lib/presentation/theme/app_theme.dart index d5adbd280d..0afb12eb25 100644 --- a/mobile-v2/lib/presentation/theme/app_theme.dart +++ b/mobile-v2/lib/presentation/theme/app_theme.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/presentation/theme/app_colors.dart'; import 'package:immich_mobile/presentation/theme/app_typography.dart'; import 'package:immich_mobile/utils/extensions/material_state.extension.dart'; +import 'package:material_symbols_icons/symbols.dart'; enum AppTheme { blue._(AppColors.blueLight, AppColors.blueDark), @@ -15,9 +16,58 @@ enum AppTheme { static ThemeData generateThemeData(ColorScheme color) { return ThemeData( + inputDecorationTheme: InputDecorationTheme( + hintStyle: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.normal, + ), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: color.error), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: color.primary), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + focusedErrorBorder: OutlineInputBorder( + borderSide: BorderSide(color: color.error), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: color.outlineVariant), + borderRadius: const BorderRadius.all(Radius.circular(15)), + ), + ), colorScheme: color, primaryColor: color.primary, - iconTheme: const IconThemeData(weight: 500, opticalSize: 24), + scaffoldBackgroundColor: color.surface, + iconTheme: const IconThemeData(size: 24, weight: 500, opticalSize: 24), + textTheme: TextTheme( + displayLarge: AppTypography.displayLarge, + displayMedium: AppTypography.displayMedium, + displaySmall: AppTypography.displaySmall, + headlineLarge: AppTypography.headlineLarge, + headlineMedium: AppTypography.headlineMedium, + headlineSmall: AppTypography.headlineSmall, + titleLarge: AppTypography.titleLarge, + titleMedium: AppTypography.titleMedium, + titleSmall: AppTypography.titleSmall, + bodyLarge: AppTypography.bodyLarge, + bodyMedium: AppTypography.bodyMedium, + bodySmall: AppTypography.bodySmall, + labelLarge: AppTypography.labelLarge, + labelMedium: AppTypography.labelMedium, + labelSmall: AppTypography.labelSmall, + ), + actionIconTheme: ActionIconThemeData( + backButtonIconBuilder: (_) => Icon(Symbols.arrow_back_rounded), + closeButtonIconBuilder: (_) => Icon(Symbols.close_rounded), + ), + appBarTheme: AppBarTheme( + iconTheme: IconThemeData(size: 22, color: color.onSurface), + titleTextStyle: + AppTypography.titleLarge.copyWith(color: color.onSurface), + ), navigationBarTheme: NavigationBarThemeData( backgroundColor: color.surfaceContainer, indicatorColor: color.primary, @@ -30,78 +80,38 @@ enum AppTheme { }, ), ), - scaffoldBackgroundColor: color.surface, navigationRailTheme: NavigationRailThemeData( backgroundColor: color.surfaceContainer, elevation: 3, - indicatorColor: color.primary, - selectedIconTheme: - IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary), unselectedIconTheme: IconThemeData( weight: 500, opticalSize: 24, color: color.onSurface.withAlpha(175), ), + selectedIconTheme: + IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary), + indicatorColor: color.primary, ), - inputDecorationTheme: InputDecorationTheme( - focusedBorder: OutlineInputBorder( - borderSide: BorderSide(color: color.primary), - borderRadius: const BorderRadius.all(Radius.circular(15)), - ), - enabledBorder: OutlineInputBorder( - borderSide: BorderSide(color: color.outlineVariant), - borderRadius: const BorderRadius.all(Radius.circular(15)), - ), - errorBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(15)), - borderSide: BorderSide(color: color.error), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: const BorderRadius.all(Radius.circular(15)), - borderSide: BorderSide(color: color.error), - ), - hintStyle: const TextStyle( - fontSize: 16.0, - fontWeight: FontWeight.normal, - ), - ), - textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary), sliderTheme: SliderThemeData( valueIndicatorColor: Color.alphaBlend(color.primary.withAlpha(80), color.onSurface) .withAlpha(240), ), - textTheme: TextTheme( - titleLarge: AppTypography.titleLarge, - titleMedium: AppTypography.titleMedium, - titleSmall: AppTypography.titleSmall, - displayLarge: AppTypography.displayLarge, - displayMedium: AppTypography.displayMedium, - displaySmall: AppTypography.displaySmall, - headlineLarge: AppTypography.headlineLarge, - headlineMedium: AppTypography.headlineMedium, - headlineSmall: AppTypography.headlineSmall, - bodyLarge: AppTypography.bodyLarge, - bodyMedium: AppTypography.bodyMedium, - bodySmall: AppTypography.bodySmall, - labelLarge: AppTypography.labelLarge, - labelMedium: AppTypography.labelMedium, - labelSmall: AppTypography.labelSmall, - ), snackBarTheme: SnackBarThemeData( - elevation: 4, - behavior: SnackBarBehavior.floating, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(4.0)), - ), - insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0), backgroundColor: Color.alphaBlend(color.primary.withAlpha(80), color.onSurface) .withAlpha(240), actionTextColor: color.inversePrimary, contentTextStyle: TextStyle(color: color.onInverseSurface), + elevation: 4, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ), + behavior: SnackBarBehavior.floating, + insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0), closeIconColor: color.onInverseSurface, ), + textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary), ); } } diff --git a/mobile-v2/lib/presentation/theme/app_typography.dart b/mobile-v2/lib/presentation/theme/app_typography.dart index 299ed012bb..b64ffd6913 100644 --- a/mobile-v2/lib/presentation/theme/app_typography.dart +++ b/mobile-v2/lib/presentation/theme/app_typography.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -class AppTypography { +abstract final class AppTypography { const AppTypography(); static const TextStyle displayLarge = TextStyle( @@ -30,16 +30,16 @@ class AppTypography { ); static const TextStyle titleLarge = TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, + fontSize: 20, + fontWeight: FontWeight.normal, ); static const TextStyle titleMedium = TextStyle( fontSize: 16, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.normal, ); static const TextStyle titleSmall = TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.normal, ); static const TextStyle bodyLarge = TextStyle( diff --git a/mobile-v2/lib/service_locator.dart b/mobile-v2/lib/service_locator.dart index 67d488575d..c6c690a04c 100644 --- a/mobile-v2/lib/service_locator.dart +++ b/mobile-v2/lib/service_locator.dart @@ -47,7 +47,7 @@ import 'package:immich_mobile/utils/immich_api_client.dart'; final di = GetIt.I; -class ServiceLocator { +abstract final class ServiceLocator { const ServiceLocator._internal(); static void _registerFactory(T Function() factoryFun) { @@ -118,19 +118,19 @@ class ServiceLocator { /// API Repos _registerFactory(() => AlbumETagRepository(db: di())); _registerFactory( - () => SyncApiRepository(syncApi: di().getSyncApi()), + () => SyncApiRepository(syncApi: di().syncApi), ); _registerFactory( - () => ServerApiRepository(serverApi: di().getServerApi()), + () => ServerApiRepository(serverApi: di().serverApi), ); _registerFactory( () => AuthenticationApiRepository( - authenticationApi: di().getAuthenticationApi(), - oAuthApi: di().getOAuthApi(), + authenticationApi: di().authenticationApi, + oAuthApi: di().oAuthApi, ), ); _registerFactory( - () => UserApiRepository(usersApi: di().getUsersApi()), + () => UserApiRepository(usersApi: di().usersApi), ); } @@ -144,9 +144,9 @@ class ServiceLocator { _registerFactory(() => const LoginService()); _registerFactory(() => HashService( hostService: di(), - assetToHashRepo: di(), - deviceAlbumRepo: di(), deviceAssetRepo: di(), + deviceAlbumRepo: di(), + assetToHashRepo: di(), )); } diff --git a/mobile-v2/lib/utils/collection_util.dart b/mobile-v2/lib/utils/collection_util.dart index 89bb315ff2..6d03f40c4a 100644 --- a/mobile-v2/lib/utils/collection_util.dart +++ b/mobile-v2/lib/utils/collection_util.dart @@ -2,7 +2,7 @@ import 'dart:async'; -class CollectionUtil { +abstract final class CollectionUtil { const CollectionUtil(); static int compareToNullable(T? a, T? b) { @@ -18,22 +18,22 @@ class CollectionUtil { /// Find the difference between the two lists [first] and [second] /// Results are passed as callbacks back to the caller during the comparison static FutureOr diffLists( - List first, - List second, { + List firstList, + List secondList, { required int Function(T a, T b) compare, required FutureOr Function(T a, T b) both, required FutureOr Function(T a) onlyFirst, required FutureOr Function(T b) onlySecond, }) async { - first.sort(compare); - first.uniqueConsecutive(compare); - second.sort(compare); - second.uniqueConsecutive(compare); + final first = + _uniqueConsecutive(List.of(firstList)..sort(compare), compare); + final second = + _uniqueConsecutive(List.of(secondList)..sort(compare), compare); bool diff = false; int i = 0, j = 0; - for (; i < first.length && j < second.length;) { + while (i < first.length && j < second.length) { final int order = compare(first[i], second[j]); if (order == 0) { diff |= await both(first[i++], second[j++]); @@ -49,27 +49,25 @@ class CollectionUtil { diff |= i < first.length || j < second.length; for (; i < first.length; i++) { - onlyFirst(first[i]); + await onlyFirst(first[i]); } for (; j < second.length; j++) { - onlySecond(second[j]); + await onlySecond(second[j]); } return diff; } } -extension _ListExtension on List { - List uniqueConsecutive(int Function(T a, T b) compare) { - int i = 1, j = 1; - for (; i < length; i++) { - if (compare(this[i - 1], this[i]) != 0) { - if (i != j) { - this[j] = this[i]; - } - j++; - } +List _uniqueConsecutive(List list, int Function(T a, T b) compare) { + if (list.isEmpty) return list; + + List unique = []; + unique.add(list.first); + + for (int i = 1; i < list.length; i++) { + if (compare(list[i], list[i - 1]) != 0) { + unique.add(list[i]); } - length = length == 0 ? 0 : j; - return this; } + return unique; } diff --git a/mobile-v2/lib/utils/constants/size_constants.dart b/mobile-v2/lib/utils/constants/size_constants.dart index 027407cd44..36534e09d6 100644 --- a/mobile-v2/lib/utils/constants/size_constants.dart +++ b/mobile-v2/lib/utils/constants/size_constants.dart @@ -1,17 +1,23 @@ import 'package:flutter/material.dart'; @immutable -class SizeConstants { +abstract final class SizeConstants { const SizeConstants._(); static const s = 8.0; static const m = 16.0; + static const xm = 25.0; static const l = 32.0; static const xl = 64.0; } -class BodyRatioConstants { - const BodyRatioConstants._(); +abstract final class RatioConstants { + const RatioConstants._(); + // 0.3 static const oneThird = 1 / 3; + // 0.25 + static const quarter = 1 / 4; + // 0.15 + static const halfQuarter = 3 / 20; } diff --git a/mobile-v2/lib/utils/extensions/iterable.extension.dart b/mobile-v2/lib/utils/extensions/iterable.extension.dart index cd8638af15..82d7faacd9 100644 --- a/mobile-v2/lib/utils/extensions/iterable.extension.dart +++ b/mobile-v2/lib/utils/extensions/iterable.extension.dart @@ -1,4 +1,4 @@ extension SortIterable on Iterable { Iterable sortedBy(Comparable Function(T k) key) => - toList()..sort((a, b) => key(a).compareTo(key(b))); + List.of(this)..sort((a, b) => key(a).compareTo(key(b))); } diff --git a/mobile-v2/lib/utils/immich_api_client.dart b/mobile-v2/lib/utils/immich_api_client.dart index 5c79263f62..d1ea4ca83d 100644 --- a/mobile-v2/lib/utils/immich_api_client.dart +++ b/mobile-v2/lib/utils/immich_api_client.dart @@ -66,9 +66,9 @@ class ImApiClient extends ApiClient with LogMixin { return res; } - UsersApi getUsersApi() => UsersApi(this); - ServerApi getServerApi() => ServerApi(this); - AuthenticationApi getAuthenticationApi() => AuthenticationApi(this); - OAuthApi getOAuthApi() => OAuthApi(this); - SyncApi getSyncApi() => SyncApi(this); + UsersApi get usersApi => UsersApi(this); + ServerApi get serverApi => ServerApi(this); + AuthenticationApi get authenticationApi => AuthenticationApi(this); + OAuthApi get oAuthApi => OAuthApi(this); + SyncApi get syncApi => SyncApi(this); } diff --git a/mobile-v2/lib/utils/immich_image_url_helper.dart b/mobile-v2/lib/utils/immich_image_url_helper.dart index 7a9086f5a9..5e45b1c3c1 100644 --- a/mobile-v2/lib/utils/immich_image_url_helper.dart +++ b/mobile-v2/lib/utils/immich_image_url_helper.dart @@ -1,4 +1,5 @@ import 'package:immich_mobile/domain/models/asset.model.dart'; +import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/utils/immich_api_client.dart'; @@ -11,11 +12,15 @@ enum AssetMediaSize { final String value; } -class ImImageUrlHelper { +abstract final class ImImageUrlHelper { const ImImageUrlHelper(); static String get _serverUrl => di().basePath; + static String getUserAvatarUrl(final User user) { + return '$_serverUrl/users/${user.id}/profile-image'; + } + static String getThumbnailUrl( final Asset asset, { AssetMediaSize type = AssetMediaSize.thumbnail, diff --git a/mobile-v2/lib/utils/isolate_helper.dart b/mobile-v2/lib/utils/isolate_helper.dart index 21025aeb98..06cd4d8cef 100644 --- a/mobile-v2/lib/utils/isolate_helper.dart +++ b/mobile-v2/lib/utils/isolate_helper.dart @@ -40,7 +40,7 @@ class IsolateHelper { ); } - void postIsolateHandling() { + Future postIsolateHandling() async { assert(_clientData != null); // Reconstruct client from cached data final client = ImApiClient(endpoint: _clientData!.endpoint); @@ -55,7 +55,7 @@ class IsolateHelper { ); // Init log manager to continue listening to log events - LogManager.I.init(shouldBuffer: false); + await LogManager.I.init(shouldBuffer: false); } static Future run(FutureOr Function() computation) async { @@ -70,10 +70,10 @@ class IsolateHelper { DartPluginRegistrant.ensureInitialized(); // Delay to ensure the isolate is ready await Future.delayed(Durations.short2); - helper.postIsolateHandling(); + await helper.postIsolateHandling(); try { final result = await computation(); - // Delay to ensure the isolate is not killed prematurely + // Wait for isolate to end; i.e, logs to be flushed await Future.delayed(Durations.short2); return result; } finally { diff --git a/mobile-v2/lib/utils/log_manager.dart b/mobile-v2/lib/utils/log_manager.dart index 56f1d7e5c2..683df045f0 100644 --- a/mobile-v2/lib/utils/log_manager.dart +++ b/mobile-v2/lib/utils/log_manager.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid-collection-mutating-methods + import 'dart:async'; import 'package:flutter/foundation.dart'; @@ -13,6 +15,7 @@ import 'package:logging/logging.dart' as logging; /// in the class. class LogManager { LogManager._(); + static final LogManager _instance = LogManager._(); static final Map _loggers = {}; @@ -40,10 +43,10 @@ class LogManager { }()); final lm = LogMessage( - logger: record.loggerName, content: record.message, level: record.level.toLogLevel(), createdAt: record.time, + logger: record.loggerName, error: record.error?.toString(), stack: record.stackTrace?.toString(), ); @@ -53,7 +56,7 @@ class LogManager { _timer ??= Timer(const Duration(seconds: 5), () => _flushBufferToDatabase()); } else { - di().create(lm); + unawaited(di().create(lm)); } } @@ -61,12 +64,13 @@ class LogManager { _timer = null; final buffer = _msgBuffer; _msgBuffer = []; - di().createAll(buffer); + unawaited(di().createAll(buffer)); } - void init({bool? shouldBuffer}) { + Future init({bool? shouldBuffer}) async { _shouldBuffer = shouldBuffer ?? _shouldBuffer; _subscription = logging.Logger.root.onRecord.listen(_onLogRecord); + await di().truncate(); } Logger get(String? loggerName) => _loggers.putIfAbsent( @@ -80,14 +84,14 @@ class LogManager { } void dispose() { - _subscription.cancel(); + unawaited(_subscription.cancel()); } - void clearLogs() { + Future clearLogs() async { _timer?.cancel(); _timer = null; _msgBuffer.clear(); - di().deleteAll(); + await di().deleteAll(); } static void setGlobalErrorCallbacks() { diff --git a/mobile-v2/lib/utils/openapi_patching.dart b/mobile-v2/lib/utils/openapi_patching.dart index 0f4ca58438..732ed6e1b5 100644 --- a/mobile-v2/lib/utils/openapi_patching.dart +++ b/mobile-v2/lib/utils/openapi_patching.dart @@ -35,10 +35,10 @@ dynamic upgradeDto(dynamic value, String targetType) { } } -addDefault(dynamic value, String keys, dynamic defaultValue) { +addDefault(Map value, String keys, dynamic defaultValue) { // Loop through the keys and assign the default value if the key is not present List keyList = keys.split('.'); - dynamic current = value; + Map current = value; for (int i = 0; i < keyList.length - 1; i++) { if (current[keyList[i]] == null) { diff --git a/mobile-v2/lib/utils/snackbar_manager.dart b/mobile-v2/lib/utils/snackbar_manager.dart index 2f0010f8b8..093f060751 100644 --- a/mobile-v2/lib/utils/snackbar_manager.dart +++ b/mobile-v2/lib/utils/snackbar_manager.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/utils/constants/globals.dart'; -class SnackbarManager { +abstract final class SnackbarManager { const SnackbarManager(); static ScaffoldMessengerState? get _s => kScafMessengerKey.currentState; diff --git a/mobile-v2/pubspec.lock b/mobile-v2/pubspec.lock index 1cfffdb000..82bb4c8fdb 100644 --- a/mobile-v2/pubspec.lock +++ b/mobile-v2/pubspec.lock @@ -976,6 +976,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + skeletonizer: + dependency: "direct main" + description: + name: skeletonizer + sha256: "3b202e4fa9c49b017d368fb0e570d4952bcd19972b67b2face071bdd68abbfae" + url: "https://pub.dev" + source: hosted + version: "1.4.2" sky_engine: dependency: transitive description: flutter diff --git a/mobile-v2/pubspec.yaml b/mobile-v2/pubspec.yaml index 4754def389..a0838a454d 100644 --- a/mobile-v2/pubspec.yaml +++ b/mobile-v2/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: flutter_list_view: ^1.1.28 cached_network_image: ^3.4.1 flutter_cache_manager: ^3.4.1 + skeletonizer: ^1.4.2 openapi: path: openapi diff --git a/mobile/lib/pages/common/app_log.page.dart b/mobile/lib/pages/common/app_log.page.dart index 226d380a28..d28d073b01 100644 --- a/mobile/lib/pages/common/app_log.page.dart +++ b/mobile/lib/pages/common/app_log.page.dart @@ -2,10 +2,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/entities/logger_message.entity.dart'; import 'package:immich_mobile/services/immich_logger.service.dart'; import 'package:intl/intl.dart';