From fe7189430848171dd3f65c5e1e35551388f5b130 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 20 May 2025 08:35:22 -0500 Subject: [PATCH] feat: locked view mobile (#18316) * feat: locked/private view * feat: locked/private view * feat: mobile lock/private view * feat: mobile lock/private view * merge main * pr feedback * pr feedback * bottom sheet sizing * always lock when navigating away --- i18n/en.json | 13 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../app/alextran/immich/MainActivity.kt | 18 +- .../app/src/main/res/values/styles.xml | 29 +- mobile/ios/Podfile.lock | 13 + mobile/ios/Runner/Info.plist | 326 +++++++++--------- mobile/lib/constants/constants.dart | 3 + mobile/lib/constants/enums.dart | 2 + mobile/lib/entities/asset.entity.dart | 27 +- mobile/lib/entities/asset.entity.g.dart | 121 ++++++- .../lib/interfaces/asset_api.interface.dart | 6 + mobile/lib/interfaces/auth_api.interface.dart | 5 + .../lib/interfaces/biometric.interface.dart | 6 + .../interfaces/secure_storage.interface.dart | 5 + mobile/lib/interfaces/timeline.interface.dart | 5 + mobile/lib/main.dart | 4 +- .../models/auth/biometric_status.model.dart | 38 ++ mobile/lib/pages/library/library.page.dart | 13 + .../lib/pages/library/locked/locked.page.dart | 95 +++++ .../pages/library/locked/pin_auth.page.dart | 127 +++++++ mobile/lib/providers/asset.provider.dart | 8 + mobile/lib/providers/auth.provider.dart | 12 + mobile/lib/providers/local_auth.provider.dart | 97 ++++++ mobile/lib/providers/routes.provider.dart | 3 + .../providers/secure_storage.provider.dart | 10 + mobile/lib/providers/timeline.provider.dart | 5 + .../repositories/asset_api.repository.dart | 24 ++ .../lib/repositories/auth_api.repository.dart | 22 ++ .../repositories/biometric.repository.dart | 35 ++ .../secure_storage.repository.dart | 27 ++ .../lib/repositories/timeline.repository.dart | 27 +- .../lib/routing/app_navigation_observer.dart | 52 +++ mobile/lib/routing/locked_guard.dart | 89 +++++ mobile/lib/routing/router.dart | 34 +- mobile/lib/routing/router.gr.dart | 66 ++++ .../lib/routing/tab_navigation_observer.dart | 35 -- mobile/lib/services/asset.service.dart | 23 ++ mobile/lib/services/auth.service.dart | 12 + mobile/lib/services/local_auth.service.dart | 26 ++ mobile/lib/services/memory.service.dart | 7 +- .../lib/services/secure_storage.service.dart | 29 ++ mobile/lib/services/timeline.service.dart | 9 + mobile/lib/theme/theme_data.dart | 16 +- mobile/lib/utils/migration.dart | 2 +- mobile/lib/utils/openapi_patching.dart | 5 + mobile/lib/utils/selection_handlers.dart | 27 ++ .../asset_grid/control_bottom_app_bar.dart | 95 +++-- .../widgets/asset_grid/multiselect_grid.dart | 29 ++ .../asset_viewer/bottom_gallery_bar.dart | 8 +- .../asset_viewer/top_control_app_bar.dart | 15 +- mobile/lib/widgets/common/drag_sheet.dart | 8 +- mobile/lib/widgets/common/immich_toast.dart | 15 +- mobile/lib/widgets/forms/pin_input.dart | 124 +++++++ .../widgets/forms/pin_registration_form.dart | 128 +++++++ .../widgets/forms/pin_verification_form.dart | 94 +++++ mobile/pubspec.lock | 104 ++++++ mobile/pubspec.yaml | 3 + 57 files changed, 1893 insertions(+), 289 deletions(-) create mode 100644 mobile/lib/interfaces/biometric.interface.dart create mode 100644 mobile/lib/interfaces/secure_storage.interface.dart create mode 100644 mobile/lib/models/auth/biometric_status.model.dart create mode 100644 mobile/lib/pages/library/locked/locked.page.dart create mode 100644 mobile/lib/pages/library/locked/pin_auth.page.dart create mode 100644 mobile/lib/providers/local_auth.provider.dart create mode 100644 mobile/lib/providers/routes.provider.dart create mode 100644 mobile/lib/providers/secure_storage.provider.dart create mode 100644 mobile/lib/repositories/biometric.repository.dart create mode 100644 mobile/lib/repositories/secure_storage.repository.dart create mode 100644 mobile/lib/routing/app_navigation_observer.dart create mode 100644 mobile/lib/routing/locked_guard.dart delete mode 100644 mobile/lib/routing/tab_navigation_observer.dart create mode 100644 mobile/lib/services/local_auth.service.dart create mode 100644 mobile/lib/services/secure_storage.service.dart create mode 100644 mobile/lib/widgets/forms/pin_input.dart create mode 100644 mobile/lib/widgets/forms/pin_registration_form.dart create mode 100644 mobile/lib/widgets/forms/pin_verification_form.dart diff --git a/i18n/en.json b/i18n/en.json index 66b6e3afe0..fb7743f8e4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -563,6 +563,10 @@ "backup_options_page_title": "Backup options", "backup_setting_subtitle": "Manage background and foreground upload settings", "backward": "Backward", + "biometric_auth_enabled": "Biometric authentication enabled", + "biometric_locked_out": "You are locked out of biometric authentication", + "biometric_no_options": "No biometric options available", + "biometric_not_available": "Biometric authentication is not available on this device", "birthdate_saved": "Date of birth saved successfully", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", @@ -822,6 +826,7 @@ "empty_trash": "Empty trash", "empty_trash_confirmation": "Are you sure you want to empty the trash? This will remove all the assets in trash permanently from Immich.\nYou cannot undo this action!", "enable": "Enable", + "enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication", "enabled": "Enabled", "end_date": "End date", "enqueued": "Enqueued", @@ -995,6 +1000,7 @@ "external_network_sheet_info": "When not on the preferred Wi-Fi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "face_unassigned": "Unassigned", "failed": "Failed", + "failed_to_authenticate": "Failed to authenticate", "failed_to_load_assets": "Failed to load assets", "failed_to_load_folder": "Failed to load folder", "favorite": "Favorite", @@ -1060,6 +1066,8 @@ "home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album so that the timeline can populate photos and videos in it", + "home_page_locked_error_local": "Can not move local assets to locked folder, skipping", + "home_page_locked_error_partner": "Can not move partner assets to locked folder, skipping", "home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "host": "Host", @@ -1227,8 +1235,6 @@ "memories_setting_description": "Manage what you see in your memories", "memories_start_over": "Start Over", "memories_swipe_to_close": "Swipe up to close", - "memories_year_ago": "A year ago", - "memories_years_ago": "{years, plural, other {# years}} ago", "memory": "Memory", "memory_lane_title": "Memory Lane {title}", "menu": "Menu", @@ -1400,6 +1406,7 @@ "play_memories": "Play memories", "play_motion_photo": "Play Motion Photo", "play_or_pause_video": "Play or pause video", + "please_auth_to_access": "Please authenticate to access", "port": "Port", "preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_title": "Preferences", @@ -1661,6 +1668,7 @@ "share_add_photos": "Add photos", "share_assets_selected": "{count} selected", "share_dialog_preparing": "Preparing...", + "share_link": "Share Link", "shared": "Shared", "shared_album_activities_input_disable": "Comment is disabled", "shared_album_activity_remove_content": "Do you want to delete this activity?", @@ -1884,6 +1892,7 @@ "uploading": "Uploading", "url": "URL", "usage": "Usage", + "use_biometric": "Use biometric", "use_current_connection": "use current connection", "use_custom_date_range": "Use custom date range instead", "user": "User", diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index eb81dc267b..2179c9eb3c 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 752ded59ce..c1e5152d28 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,14 +1,14 @@ package app.alextran.immich -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine import androidx.annotation.NonNull +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine -class MainActivity : FlutterActivity() { - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - flutterEngine.plugins.add(BackgroundServicePlugin()) - flutterEngine.plugins.add(HttpSSLOptionsPlugin()) - // No need to set up method channel here as it's now handled in the plugin - } +class MainActivity : FlutterFragmentActivity() { + override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + flutterEngine.plugins.add(BackgroundServicePlugin()) + flutterEngine.plugins.add(HttpSSLOptionsPlugin()) + // No need to set up method channel here as it's now handled in the plugin + } } diff --git a/mobile/android/app/src/main/res/values/styles.xml b/mobile/android/app/src/main/res/values/styles.xml index 0fdc703671..0a4dd28549 100644 --- a/mobile/android/app/src/main/res/values/styles.xml +++ b/mobile/android/app/src/main/res/values/styles.xml @@ -1,22 +1,23 @@ - - - - - + + \ No newline at end of file diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 9740d6aa52..537cdba8d8 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -44,6 +44,8 @@ PODS: - Flutter - flutter_native_splash (2.4.3): - Flutter + - flutter_secure_storage (6.0.0): + - Flutter - flutter_udid (0.0.1): - Flutter - SAMKeychain @@ -59,6 +61,9 @@ PODS: - Flutter - isar_flutter_libs (1.0.0): - Flutter + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS - MapLibre (6.5.0) - maplibre_gl (0.0.1): - Flutter @@ -130,6 +135,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - flutter_udid (from `.symlinks/plugins/flutter_udid/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) @@ -137,6 +143,7 @@ DEPENDENCIES: - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) + - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) @@ -178,6 +185,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" flutter_udid: :path: ".symlinks/plugins/flutter_udid/ios" flutter_web_auth_2: @@ -192,6 +201,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/integration_test/ios" isar_flutter_libs: :path: ".symlinks/plugins/isar_flutter_libs/ios" + local_auth_darwin: + :path: ".symlinks/plugins/local_auth_darwin/darwin" maplibre_gl: :path: ".symlinks/plugins/maplibre_gl/ios" native_video_player: @@ -233,6 +244,7 @@ SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 @@ -240,6 +252,7 @@ SPEC CHECKSUMS: image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 + local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391 MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9 native_video_player: b65c58951ede2f93d103a25366bdebca95081265 diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 38394f0f1b..e0c719fd0f 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -1,165 +1,167 @@ - - AppGroupId - $(CUSTOM_GROUP_ID) - BGTaskSchedulerPermittedIdentifiers - - app.alextran.immich.backgroundFetch - app.alextran.immich.backgroundProcessing - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - ${PRODUCT_NAME} - CFBundleDocumentTypes - - - CFBundleTypeName - ShareHandler - LSHandlerRank - Alternate - LSItemContentTypes - - public.file-url - public.image - public.text - public.movie - public.url - public.data - - - - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleLocalizations - - en - ar - ca - cs - da - de - es - fi - fr - he - hi - hu - it - ja - ko - lv - mn - nb - nl - pl - pt - ro - ru - sk - sl - sr - sv - th - uk - vi - zh - - CFBundleName - immich_mobile - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.132.3 - CFBundleSignature - ???? - CFBundleURLTypes - - - CFBundleTypeRole - Editor - CFBundleURLSchemes - - ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) - - - - CFBundleVersion - 205 - FLTEnableImpeller - - ITSAppUsesNonExemptEncryption - - LSApplicationQueriesSchemes - - https - - LSRequiresIPhoneOS - - LSSupportsOpeningDocumentsInPlace - No - MGLMapboxMetricsEnabledSettingShownInApp - - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - NSCameraUsageDescription - We need to access the camera to let you take beautiful video using this app - NSLocationAlwaysAndWhenInUseUsageDescription - We require this permission to access the local WiFi name for background upload mechanism - NSLocationUsageDescription - We require this permission to access the local WiFi name - NSLocationWhenInUseUsageDescription - We require this permission to access the local WiFi name - NSMicrophoneUsageDescription - We need to access the microphone to let you take beautiful video using this app - NSPhotoLibraryAddUsageDescription - We need to manage backup your photos album - NSPhotoLibraryUsageDescription - We need to manage backup your photos album - NSUserActivityTypes - - INSendMessageIntent - - UIApplicationSupportsIndirectInputEvents - - UIBackgroundModes - - fetch - processing - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UIStatusBarHidden - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - io.flutter.embedded_views_preview - - - + + AppGroupId + $(CUSTOM_GROUP_ID) + BGTaskSchedulerPermittedIdentifiers + + app.alextran.immich.backgroundFetch + app.alextran.immich.backgroundProcessing + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ${PRODUCT_NAME} + CFBundleDocumentTypes + + + CFBundleTypeName + ShareHandler + LSHandlerRank + Alternate + LSItemContentTypes + + public.file-url + public.image + public.text + public.movie + public.url + public.data + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + en + ar + ca + cs + da + de + es + fi + fr + he + hi + hu + it + ja + ko + lv + mn + nb + nl + pl + pt + ro + ru + sk + sl + sr + sv + th + uk + vi + zh + + CFBundleName + immich_mobile + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.132.3 + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER) + + + + CFBundleVersion + 205 + FLTEnableImpeller + + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + https + + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + No + MGLMapboxMetricsEnabledSettingShownInApp + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + We need to access the camera to let you take beautiful video using this app + NSLocationAlwaysAndWhenInUseUsageDescription + We require this permission to access the local WiFi name for background upload mechanism + NSLocationUsageDescription + We require this permission to access the local WiFi name + NSLocationWhenInUseUsageDescription + We require this permission to access the local WiFi name + NSMicrophoneUsageDescription + We need to access the microphone to let you take beautiful video using this app + NSPhotoLibraryAddUsageDescription + We need to manage backup your photos album + NSPhotoLibraryUsageDescription + We need to manage backup your photos album + NSUserActivityTypes + + INSendMessageIntent + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + fetch + processing + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIStatusBarHidden + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + io.flutter.embedded_views_preview + + NSFaceIDUsageDescription + We need to use FaceID to allow access to your locked folder + + \ No newline at end of file diff --git a/mobile/lib/constants/constants.dart b/mobile/lib/constants/constants.dart index a91e0a715d..33683afd92 100644 --- a/mobile/lib/constants/constants.dart +++ b/mobile/lib/constants/constants.dart @@ -11,3 +11,6 @@ const int kSyncEventBatchSize = 5000; // Hash batch limits const int kBatchHashFileLimit = 128; const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB + +// Secure storage keys +const String kSecuredPinCode = "secured_pin_code"; diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 3a3bf9959a..a691263a1e 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -8,3 +8,5 @@ enum TextSearchType { filename, description, } + +enum AssetVisibilityEnum { timeline, hidden, archive, locked } diff --git a/mobile/lib/entities/asset.entity.dart b/mobile/lib/entities/asset.entity.dart index 084cd1ee5d..9119d96a63 100644 --- a/mobile/lib/entities/asset.entity.dart +++ b/mobile/lib/entities/asset.entity.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' @@ -45,7 +46,8 @@ class Asset { : remote.stack?.primaryAssetId, stackCount = remote.stack?.assetCount ?? 0, stackId = remote.stack?.id, - thumbhash = remote.thumbhash; + thumbhash = remote.thumbhash, + visibility = getVisibility(remote.visibility); Asset({ this.id = Isar.autoIncrement, @@ -71,6 +73,7 @@ class Asset { this.stackCount = 0, this.isOffline = false, this.thumbhash, + this.visibility = AssetVisibilityEnum.timeline, }); @ignore @@ -173,6 +176,9 @@ class Asset { int stackCount; + @Enumerated(EnumType.ordinal) + AssetVisibilityEnum visibility; + /// Returns null if the asset has no sync access to the exif info @ignore double? get aspectRatio { @@ -349,7 +355,8 @@ class Asset { a.thumbhash != thumbhash || stackId != a.stackId || stackCount != a.stackCount || - stackPrimaryAssetId == null && a.stackPrimaryAssetId != null; + stackPrimaryAssetId == null && a.stackPrimaryAssetId != null || + visibility != a.visibility; } /// Returns a new [Asset] with values from this and merged & updated with [a] @@ -452,6 +459,7 @@ class Asset { String? stackPrimaryAssetId, int? stackCount, String? thumbhash, + AssetVisibilityEnum? visibility, }) => Asset( id: id ?? this.id, @@ -477,6 +485,7 @@ class Asset { stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackCount: stackCount ?? this.stackCount, thumbhash: thumbhash ?? this.thumbhash, + visibility: visibility ?? this.visibility, ); Future put(Isar db) async { @@ -541,8 +550,22 @@ class Asset { "isArchived": $isArchived, "isTrashed": $isTrashed, "isOffline": $isOffline, + "visibility": "$visibility", }"""; } + + static getVisibility(AssetResponseDtoVisibilityEnum visibility) { + switch (visibility) { + case AssetResponseDtoVisibilityEnum.timeline: + return AssetVisibilityEnum.timeline; + case AssetResponseDtoVisibilityEnum.archive: + return AssetVisibilityEnum.archive; + case AssetResponseDtoVisibilityEnum.hidden: + return AssetVisibilityEnum.hidden; + case AssetResponseDtoVisibilityEnum.locked: + return AssetVisibilityEnum.locked; + } + } } enum AssetType { diff --git a/mobile/lib/entities/asset.entity.g.dart b/mobile/lib/entities/asset.entity.g.dart index 07eee4825e..b558690813 100644 --- a/mobile/lib/entities/asset.entity.g.dart +++ b/mobile/lib/entities/asset.entity.g.dart @@ -118,8 +118,14 @@ const AssetSchema = CollectionSchema( name: r'updatedAt', type: IsarType.dateTime, ), - r'width': PropertySchema( + r'visibility': PropertySchema( id: 20, + name: r'visibility', + type: IsarType.byte, + enumMap: _AssetvisibilityEnumValueMap, + ), + r'width': PropertySchema( + id: 21, name: r'width', type: IsarType.int, ) @@ -256,7 +262,8 @@ void _assetSerialize( writer.writeString(offsets[17], object.thumbhash); writer.writeByte(offsets[18], object.type.index); writer.writeDateTime(offsets[19], object.updatedAt); - writer.writeInt(offsets[20], object.width); + writer.writeByte(offsets[20], object.visibility.index); + writer.writeInt(offsets[21], object.width); } Asset _assetDeserialize( @@ -288,7 +295,10 @@ Asset _assetDeserialize( type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? AssetType.other, updatedAt: reader.readDateTime(offsets[19]), - width: reader.readIntOrNull(offsets[20]), + visibility: + _AssetvisibilityValueEnumMap[reader.readByteOrNull(offsets[20])] ?? + AssetVisibilityEnum.timeline, + width: reader.readIntOrNull(offsets[21]), ); return object; } @@ -342,6 +352,9 @@ P _assetDeserializeProp

( case 19: return (reader.readDateTime(offset)) as P; case 20: + return (_AssetvisibilityValueEnumMap[reader.readByteOrNull(offset)] ?? + AssetVisibilityEnum.timeline) as P; + case 21: return (reader.readIntOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); @@ -360,6 +373,18 @@ const _AssettypeValueEnumMap = { 2: AssetType.video, 3: AssetType.audio, }; +const _AssetvisibilityEnumValueMap = { + 'timeline': 0, + 'hidden': 1, + 'archive': 2, + 'locked': 3, +}; +const _AssetvisibilityValueEnumMap = { + 0: AssetVisibilityEnum.timeline, + 1: AssetVisibilityEnum.hidden, + 2: AssetVisibilityEnum.archive, + 3: AssetVisibilityEnum.locked, +}; Id _assetGetId(Asset object) { return object.id; @@ -2477,6 +2502,59 @@ extension AssetQueryFilter on QueryBuilder { }); } + QueryBuilder visibilityEqualTo( + AssetVisibilityEnum value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'visibility', + value: value, + )); + }); + } + + QueryBuilder visibilityGreaterThan( + AssetVisibilityEnum value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'visibility', + value: value, + )); + }); + } + + QueryBuilder visibilityLessThan( + AssetVisibilityEnum value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'visibility', + value: value, + )); + }); + } + + QueryBuilder visibilityBetween( + AssetVisibilityEnum lower, + AssetVisibilityEnum upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'visibility', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder widthIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -2791,6 +2869,18 @@ extension AssetQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByVisibility() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.asc); + }); + } + + QueryBuilder sortByVisibilityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.desc); + }); + } + QueryBuilder sortByWidth() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'width', Sort.asc); @@ -3057,6 +3147,18 @@ extension AssetQuerySortThenBy on QueryBuilder { }); } + QueryBuilder thenByVisibility() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.asc); + }); + } + + QueryBuilder thenByVisibilityDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'visibility', Sort.desc); + }); + } + QueryBuilder thenByWidth() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'width', Sort.asc); @@ -3201,6 +3303,12 @@ extension AssetQueryWhereDistinct on QueryBuilder { }); } + QueryBuilder distinctByVisibility() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'visibility'); + }); + } + QueryBuilder distinctByWidth() { return QueryBuilder.apply(this, (query) { return query.addDistinctBy(r'width'); @@ -3335,6 +3443,13 @@ extension AssetQueryProperty on QueryBuilder { }); } + QueryBuilder + visibilityProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'visibility'); + }); + } + QueryBuilder widthProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'width'); diff --git a/mobile/lib/interfaces/asset_api.interface.dart b/mobile/lib/interfaces/asset_api.interface.dart index fe3320c9bb..a17e607d83 100644 --- a/mobile/lib/interfaces/asset_api.interface.dart +++ b/mobile/lib/interfaces/asset_api.interface.dart @@ -1,3 +1,4 @@ +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; abstract interface class IAssetApiRepository { @@ -15,4 +16,9 @@ abstract interface class IAssetApiRepository { // Future delete(String id); Future> search({List personIds = const []}); + + Future updateVisibility( + List list, + AssetVisibilityEnum visibility, + ); } diff --git a/mobile/lib/interfaces/auth_api.interface.dart b/mobile/lib/interfaces/auth_api.interface.dart index 0a4b235ff3..bb9a8b5a2c 100644 --- a/mobile/lib/interfaces/auth_api.interface.dart +++ b/mobile/lib/interfaces/auth_api.interface.dart @@ -6,4 +6,9 @@ abstract interface class IAuthApiRepository { Future logout(); Future changePassword(String newPassword); + + Future unlockPinCode(String pinCode); + Future lockPinCode(); + + Future setupPinCode(String pinCode); } diff --git a/mobile/lib/interfaces/biometric.interface.dart b/mobile/lib/interfaces/biometric.interface.dart new file mode 100644 index 0000000000..e410c8e26e --- /dev/null +++ b/mobile/lib/interfaces/biometric.interface.dart @@ -0,0 +1,6 @@ +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; + +abstract interface class IBiometricRepository { + Future getStatus(); + Future authenticate(String? message); +} diff --git a/mobile/lib/interfaces/secure_storage.interface.dart b/mobile/lib/interfaces/secure_storage.interface.dart new file mode 100644 index 0000000000..81230e0abd --- /dev/null +++ b/mobile/lib/interfaces/secure_storage.interface.dart @@ -0,0 +1,5 @@ +abstract interface class ISecureStorageRepository { + Future read(String key); + Future write(String key, String value); + Future delete(String key); +} diff --git a/mobile/lib/interfaces/timeline.interface.dart b/mobile/lib/interfaces/timeline.interface.dart index bc486a785f..3a4cce3cb6 100644 --- a/mobile/lib/interfaces/timeline.interface.dart +++ b/mobile/lib/interfaces/timeline.interface.dart @@ -31,4 +31,9 @@ abstract class ITimelineRepository { ); Stream watchAssetSelectionTimeline(String userId); + + Stream watchLockedTimeline( + String userId, + GroupAssetsBy groupAssetsBy, + ); } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index c39d5e3a66..3c7c1fbe4d 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -19,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; import 'package:immich_mobile/providers/locale_provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/routing/router.dart'; -import 'package:immich_mobile/routing/tab_navigation_observer.dart'; +import 'package:immich_mobile/routing/app_navigation_observer.dart'; import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart'; @@ -219,7 +219,7 @@ class ImmichAppState extends ConsumerState ), routeInformationParser: router.defaultRouteParser(), routerDelegate: router.delegate( - navigatorObservers: () => [TabNavigationObserver(ref: ref)], + navigatorObservers: () => [AppNavigationObserver(ref: ref)], ), ), ), diff --git a/mobile/lib/models/auth/biometric_status.model.dart b/mobile/lib/models/auth/biometric_status.model.dart new file mode 100644 index 0000000000..3057f06e9c --- /dev/null +++ b/mobile/lib/models/auth/biometric_status.model.dart @@ -0,0 +1,38 @@ +import 'package:collection/collection.dart'; +import 'package:local_auth/local_auth.dart'; + +class BiometricStatus { + final List availableBiometrics; + final bool canAuthenticate; + + const BiometricStatus({ + required this.availableBiometrics, + required this.canAuthenticate, + }); + + @override + String toString() => + 'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)'; + + BiometricStatus copyWith({ + List? availableBiometrics, + bool? canAuthenticate, + }) { + return BiometricStatus( + availableBiometrics: availableBiometrics ?? this.availableBiometrics, + canAuthenticate: canAuthenticate ?? this.canAuthenticate, + ); + } + + @override + bool operator ==(covariant BiometricStatus other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.availableBiometrics, availableBiometrics) && + other.canAuthenticate == canAuthenticate; + } + + @override + int get hashCode => availableBiometrics.hashCode ^ canAuthenticate.hashCode; +} diff --git a/mobile/lib/pages/library/library.page.dart b/mobile/lib/pages/library/library.page.dart index 1dc336d204..50126ed1a8 100644 --- a/mobile/lib/pages/library/library.page.dart +++ b/mobile/lib/pages/library/library.page.dart @@ -140,6 +140,19 @@ class QuickAccessButtons extends ConsumerWidget { ), onTap: () => context.pushRoute(FolderRoute()), ), + ListTile( + leading: const Icon( + Icons.lock_outline_rounded, + size: 26, + ), + title: Text( + 'locked_folder'.tr(), + style: context.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + onTap: () => context.pushRoute(const LockedRoute()), + ), ListTile( leading: const Icon( Icons.group_outlined, diff --git a/mobile/lib/pages/library/locked/locked.page.dart b/mobile/lib/pages/library/locked/locked.page.dart new file mode 100644 index 0000000000..eef12a7107 --- /dev/null +++ b/mobile/lib/pages/library/locked/locked.page.dart @@ -0,0 +1,95 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/timeline.provider.dart'; +import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; + +@RoutePage() +class LockedPage extends HookConsumerWidget { + const LockedPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appLifeCycle = useAppLifecycleState(); + final showOverlay = useState(false); + final authProviderNotifier = ref.read(authProvider.notifier); + // lock the page when it is destroyed + useEffect( + () { + return () { + authProviderNotifier.lockPinCode(); + }; + }, + [], + ); + + useEffect( + () { + if (context.mounted) { + if (appLifeCycle == AppLifecycleState.resumed) { + showOverlay.value = false; + } else { + showOverlay.value = true; + } + } + + return null; + }, + [appLifeCycle], + ); + + return Scaffold( + appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(), + body: showOverlay.value + ? const SizedBox() + : MultiselectGrid( + renderListProvider: lockedTimelineProvider, + topWidget: Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: Text( + 'no_locked_photos_message'.tr(), + style: context.textTheme.labelLarge, + ), + ), + ), + editEnabled: false, + favoriteEnabled: false, + unfavorite: false, + archiveEnabled: false, + stackEnabled: false, + unarchive: false, + ), + ); + } +} + +class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget { + const LockPageAppBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppBar( + leading: IconButton( + onPressed: () { + ref.read(authProvider.notifier).lockPinCode(); + context.maybePop(); + }, + icon: const Icon(Icons.arrow_back_ios_rounded), + ), + centerTitle: true, + automaticallyImplyLeading: false, + title: const Text( + 'locked_folder', + ).tr(), + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/mobile/lib/pages/library/locked/pin_auth.page.dart b/mobile/lib/pages/library/locked/pin_auth.page.dart new file mode 100644 index 0000000000..cca0e3b7ac --- /dev/null +++ b/mobile/lib/pages/library/locked/pin_auth.page.dart @@ -0,0 +1,127 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/local_auth.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/forms/pin_registration_form.dart'; +import 'package:immich_mobile/widgets/forms/pin_verification_form.dart'; + +@RoutePage() +class PinAuthPage extends HookConsumerWidget { + final bool createPinCode; + + const PinAuthPage({super.key, this.createPinCode = false}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localAuthState = ref.watch(localAuthProvider); + final showPinRegistrationForm = useState(createPinCode); + + Future registerBiometric(String pinCode) async { + final isRegistered = + await ref.read(localAuthProvider.notifier).registerBiometric( + context, + pinCode, + ); + + if (isRegistered) { + context.showSnackBar( + SnackBar( + content: Text( + 'biometric_auth_enabled'.tr(), + style: context.textTheme.labelLarge, + ), + duration: const Duration(seconds: 3), + backgroundColor: context.colorScheme.primaryContainer, + ), + ); + + context.replaceRoute(const LockedRoute()); + } + } + + enableBiometricAuth() { + showDialog( + context: context, + builder: (buildContext) { + return SimpleDialog( + children: [ + Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + PinVerificationForm( + description: 'enable_biometric_auth_description'.tr(), + onSuccess: (pinCode) { + Navigator.pop(buildContext); + registerBiometric(pinCode); + }, + autoFocus: true, + icon: Icons.fingerprint_rounded, + successIcon: Icons.fingerprint_rounded, + ), + ], + ), + ), + ], + ); + }, + ); + } + + return Scaffold( + appBar: AppBar( + title: Text('locked_folder'.tr()), + ), + body: ListView( + shrinkWrap: true, + children: [ + Padding( + padding: const EdgeInsets.only(top: 36.0), + child: showPinRegistrationForm.value + ? Center( + child: PinRegistrationForm( + onDone: () => showPinRegistrationForm.value = false, + ), + ) + : Column( + children: [ + Center( + child: PinVerificationForm( + autoFocus: true, + onSuccess: (_) => + context.replaceRoute(const LockedRoute()), + ), + ), + const SizedBox(height: 24), + if (localAuthState.canAuthenticate) ...[ + Padding( + padding: const EdgeInsets.only(right: 16.0), + child: TextButton.icon( + icon: const Icon( + Icons.fingerprint, + size: 28, + ), + onPressed: enableBiometricAuth, + label: Text( + 'use_biometric'.tr(), + style: context.textTheme.labelLarge?.copyWith( + color: context.primaryColor, + fontSize: 18, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/providers/asset.provider.dart b/mobile/lib/providers/asset.provider.dart index a35ab10bf3..5b77da90f3 100644 --- a/mobile/lib/providers/asset.provider.dart +++ b/mobile/lib/providers/asset.provider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; @@ -170,6 +171,13 @@ class AssetNotifier extends StateNotifier { status ??= !assets.every((a) => a.isArchived); return _assetService.changeArchiveStatus(assets, status); } + + Future setLockedView( + List selection, + AssetVisibilityEnum visibility, + ) { + return _assetService.setVisibility(selection, visibility); + } } final assetDetailProvider = diff --git a/mobile/lib/providers/auth.provider.dart b/mobile/lib/providers/auth.provider.dart index 297b3a99fe..8c783395cd 100644 --- a/mobile/lib/providers/auth.provider.dart +++ b/mobile/lib/providers/auth.provider.dart @@ -188,4 +188,16 @@ class AuthNotifier extends StateNotifier { Future setOpenApiServiceEndpoint() { return _authService.setOpenApiServiceEndpoint(); } + + Future unlockPinCode(String pinCode) { + return _authService.unlockPinCode(pinCode); + } + + Future lockPinCode() { + return _authService.lockPinCode(); + } + + Future setupPinCode(String pinCode) { + return _authService.setupPinCode(pinCode); + } } diff --git a/mobile/lib/providers/local_auth.provider.dart b/mobile/lib/providers/local_auth.provider.dart new file mode 100644 index 0000000000..6f7ca5eb71 --- /dev/null +++ b/mobile/lib/providers/local_auth.provider.dart @@ -0,0 +1,97 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; +import 'package:immich_mobile/services/local_auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:logging/logging.dart'; + +final localAuthProvider = + StateNotifierProvider((ref) { + return LocalAuthNotifier( + ref.watch(localAuthServiceProvider), + ref.watch(secureStorageServiceProvider), + ); +}); + +class LocalAuthNotifier extends StateNotifier { + final LocalAuthService _localAuthService; + final SecureStorageService _secureStorageService; + + final _log = Logger("LocalAuthNotifier"); + + LocalAuthNotifier(this._localAuthService, this._secureStorageService) + : super( + const BiometricStatus( + availableBiometrics: [], + canAuthenticate: false, + ), + ) { + _localAuthService.getStatus().then((value) { + state = state.copyWith( + canAuthenticate: value.canAuthenticate, + availableBiometrics: value.availableBiometrics, + ); + }); + } + + Future registerBiometric(BuildContext context, String pinCode) async { + final isAuthenticated = + await authenticate(context, 'Authenticate to enable biometrics'); + + if (!isAuthenticated) { + return false; + } + + await _secureStorageService.write(kSecuredPinCode, pinCode); + + return true; + } + + Future authenticate(BuildContext context, String? message) async { + String errorMessage = ""; + + try { + return await _localAuthService.authenticate(message); + } on PlatformException catch (error) { + switch (error.code) { + case "NotEnrolled": + _log.warning("User is not enrolled in biometrics"); + errorMessage = "biometric_no_options".tr(); + break; + case "NotAvailable": + _log.warning("Biometric authentication is not available"); + errorMessage = "biometric_not_available".tr(); + break; + case "LockedOut": + _log.warning("User is locked out of biometric authentication"); + errorMessage = "biometric_locked_out".tr(); + break; + default: + _log.warning("Failed to authenticate with unknown reason"); + errorMessage = 'failed_to_authenticate'.tr(); + } + } catch (error) { + _log.warning("Error during authentication: $error"); + errorMessage = 'failed_to_authenticate'.tr(); + } finally { + if (errorMessage.isNotEmpty) { + context.showSnackBar( + SnackBar( + content: Text( + errorMessage, + style: context.textTheme.labelLarge, + ), + duration: const Duration(seconds: 3), + backgroundColor: context.colorScheme.errorContainer, + ), + ); + } + } + + return false; + } +} diff --git a/mobile/lib/providers/routes.provider.dart b/mobile/lib/providers/routes.provider.dart new file mode 100644 index 0000000000..a5b903e312 --- /dev/null +++ b/mobile/lib/providers/routes.provider.dart @@ -0,0 +1,3 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final inLockedViewProvider = StateProvider((ref) => false); diff --git a/mobile/lib/providers/secure_storage.provider.dart b/mobile/lib/providers/secure_storage.provider.dart new file mode 100644 index 0000000000..0194e527e9 --- /dev/null +++ b/mobile/lib/providers/secure_storage.provider.dart @@ -0,0 +1,10 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final secureStorageProvider = + StateNotifierProvider((ref) { + return SecureStorageProvider(); +}); + +class SecureStorageProvider extends StateNotifier { + SecureStorageProvider() : super(null); +} diff --git a/mobile/lib/providers/timeline.provider.dart b/mobile/lib/providers/timeline.provider.dart index f857d8aa6c..b2c763cdfa 100644 --- a/mobile/lib/providers/timeline.provider.dart +++ b/mobile/lib/providers/timeline.provider.dart @@ -73,3 +73,8 @@ final assetsTimelineProvider = null, ); }); + +final lockedTimelineProvider = StreamProvider((ref) { + final timelineService = ref.watch(timelineServiceProvider); + return timelineService.watchLockedTimelineProvider(); +}); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index f4fcd8a6dd..45442c2d61 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -1,4 +1,5 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/providers/api.provider.dart'; @@ -48,4 +49,27 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository { } return result; } + + @override + Future updateVisibility( + List ids, + AssetVisibilityEnum visibility, + ) async { + return _api.updateAssets( + AssetBulkUpdateDto(ids: ids, visibility: _mapVisibility(visibility)), + ); + } + + _mapVisibility(AssetVisibilityEnum visibility) { + switch (visibility) { + case AssetVisibilityEnum.timeline: + return AssetVisibility.timeline; + case AssetVisibilityEnum.hidden: + return AssetVisibility.hidden; + case AssetVisibilityEnum.locked: + return AssetVisibility.locked; + case AssetVisibilityEnum.archive: + return AssetVisibility.archive; + } + } } diff --git a/mobile/lib/repositories/auth_api.repository.dart b/mobile/lib/repositories/auth_api.repository.dart index f3a1d52de3..4015ffd7bc 100644 --- a/mobile/lib/repositories/auth_api.repository.dart +++ b/mobile/lib/repositories/auth_api.repository.dart @@ -55,4 +55,26 @@ class AuthApiRepository extends ApiRepository implements IAuthApiRepository { userId: dto.userId, ); } + + @override + Future unlockPinCode(String pinCode) async { + try { + await _apiService.authenticationApi + .unlockAuthSession(SessionUnlockDto(pinCode: pinCode)); + return true; + } catch (_) { + return false; + } + } + + @override + Future setupPinCode(String pinCode) { + return _apiService.authenticationApi + .setupPinCode(PinCodeSetupDto(pinCode: pinCode)); + } + + @override + Future lockPinCode() { + return _apiService.authenticationApi.lockAuthSession(); + } } diff --git a/mobile/lib/repositories/biometric.repository.dart b/mobile/lib/repositories/biometric.repository.dart new file mode 100644 index 0000000000..588fa44797 --- /dev/null +++ b/mobile/lib/repositories/biometric.repository.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/biometric.interface.dart'; +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; +import 'package:local_auth/local_auth.dart'; + +final biometricRepositoryProvider = + Provider((ref) => BiometricRepository(LocalAuthentication())); + +class BiometricRepository implements IBiometricRepository { + final LocalAuthentication _localAuth; + + BiometricRepository(this._localAuth); + + @override + Future getStatus() async { + final bool canAuthenticateWithBiometrics = + await _localAuth.canCheckBiometrics; + final bool canAuthenticate = + canAuthenticateWithBiometrics || await _localAuth.isDeviceSupported(); + final availableBiometric = await _localAuth.getAvailableBiometrics(); + + return BiometricStatus( + canAuthenticate: canAuthenticate, + availableBiometrics: availableBiometric, + ); + } + + @override + Future authenticate(String? message) async { + return _localAuth.authenticate( + localizedReason: message ?? 'please_auth_to_access'.tr(), + ); + } +} diff --git a/mobile/lib/repositories/secure_storage.repository.dart b/mobile/lib/repositories/secure_storage.repository.dart new file mode 100644 index 0000000000..fc641bcc91 --- /dev/null +++ b/mobile/lib/repositories/secure_storage.repository.dart @@ -0,0 +1,27 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/secure_storage.interface.dart'; + +final secureStorageRepositoryProvider = + Provider((ref) => SecureStorageRepository(const FlutterSecureStorage())); + +class SecureStorageRepository implements ISecureStorageRepository { + final FlutterSecureStorage _secureStorage; + + SecureStorageRepository(this._secureStorage); + + @override + Future read(String key) { + return _secureStorage.read(key: key); + } + + @override + Future write(String key, String value) { + return _secureStorage.write(key: key, value: value); + } + + @override + Future delete(String key) { + return _secureStorage.delete(key: key); + } +} diff --git a/mobile/lib/repositories/timeline.repository.dart b/mobile/lib/repositories/timeline.repository.dart index 319ce3e5b4..f48b749767 100644 --- a/mobile/lib/repositories/timeline.repository.dart +++ b/mobile/lib/repositories/timeline.repository.dart @@ -45,8 +45,8 @@ class TimelineRepository extends DatabaseRepository .where() .ownerIdEqualToAnyChecksum(fastHash(userId)) .filter() - .isArchivedEqualTo(true) .isTrashedEqualTo(false) + .visibilityEqualTo(AssetVisibilityEnum.archive) .sortByFileCreatedAtDesc(); return _watchRenderList(query, GroupAssetsBy.none); @@ -59,6 +59,8 @@ class TimelineRepository extends DatabaseRepository .ownerIdEqualToAnyChecksum(fastHash(userId)) .filter() .isFavoriteEqualTo(true) + .not() + .visibilityEqualTo(AssetVisibilityEnum.locked) .isTrashedEqualTo(false) .sortByFileCreatedAtDesc(); @@ -94,8 +96,8 @@ class TimelineRepository extends DatabaseRepository Stream watchAllVideosTimeline() { final query = db.assets .filter() - .isArchivedEqualTo(false) .isTrashedEqualTo(false) + .visibilityEqualTo(AssetVisibilityEnum.timeline) .typeEqualTo(AssetType.video) .sortByFileCreatedAtDesc(); @@ -111,9 +113,9 @@ class TimelineRepository extends DatabaseRepository .where() .ownerIdEqualToAnyChecksum(fastHash(userId)) .filter() - .isArchivedEqualTo(false) .isTrashedEqualTo(false) .stackPrimaryAssetIdIsNull() + .visibilityEqualTo(AssetVisibilityEnum.timeline) .sortByFileCreatedAtDesc(); return _watchRenderList(query, groupAssetByOption); @@ -129,8 +131,8 @@ class TimelineRepository extends DatabaseRepository .where() .anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id)) .filter() - .isArchivedEqualTo(false) .isTrashedEqualTo(false) + .visibilityEqualTo(AssetVisibilityEnum.timeline) .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); return _watchRenderList(query, groupAssetByOption); @@ -151,6 +153,7 @@ class TimelineRepository extends DatabaseRepository .remoteIdIsNotNull() .filter() .ownerIdEqualTo(fastHash(userId)) + .visibilityEqualTo(AssetVisibilityEnum.timeline) .isTrashedEqualTo(false) .stackPrimaryAssetIdIsNull() .sortByFileCreatedAtDesc(); @@ -158,6 +161,22 @@ class TimelineRepository extends DatabaseRepository return _watchRenderList(query, GroupAssetsBy.none); } + @override + Stream watchLockedTimeline( + String userId, + GroupAssetsBy getGroupByOption, + ) { + final query = db.assets + .where() + .ownerIdEqualToAnyChecksum(fastHash(userId)) + .filter() + .visibilityEqualTo(AssetVisibilityEnum.locked) + .isTrashedEqualTo(false) + .sortByFileCreatedAtDesc(); + + return _watchRenderList(query, getGroupByOption); + } + Stream _watchRenderList( QueryBuilder query, GroupAssetsBy groupAssetsBy, diff --git a/mobile/lib/routing/app_navigation_observer.dart b/mobile/lib/routing/app_navigation_observer.dart new file mode 100644 index 0000000000..44662c0b8b --- /dev/null +++ b/mobile/lib/routing/app_navigation_observer.dart @@ -0,0 +1,52 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; + +class AppNavigationObserver extends AutoRouterObserver { + /// Riverpod Instance + final WidgetRef ref; + + AppNavigationObserver({ + required this.ref, + }); + + @override + Future didChangeTabRoute( + TabPageRoute route, + TabPageRoute previousRoute, + ) async { + Future( + () => ref.read(inLockedViewProvider.notifier).state = false, + ); + } + + @override + void didPush(Route route, Route? previousRoute) { + _handleLockedViewState(route, previousRoute); + } + + _handleLockedViewState(Route route, Route? previousRoute) { + final isInLockedView = ref.read(inLockedViewProvider); + final isFromLockedViewToDetailView = + route.settings.name == GalleryViewerRoute.name && + previousRoute?.settings.name == LockedRoute.name; + + final isFromDetailViewToInfoPanelView = route.settings.name == null && + previousRoute?.settings.name == GalleryViewerRoute.name && + isInLockedView; + + if (route.settings.name == LockedRoute.name || + isFromLockedViewToDetailView || + isFromDetailViewToInfoPanelView) { + Future( + () => ref.read(inLockedViewProvider.notifier).state = true, + ); + } else { + Future( + () => ref.read(inLockedViewProvider.notifier).state = false, + ); + } + } +} diff --git a/mobile/lib/routing/locked_guard.dart b/mobile/lib/routing/locked_guard.dart new file mode 100644 index 0000000000..d731c7942c --- /dev/null +++ b/mobile/lib/routing/locked_guard.dart @@ -0,0 +1,89 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/services.dart'; +import 'package:immich_mobile/constants/constants.dart'; +import 'package:immich_mobile/routing/router.dart'; + +import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/local_auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; +import 'package:local_auth/error_codes.dart' as auth_error; +import 'package:logging/logging.dart'; +// ignore: import_rule_openapi +import 'package:openapi/api.dart'; + +class LockedGuard extends AutoRouteGuard { + final ApiService _apiService; + final SecureStorageService _secureStorageService; + final LocalAuthService _localAuth; + final _log = Logger("AuthGuard"); + + LockedGuard( + this._apiService, + this._secureStorageService, + this._localAuth, + ); + + @override + void onNavigation(NavigationResolver resolver, StackRouter router) async { + final authStatus = await _apiService.authenticationApi.getAuthStatus(); + + if (authStatus == null) { + resolver.next(false); + return; + } + + /// Check if a pincode has been created but this user. Show the form to create if not exist + if (!authStatus.pinCode) { + router.push(PinAuthRoute(createPinCode: true)); + } + + if (authStatus.isElevated) { + resolver.next(true); + return; + } + + /// Check if the user has the pincode saved in secure storage, meaning + /// the user has enabled the biometric authentication + final securePinCode = await _secureStorageService.read(kSecuredPinCode); + if (securePinCode == null) { + router.push(PinAuthRoute()); + return; + } + + try { + final bool isAuth = await _localAuth.authenticate(); + + if (!isAuth) { + resolver.next(false); + return; + } + + await _apiService.authenticationApi.unlockAuthSession( + SessionUnlockDto(pinCode: securePinCode), + ); + + resolver.next(true); + } on PlatformException catch (error) { + switch (error.code) { + case auth_error.notAvailable: + _log.severe("notAvailable: $error"); + break; + case auth_error.notEnrolled: + _log.severe("not enrolled"); + break; + default: + _log.severe("error"); + break; + } + + resolver.next(false); + } on ApiException { + // PIN code has changed, need to re-enter to access + await _secureStorageService.delete(kSecuredPinCode); + router.push(PinAuthRoute()); + } catch (error) { + _log.severe("Failed to access locked page", error); + resolver.next(false); + } + } +} diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index fcfe7e59bd..317ce7cc54 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -39,6 +39,8 @@ import 'package:immich_mobile/pages/library/favorite.page.dart'; import 'package:immich_mobile/pages/library/folder/folder.page.dart'; import 'package:immich_mobile/pages/library/library.page.dart'; import 'package:immich_mobile/pages/library/local_albums.page.dart'; +import 'package:immich_mobile/pages/library/locked/locked.page.dart'; +import 'package:immich_mobile/pages/library/locked/pin_auth.page.dart'; import 'package:immich_mobile/pages/library/partner/partner.page.dart'; import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; import 'package:immich_mobile/pages/library/people/people_collection.page.dart'; @@ -67,24 +69,41 @@ import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/backup_permission_guard.dart'; import 'package:immich_mobile/routing/custom_transition_builders.dart'; import 'package:immich_mobile/routing/duplicate_guard.dart'; +import 'package:immich_mobile/routing/locked_guard.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/services/local_auth.service.dart'; +import 'package:immich_mobile/services/secure_storage.service.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; part 'router.gr.dart'; +final appRouterProvider = Provider( + (ref) => AppRouter( + ref.watch(apiServiceProvider), + ref.watch(galleryPermissionNotifier.notifier), + ref.watch(secureStorageServiceProvider), + ref.watch(localAuthServiceProvider), + ), +); + @AutoRouterConfig(replaceInRouteName: 'Page,Route') class AppRouter extends RootStackRouter { late final AuthGuard _authGuard; late final DuplicateGuard _duplicateGuard; late final BackupPermissionGuard _backupPermissionGuard; + late final LockedGuard _lockedGuard; AppRouter( ApiService apiService, GalleryPermissionNotifier galleryPermissionNotifier, + SecureStorageService secureStorageService, + LocalAuthService localAuthService, ) { _authGuard = AuthGuard(apiService); _duplicateGuard = DuplicateGuard(); + _lockedGuard = + LockedGuard(apiService, secureStorageService, localAuthService); _backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier); } @@ -289,12 +308,13 @@ class AppRouter extends RootStackRouter { page: ShareIntentRoute.page, guards: [_authGuard, _duplicateGuard], ), + AutoRoute( + page: LockedRoute.page, + guards: [_authGuard, _lockedGuard, _duplicateGuard], + ), + AutoRoute( + page: PinAuthRoute.page, + guards: [_authGuard, _duplicateGuard], + ), ]; } - -final appRouterProvider = Provider( - (ref) => AppRouter( - ref.watch(apiServiceProvider), - ref.watch(galleryPermissionNotifier.notifier), - ), -); diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 01ab3fa13c..da488779e6 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -956,6 +956,25 @@ class LocalAlbumsRoute extends PageRouteInfo { ); } +/// generated route for +/// [LockedPage] +class LockedRoute extends PageRouteInfo { + const LockedRoute({List? children}) + : super( + LockedRoute.name, + initialChildren: children, + ); + + static const String name = 'LockedRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + return const LockedPage(); + }, + ); +} + /// generated route for /// [LoginPage] class LoginRoute extends PageRouteInfo { @@ -1359,6 +1378,53 @@ class PhotosRoute extends PageRouteInfo { ); } +/// generated route for +/// [PinAuthPage] +class PinAuthRoute extends PageRouteInfo { + PinAuthRoute({ + Key? key, + bool createPinCode = false, + List? children, + }) : super( + PinAuthRoute.name, + args: PinAuthRouteArgs( + key: key, + createPinCode: createPinCode, + ), + initialChildren: children, + ); + + static const String name = 'PinAuthRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = + data.argsAs(orElse: () => const PinAuthRouteArgs()); + return PinAuthPage( + key: args.key, + createPinCode: args.createPinCode, + ); + }, + ); +} + +class PinAuthRouteArgs { + const PinAuthRouteArgs({ + this.key, + this.createPinCode = false, + }); + + final Key? key; + + final bool createPinCode; + + @override + String toString() { + return 'PinAuthRouteArgs{key: $key, createPinCode: $createPinCode}'; + } +} + /// generated route for /// [PlacesCollectionPage] class PlacesCollectionRoute extends PageRouteInfo { diff --git a/mobile/lib/routing/tab_navigation_observer.dart b/mobile/lib/routing/tab_navigation_observer.dart deleted file mode 100644 index d95820885e..0000000000 --- a/mobile/lib/routing/tab_navigation_observer.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:immich_mobile/providers/asset.provider.dart'; -import 'package:immich_mobile/providers/infrastructure/user.provider.dart'; -import 'package:immich_mobile/providers/memory.provider.dart'; -import 'package:immich_mobile/providers/server_info.provider.dart'; - -class TabNavigationObserver extends AutoRouterObserver { - /// Riverpod Instance - final WidgetRef ref; - - TabNavigationObserver({ - required this.ref, - }); - - @override - Future didChangeTabRoute( - TabPageRoute route, - TabPageRoute previousRoute, - ) async { - if (route.name == 'HomeRoute') { - ref.invalidate(memoryFutureProvider); - Future(() => ref.read(assetProvider.notifier).getAllAsset()); - - // Update user info - try { - ref.read(userServiceProvider).refreshMyUser(); - ref.read(serverInfoProvider.notifier).getServerVersion(); - } catch (e) { - debugPrint("Error refreshing user info $e"); - } - } - } -} diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 8a24e72fbe..a52d6e6368 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/interfaces/exif.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -239,6 +240,9 @@ class AssetService { for (var element in assets) { element.isArchived = isArchived; + element.visibility = isArchived + ? AssetVisibilityEnum.archive + : AssetVisibilityEnum.timeline; } await _syncService.upsertAssetsWithExif(assets); @@ -458,6 +462,7 @@ class AssetService { bool shouldDeletePermanently = false, }) async { final candidates = assets.where((a) => a.isRemote); + if (candidates.isEmpty) { return; } @@ -475,6 +480,7 @@ class AssetService { .where((asset) => asset.storage == AssetState.merged) .map((asset) { asset.remoteId = null; + asset.visibility = AssetVisibilityEnum.timeline; return asset; }) : assets.where((asset) => asset.isRemote).map((asset) { @@ -529,4 +535,21 @@ class AssetService { final me = _userService.getMyUser(); return _assetRepository.getMotionAssets(me.id); } + + Future setVisibility( + List assets, + AssetVisibilityEnum visibility, + ) async { + await _assetApiRepository.updateVisibility( + assets.map((asset) => asset.remoteId!).toList(), + visibility, + ); + + final updatedAssets = assets.map((asset) { + asset.visibility = visibility; + return asset; + }).toList(); + + await _assetRepository.updateAll(updatedAssets); + } } diff --git a/mobile/lib/services/auth.service.dart b/mobile/lib/services/auth.service.dart index ec053c078b..41709b714c 100644 --- a/mobile/lib/services/auth.service.dart +++ b/mobile/lib/services/auth.service.dart @@ -201,4 +201,16 @@ class AuthService { return null; } + + Future unlockPinCode(String pinCode) { + return _authApiRepository.unlockPinCode(pinCode); + } + + Future lockPinCode() { + return _authApiRepository.lockPinCode(); + } + + Future setupPinCode(String pinCode) { + return _authApiRepository.setupPinCode(pinCode); + } } diff --git a/mobile/lib/services/local_auth.service.dart b/mobile/lib/services/local_auth.service.dart new file mode 100644 index 0000000000..f797e9065a --- /dev/null +++ b/mobile/lib/services/local_auth.service.dart @@ -0,0 +1,26 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/biometric.interface.dart'; +import 'package:immich_mobile/models/auth/biometric_status.model.dart'; +import 'package:immich_mobile/repositories/biometric.repository.dart'; + +final localAuthServiceProvider = Provider( + (ref) => LocalAuthService( + ref.watch(biometricRepositoryProvider), + ), +); + +class LocalAuthService { + // final _log = Logger("LocalAuthService"); + + final IBiometricRepository _biometricRepository; + + LocalAuthService(this._biometricRepository); + + Future getStatus() { + return _biometricRepository.getStatus(); + } + + Future authenticate([String? message]) async { + return _biometricRepository.authenticate(message); + } +} diff --git a/mobile/lib/services/memory.service.dart b/mobile/lib/services/memory.service.dart index efd38f1140..d6c44278c7 100644 --- a/mobile/lib/services/memory.service.dart +++ b/mobile/lib/services/memory.service.dart @@ -1,10 +1,10 @@ -import 'package:easy_localization/easy_localization.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/services/api.service.dart'; +import 'package:immich_mobile/utils/translation.dart'; import 'package:logging/logging.dart'; final memoryServiceProvider = StateProvider((ref) { @@ -40,10 +40,7 @@ class MemoryService { .getAllByRemoteId(memory.assets.map((e) => e.id)); final yearsAgo = now.year - memory.data.year; if (dbAssets.isNotEmpty) { - final String title = yearsAgo <= 1 - ? 'memories_year_ago'.tr() - : 'memories_years_ago' - .tr(namedArgs: {'years': yearsAgo.toString()}); + final String title = t('years_ago', {'years': yearsAgo.toString()}); memories.add( Memory( title: title, diff --git a/mobile/lib/services/secure_storage.service.dart b/mobile/lib/services/secure_storage.service.dart new file mode 100644 index 0000000000..77803f29c3 --- /dev/null +++ b/mobile/lib/services/secure_storage.service.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/interfaces/secure_storage.interface.dart'; +import 'package:immich_mobile/repositories/secure_storage.repository.dart'; + +final secureStorageServiceProvider = Provider( + (ref) => SecureStorageService( + ref.watch(secureStorageRepositoryProvider), + ), +); + +class SecureStorageService { + // final _log = Logger("LocalAuthService"); + + final ISecureStorageRepository _secureStorageRepository; + + SecureStorageService(this._secureStorageRepository); + + Future write(String key, String value) async { + await _secureStorageRepository.write(key, value); + } + + Future delete(String key) async { + await _secureStorageRepository.delete(key); + } + + Future read(String key) async { + return _secureStorageRepository.read(key); + } +} diff --git a/mobile/lib/services/timeline.service.dart b/mobile/lib/services/timeline.service.dart index 4e91d27a7c..7ecad43ca7 100644 --- a/mobile/lib/services/timeline.service.dart +++ b/mobile/lib/services/timeline.service.dart @@ -105,4 +105,13 @@ class TimelineService { return GroupAssetsBy .values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)]; } + + Stream watchLockedTimelineProvider() async* { + final user = _userService.getMyUser(); + + yield* _timelineRepository.watchLockedTimeline( + user.id, + _getGroupByOption(), + ); + } } diff --git a/mobile/lib/theme/theme_data.dart b/mobile/lib/theme/theme_data.dart index 2a593ffb38..a351b09093 100644 --- a/mobile/lib/theme/theme_data.dart +++ b/mobile/lib/theme/theme_data.dart @@ -42,7 +42,7 @@ ThemeData getThemeData({ titleTextStyle: TextStyle( color: colorScheme.primary, fontFamily: _getFontFamilyFromLocale(locale), - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, fontSize: 18, ), backgroundColor: @@ -54,28 +54,28 @@ ThemeData getThemeData({ ), textTheme: const TextTheme( displayLarge: TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, + fontSize: 18, + fontWeight: FontWeight.w600, ), displayMedium: TextStyle( fontSize: 14, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), displaySmall: TextStyle( fontSize: 12, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), titleSmall: TextStyle( fontSize: 16.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), titleMedium: TextStyle( fontSize: 18.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), titleLarge: TextStyle( fontSize: 26.0, - fontWeight: FontWeight.bold, + fontWeight: FontWeight.w600, ), ), elevatedButtonTheme: ElevatedButtonThemeData( diff --git a/mobile/lib/utils/migration.dart b/mobile/lib/utils/migration.dart index 6a09f79ce2..4519c6d803 100644 --- a/mobile/lib/utils/migration.dart +++ b/mobile/lib/utils/migration.dart @@ -20,7 +20,7 @@ import 'package:isar/isar.dart'; // ignore: import_rule_photo_manager import 'package:photo_manager/photo_manager.dart'; -const int targetVersion = 10; +const int targetVersion = 11; Future migrateDatabaseIfNeeded(Isar db) async { final int version = Store.get(StoreKey.version, targetVersion); diff --git a/mobile/lib/utils/openapi_patching.dart b/mobile/lib/utils/openapi_patching.dart index d054749b1e..1ffe05c781 100644 --- a/mobile/lib/utils/openapi_patching.dart +++ b/mobile/lib/utils/openapi_patching.dart @@ -32,6 +32,11 @@ dynamic upgradeDto(dynamic value, String targetType) { addDefault(value, 'visibility', AssetVisibility.timeline); } break; + case 'AssetResponseDto': + if (value is Map) { + addDefault(value, 'visibility', 'timeline'); + } + break; case 'UserAdminResponseDto': if (value is Map) { addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); diff --git a/mobile/lib/utils/selection_handlers.dart b/mobile/lib/utils/selection_handlers.dart index c63d819153..1ae583bedd 100644 --- a/mobile/lib/utils/selection_handlers.dart +++ b/mobile/lib/utils/selection_handlers.dart @@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -157,3 +158,29 @@ Future handleEditLocation( ref.read(assetServiceProvider).changeLocation(selection.toList(), location); } + +Future handleSetAssetsVisibility( + WidgetRef ref, + BuildContext context, + AssetVisibilityEnum visibility, + List selection, { + ToastGravity toastGravity = ToastGravity.BOTTOM, +}) async { + if (selection.isNotEmpty) { + await ref + .watch(assetProvider.notifier) + .setLockedView(selection, visibility); + + final assetOrAssets = selection.length > 1 ? 'assets' : 'asset'; + final toastMessage = visibility == AssetVisibilityEnum.locked + ? 'Added ${selection.length} $assetOrAssets to locked folder' + : 'Removed ${selection.length} $assetOrAssets from locked folder'; + if (context.mounted) { + ImmichToast.show( + context: context, + msg: toastMessage, + gravity: ToastGravity.BOTTOM, + ); + } + } +} diff --git a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart index 7a049fa7fd..892e7e5b8a 100644 --- a/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart +++ b/mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/providers/album/album.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart'; import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; @@ -37,6 +38,7 @@ class ControlBottomAppBar extends HookConsumerWidget { final void Function()? onEditTime; final void Function()? onEditLocation; final void Function()? onRemoveFromAlbum; + final void Function()? onToggleLocked; final bool enabled; final bool unfavorite; @@ -58,6 +60,7 @@ class ControlBottomAppBar extends HookConsumerWidget { this.onEditTime, this.onEditLocation, this.onRemoveFromAlbum, + this.onToggleLocked, this.selectionAssetState = const AssetSelectionState(), this.enabled = true, this.unarchive = false, @@ -77,6 +80,7 @@ class ControlBottomAppBar extends HookConsumerWidget { ref.watch(albumProvider).where((a) => a.shared).toList(); const bottomPadding = 0.20; final scrollController = useDraggableScrollController(); + final isInLockedView = ref.watch(inLockedViewProvider); void minimize() { scrollController.animateTo( @@ -133,11 +137,12 @@ class ControlBottomAppBar extends HookConsumerWidget { label: "share".tr(), onPressed: enabled ? () => onShare(true) : null, ), - ControlBoxButton( - iconData: Icons.link_rounded, - label: "control_bottom_app_bar_share_link".tr(), - onPressed: enabled ? () => onShare(false) : null, - ), + if (!isInLockedView) + ControlBoxButton( + iconData: Icons.link_rounded, + label: "share_link".tr(), + onPressed: enabled ? () => onShare(false) : null, + ), if (hasRemote && onArchive != null) ControlBoxButton( iconData: @@ -153,7 +158,7 @@ class ControlBottomAppBar extends HookConsumerWidget { label: (unfavorite ? "unfavorite" : "favorite").tr(), onPressed: enabled ? onFavorite : null, ), - if (hasLocal && hasRemote && onDelete != null) + if (hasLocal && hasRemote && onDelete != null && !isInLockedView) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 90), child: ControlBoxButton( @@ -166,7 +171,7 @@ class ControlBottomAppBar extends HookConsumerWidget { enabled ? () => showForceDeleteDialog(onDelete!) : null, ), ), - if (hasRemote && onDeleteServer != null) + if (hasRemote && onDeleteServer != null && !isInLockedView) ConstrainedBox( constraints: const BoxConstraints(maxWidth: 85), child: ControlBoxButton( @@ -189,9 +194,23 @@ class ControlBottomAppBar extends HookConsumerWidget { : null, ), ), - if (hasLocal && onDeleteLocal != null) + if (isInLockedView) ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 85), + constraints: const BoxConstraints(maxWidth: 110), + child: ControlBoxButton( + iconData: Icons.delete_forever, + label: "delete_dialog_title".tr(), + onPressed: enabled + ? () => showForceDeleteDialog( + onDeleteServer!, + alertMsg: "delete_dialog_alert_remote", + ) + : null, + ), + ), + if (hasLocal && onDeleteLocal != null && !isInLockedView) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 95), child: ControlBoxButton( iconData: Icons.no_cell_outlined, label: "control_bottom_app_bar_delete_from_local".tr(), @@ -231,6 +250,19 @@ class ControlBottomAppBar extends HookConsumerWidget { onPressed: enabled ? onEditLocation : null, ), ), + if (hasRemote) + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 100), + child: ControlBoxButton( + iconData: isInLockedView + ? Icons.lock_open_rounded + : Icons.lock_outline_rounded, + label: isInLockedView + ? "remove_from_locked_folder".tr() + : "move_to_locked_folder".tr(), + onPressed: enabled ? onToggleLocked : null, + ), + ), if (!selectionAssetState.hasLocal && selectionAssetState.selectedCount > 1 && onStack != null) @@ -269,20 +301,40 @@ class ControlBottomAppBar extends HookConsumerWidget { ]; } + getInitialSize() { + if (isInLockedView) { + return 0.20; + } + if (hasRemote) { + return 0.35; + } + return bottomPadding; + } + + getMaxChildSize() { + if (isInLockedView) { + return 0.20; + } + if (hasRemote) { + return 0.65; + } + return bottomPadding; + } + return DraggableScrollableSheet( controller: scrollController, - initialChildSize: hasRemote ? 0.35 : bottomPadding, + initialChildSize: getInitialSize(), minChildSize: bottomPadding, - maxChildSize: hasRemote ? 0.65 : bottomPadding, + maxChildSize: getMaxChildSize(), snap: true, builder: ( BuildContext context, ScrollController scrollController, ) { return Card( - color: context.colorScheme.surfaceContainerLow, - surfaceTintColor: Colors.transparent, - elevation: 18.0, + color: context.colorScheme.surfaceContainerHigh, + surfaceTintColor: context.colorScheme.surfaceContainerHigh, + elevation: 6.0, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(12), @@ -300,27 +352,27 @@ class ControlBottomAppBar extends HookConsumerWidget { const CustomDraggingHandle(), const SizedBox(height: 12), SizedBox( - height: 100, + height: 120, child: ListView( shrinkWrap: true, scrollDirection: Axis.horizontal, children: renderActionButtons(), ), ), - if (hasRemote) + if (hasRemote && !isInLockedView) ...[ const Divider( indent: 16, endIndent: 16, thickness: 1, ), - if (hasRemote) _AddToAlbumTitleRow( onCreateNewAlbum: enabled ? onCreateNewAlbum : null, ), + ], ], ), ), - if (hasRemote) + if (hasRemote && !isInLockedView) SliverPadding( padding: const EdgeInsets.symmetric(horizontal: 16), sliver: AddToAlbumSliverList( @@ -352,12 +404,9 @@ class _AddToAlbumTitleRow extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( + Text( "add_to_album", - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - ), + style: context.textTheme.titleSmall, ).tr(), TextButton.icon( onPressed: onCreateNewAlbum, diff --git a/mobile/lib/widgets/asset_grid/multiselect_grid.dart b/mobile/lib/widgets/asset_grid/multiselect_grid.dart index ceaee581d2..8cc725ab77 100644 --- a/mobile/lib/widgets/asset_grid/multiselect_grid.dart +++ b/mobile/lib/widgets/asset_grid/multiselect_grid.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; @@ -15,6 +16,7 @@ import 'package:immich_mobile/providers/album/album.provider.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; import 'package:immich_mobile/providers/backup/manual_upload.provider.dart'; import 'package:immich_mobile/providers/multiselect.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/services/album.service.dart'; @@ -395,6 +397,32 @@ class MultiselectGrid extends HookConsumerWidget { } } + void onToggleLockedVisibility() async { + processing.value = true; + try { + final remoteAssets = ownedRemoteSelection( + localErrorMessage: 'home_page_locked_error_local'.tr(), + ownerErrorMessage: 'home_page_locked_error_partner'.tr(), + ); + if (remoteAssets.isNotEmpty) { + final isInLockedView = ref.read(inLockedViewProvider); + final visibility = isInLockedView + ? AssetVisibilityEnum.timeline + : AssetVisibilityEnum.locked; + + await handleSetAssetsVisibility( + ref, + context, + visibility, + remoteAssets.toList(), + ); + } + } finally { + processing.value = false; + selectionEnabledHook.value = false; + } + } + Future Function() wrapLongRunningFun( Future Function() fun, { bool showOverlay = true, @@ -460,6 +488,7 @@ class MultiselectGrid extends HookConsumerWidget { onEditLocation: editEnabled ? onEditLocation : null, unfavorite: unfavorite, unarchive: unarchive, + onToggleLocked: onToggleLockedVisibility, onRemoveFromAlbum: onRemoveFromAlbum != null ? wrapLongRunningFun( () => onRemoveFromAlbum!(selection.value), diff --git a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart index 8bfcdc12ca..1ff8596c43 100644 --- a/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart +++ b/mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/download.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/server_info.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -46,6 +47,7 @@ class BottomGalleryBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isInLockedView = ref.watch(inLockedViewProvider); final asset = ref.watch(currentAssetProvider); if (asset == null) { return const SizedBox(); @@ -277,7 +279,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'share'.tr(), ): (_) => shareAsset(), }, - if (asset.isImage) + if (asset.isImage && !isInLockedView) { BottomNavigationBarItem( icon: const Icon(Icons.tune_outlined), @@ -285,7 +287,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'edit'.tr(), ): (_) => handleEdit(), }, - if (isOwner) + if (isOwner && !isInLockedView) { asset.isArchived ? BottomNavigationBarItem( @@ -299,7 +301,7 @@ class BottomGalleryBar extends ConsumerWidget { tooltip: 'archive'.tr(), ): (_) => handleArchive(), }, - if (isOwner && asset.stackCount > 0) + if (isOwner && asset.stackCount > 0 && !isInLockedView) { BottomNavigationBarItem( icon: const Icon(Icons.burst_mode_outlined), diff --git a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart index 937d1adf32..64cb1c619f 100644 --- a/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart +++ b/mobile/lib/widgets/asset_viewer/top_control_app_bar.dart @@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart'; import 'package:immich_mobile/providers/album/current_album.provider.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/providers/asset.provider.dart'; +import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; @@ -39,6 +40,7 @@ class TopControlAppBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isInLockedView = ref.watch(inLockedViewProvider); const double iconSize = 22.0; final a = ref.watch(assetWatcher(asset)).value ?? asset; final album = ref.watch(currentAlbumProvider); @@ -178,15 +180,22 @@ class TopControlAppBar extends HookConsumerWidget { shape: const Border(), actions: [ if (asset.isRemote && isOwner) buildFavoriteButton(a), - if (isOwner && !isInHomePage && !(isInTrash ?? false)) + if (isOwner && + !isInHomePage && + !(isInTrash ?? false) && + !isInLockedView) buildLocateButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), - if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) + if (asset.isRemote && + (isOwner || isPartner) && + !asset.isTrashed && + !isInLockedView) buildAddToAlbumButton(), if (asset.isTrashed) buildRestoreButton(), - if (album != null && album.shared) buildActivitiesButton(), + if (album != null && album.shared && !isInLockedView) + buildActivitiesButton(), buildMoreInfoButton(), ], ); diff --git a/mobile/lib/widgets/common/drag_sheet.dart b/mobile/lib/widgets/common/drag_sheet.dart index 45addd0c2e..923050bcc6 100644 --- a/mobile/lib/widgets/common/drag_sheet.dart +++ b/mobile/lib/widgets/common/drag_sheet.dart @@ -35,7 +35,9 @@ class ControlBoxButton extends StatelessWidget { Widget build(BuildContext context) { return MaterialButton( padding: const EdgeInsets.all(10), - shape: const CircleBorder(), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(20)), + ), onPressed: onPressed, onLongPress: onLongPressed, minWidth: 75.0, @@ -47,8 +49,8 @@ class ControlBoxButton extends StatelessWidget { const SizedBox(height: 8), Text( label, - style: const TextStyle(fontSize: 12.0), - maxLines: 2, + style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400), + maxLines: 3, textAlign: TextAlign.center, ), ], diff --git a/mobile/lib/widgets/common/immich_toast.dart b/mobile/lib/widgets/common/immich_toast.dart index 7f3207032b..945568a74c 100644 --- a/mobile/lib/widgets/common/immich_toast.dart +++ b/mobile/lib/widgets/common/immich_toast.dart @@ -40,7 +40,7 @@ class ImmichToast { child: Container( padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5.0), + borderRadius: const BorderRadius.all(Radius.circular(16.0)), color: context.colorScheme.surfaceContainer, border: Border.all( color: context.colorScheme.outline.withValues(alpha: .5), @@ -59,14 +59,23 @@ class ImmichToast { msg, style: TextStyle( color: getColor(toastType, context), - fontWeight: FontWeight.bold, - fontSize: 15, + fontWeight: FontWeight.w600, + fontSize: 14, ), ), ), ], ), ), + positionedToastBuilder: (context, child, gravity) { + return Positioned( + top: gravity == ToastGravity.TOP ? 150 : null, + bottom: gravity == ToastGravity.BOTTOM ? 150 : null, + left: MediaQuery.of(context).size.width / 2 - 150, + right: MediaQuery.of(context).size.width / 2 - 150, + child: child, + ); + }, gravity: gravity, toastDuration: Duration(seconds: durationInSecond), ); diff --git a/mobile/lib/widgets/forms/pin_input.dart b/mobile/lib/widgets/forms/pin_input.dart new file mode 100644 index 0000000000..1588a65c60 --- /dev/null +++ b/mobile/lib/widgets/forms/pin_input.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:pinput/pinput.dart'; + +class PinInput extends StatelessWidget { + final Function(String)? onCompleted; + final Function(String)? onChanged; + final int? length; + final bool? obscureText; + final bool? autoFocus; + final bool? hasError; + final String? label; + final TextEditingController? controller; + + const PinInput({ + super.key, + this.onCompleted, + this.onChanged, + this.length, + this.obscureText, + this.autoFocus, + this.hasError, + this.label, + this.controller, + }); + + @override + Widget build(BuildContext context) { + getPinSize() { + final minimumPadding = 18.0; + final gapWidth = 3.0; + final screenWidth = context.width; + final pinWidth = + (screenWidth - (minimumPadding * 2) - (gapWidth * 5)) / (length ?? 6); + + if (pinWidth > 60) { + return const Size(60, 64); + } + + final pinHeight = pinWidth / (60 / 64); + return Size(pinWidth, pinHeight); + } + + final defaultPinTheme = PinTheme( + width: getPinSize().width, + height: getPinSize().height, + textStyle: TextStyle( + fontSize: 24, + color: context.colorScheme.onSurface, + fontFamily: 'Overpass Mono', + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(19)), + border: Border.all(color: context.colorScheme.surfaceBright), + color: context.colorScheme.surfaceContainerHigh, + ), + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: context.textTheme.displayLarge + ?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)), + ), + const SizedBox(height: 4), + ], + Pinput( + controller: controller, + forceErrorState: hasError ?? false, + autofocus: autoFocus ?? false, + obscureText: obscureText ?? false, + obscuringWidget: Icon( + Icons.vpn_key_rounded, + color: context.primaryColor, + size: 20, + ), + separatorBuilder: (index) => const SizedBox( + height: 64, + width: 3, + ), + cursor: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + margin: const EdgeInsets.only(bottom: 9), + width: 18, + height: 2, + color: context.primaryColor, + ), + ], + ), + defaultPinTheme: defaultPinTheme, + focusedPinTheme: defaultPinTheme.copyWith( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(19)), + border: Border.all( + color: context.primaryColor.withValues(alpha: 0.5), + width: 2, + ), + color: context.colorScheme.surfaceContainerHigh, + ), + ), + errorPinTheme: defaultPinTheme.copyWith( + decoration: BoxDecoration( + color: context.colorScheme.error.withAlpha(15), + borderRadius: const BorderRadius.all(Radius.circular(19)), + border: Border.all( + color: context.colorScheme.error.withAlpha(100), + width: 2, + ), + ), + ), + pinputAutovalidateMode: PinputAutovalidateMode.onSubmit, + length: length ?? 6, + onChanged: onChanged, + onCompleted: onCompleted, + ), + ], + ); + } +} diff --git a/mobile/lib/widgets/forms/pin_registration_form.dart b/mobile/lib/widgets/forms/pin_registration_form.dart new file mode 100644 index 0000000000..c3cfd3a864 --- /dev/null +++ b/mobile/lib/widgets/forms/pin_registration_form.dart @@ -0,0 +1,128 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/forms/pin_input.dart'; + +class PinRegistrationForm extends HookConsumerWidget { + final Function() onDone; + + const PinRegistrationForm({ + super.key, + required this.onDone, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasError = useState(false); + final newPinCodeController = useTextEditingController(); + final confirmPinCodeController = useTextEditingController(); + + bool validatePinCode() { + if (confirmPinCodeController.text.length != 6) { + return false; + } + + if (newPinCodeController.text != confirmPinCodeController.text) { + return false; + } + + return true; + } + + createNewPinCode() async { + final isValid = validatePinCode(); + if (!isValid) { + hasError.value = true; + return; + } + + try { + await ref.read(authProvider.notifier).setupPinCode( + newPinCodeController.text, + ); + + onDone(); + } catch (error) { + hasError.value = true; + context.showSnackBar( + SnackBar(content: Text(error.toString())), + ); + } + } + + return Form( + child: Column( + children: [ + Icon( + Icons.pin_outlined, + size: 64, + color: context.primaryColor, + ), + const SizedBox(height: 32), + SizedBox( + width: context.width * 0.7, + child: Text( + 'setup_pin_code'.tr(), + style: context.textTheme.labelLarge!.copyWith( + fontSize: 24, + ), + textAlign: TextAlign.center, + ), + ), + SizedBox( + width: context.width * 0.8, + child: Text( + 'new_pin_code_subtitle'.tr(), + style: context.textTheme.bodyLarge!.copyWith( + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 32), + PinInput( + controller: newPinCodeController, + label: 'new_pin_code'.tr(), + length: 6, + autoFocus: true, + hasError: hasError.value, + onChanged: (input) { + if (input.length < 6) { + hasError.value = false; + } + }, + ), + const SizedBox(height: 32), + PinInput( + controller: confirmPinCodeController, + label: 'confirm_new_pin_code'.tr(), + length: 6, + hasError: hasError.value, + onChanged: (input) { + if (input.length < 6) { + hasError.value = false; + } + }, + ), + const SizedBox(height: 48), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: createNewPinCode, + child: Text('create'.tr()), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/forms/pin_verification_form.dart b/mobile/lib/widgets/forms/pin_verification_form.dart new file mode 100644 index 0000000000..f4ebf4272f --- /dev/null +++ b/mobile/lib/widgets/forms/pin_verification_form.dart @@ -0,0 +1,94 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/providers/auth.provider.dart'; +import 'package:immich_mobile/widgets/forms/pin_input.dart'; + +class PinVerificationForm extends HookConsumerWidget { + final Function(String) onSuccess; + final VoidCallback? onError; + final bool? autoFocus; + final String? description; + final IconData? icon; + final IconData? successIcon; + + const PinVerificationForm({ + super.key, + required this.onSuccess, + this.onError, + this.autoFocus, + this.description, + this.icon, + this.successIcon, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final hasError = useState(false); + final isVerified = useState(false); + + verifyPin(String pinCode) async { + final isUnlocked = + await ref.read(authProvider.notifier).unlockPinCode(pinCode); + + if (isUnlocked) { + isVerified.value = true; + + await Future.delayed(const Duration(seconds: 1)); + onSuccess(pinCode); + } else { + hasError.value = true; + onError?.call(); + } + } + + return Form( + child: Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: isVerified.value + ? Icon( + successIcon ?? Icons.lock_open_rounded, + size: 64, + color: Colors.green[300], + ) + : Icon( + icon ?? Icons.lock_outline_rounded, + size: 64, + color: hasError.value + ? context.colorScheme.error + : context.primaryColor, + ), + ), + const SizedBox(height: 36), + SizedBox( + width: context.width * 0.7, + child: Text( + description ?? 'enter_your_pin_code_subtitle'.tr(), + style: context.textTheme.labelLarge!.copyWith( + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 18), + PinInput( + obscureText: true, + autoFocus: autoFocus, + hasError: hasError.value, + length: 6, + onChanged: (pinCode) { + if (pinCode.length < 6) { + hasError.value = false; + } + }, + onCompleted: verifyPin, + ), + ], + ), + ); + } +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 7e490edd25..3df4e4e8a9 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -621,6 +621,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_svg: dependency: "direct main" description: @@ -976,6 +1024,46 @@ packages: url: "https://pub.dev" source: hosted version: "5.1.1" + local_auth: + dependency: "direct main" + description: + name: local_auth + sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + local_auth_android: + dependency: transitive + description: + name: local_auth_android + sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e" + url: "https://pub.dev" + source: hosted + version: "1.0.49" + local_auth_darwin: + dependency: transitive + description: + name: local_auth_darwin + sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2" + url: "https://pub.dev" + source: hosted + version: "1.4.3" + local_auth_platform_interface: + dependency: transitive + description: + name: local_auth_platform_interface + sha256: "1b842ff177a7068442eae093b64abe3592f816afd2a533c0ebcdbe40f9d2075a" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + local_auth_windows: + dependency: transitive + description: + name: local_auth_windows + sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5 + url: "https://pub.dev" + source: hosted + version: "1.0.11" logging: dependency: "direct main" description: @@ -1264,6 +1352,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pinput: + dependency: "direct main" + description: + name: pinput + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" + url: "https://pub.dev" + source: hosted + version: "5.0.1" platform: dependency: transitive description: @@ -1741,6 +1837,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.2" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: "direct main" description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 08e9661d58..37c9ef7498 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -64,6 +64,9 @@ dependencies: uuid: ^4.5.1 wakelock_plus: ^1.2.10 worker_manager: ^7.2.3 + local_auth: ^2.3.0 + pinput: ^5.0.1 + flutter_secure_storage: ^9.2.4 native_video_player: git: