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
This commit is contained in:
Alex 2025-05-20 08:35:22 -05:00 committed by GitHub
parent 397808dd1a
commit fe71894308
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 1893 additions and 289 deletions

View File

@ -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",

View File

@ -18,6 +18,7 @@
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Foreground service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@ -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
}
}

View File

@ -1,22 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode
setting is off -->
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight">
<!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@ -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

View File

@ -1,165 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.132.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>205</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict>
</plist>
<dict>
<key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>app.alextran.immich.backgroundFetch</string>
<string>app.alextran.immich.backgroundProcessing</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ShareHandler</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.file-url</string>
<string>public.image</string>
<string>public.text</string>
<string>public.movie</string>
<string>public.url</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>ar</string>
<string>ca</string>
<string>cs</string>
<string>da</string>
<string>de</string>
<string>es</string>
<string>fi</string>
<string>fr</string>
<string>he</string>
<string>hi</string>
<string>hu</string>
<string>it</string>
<string>ja</string>
<string>ko</string>
<string>lv</string>
<string>mn</string>
<string>nb</string>
<string>nl</string>
<string>pl</string>
<string>pt</string>
<string>ro</string>
<string>ru</string>
<string>sk</string>
<string>sl</string>
<string>sr</string>
<string>sv</string>
<string>th</string>
<string>uk</string>
<string>vi</string>
<string>zh</string>
</array>
<key>CFBundleName</key>
<string>immich_mobile</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.132.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>205</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
<false />
<key>LSApplicationQueriesSchemes</key>
<array>
<string>https</string>
</array>
<key>LSRequiresIPhoneOS</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name for background upload mechanism</string>
<key>NSLocationUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>We require this permission to access the local WiFi name</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need to access the microphone to let you take beautiful video using this app</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need to manage backup your photos album</string>
<key>NSUserActivityTypes</key>
<array>
<string>INSendMessageIntent</string>
</array>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
<string>processing</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false />
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>io.flutter.embedded_views_preview</key>
<true />
<key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
</dict>
</plist>

View File

@ -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";

View File

@ -8,3 +8,5 @@ enum TextSearchType {
filename,
description,
}
enum AssetVisibilityEnum { timeline, hidden, archive, locked }

View File

@ -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<void> 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 {

View File

@ -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<P>(
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<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityEqualTo(
AssetVisibilityEnum value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'visibility',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityGreaterThan(
AssetVisibilityEnum value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'visibility',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> visibilityLessThan(
AssetVisibilityEnum value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'visibility',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> 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<Asset, Asset, QAfterFilterCondition> widthIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@ -2791,6 +2869,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByVisibility() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'visibility', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByVisibilityDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'visibility', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc);
@ -3057,6 +3147,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByVisibility() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'visibility', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByVisibilityDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'visibility', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc);
@ -3201,6 +3303,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByVisibility() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'visibility');
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByWidth() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'width');
@ -3335,6 +3443,13 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, AssetVisibilityEnum, QQueryOperations>
visibilityProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'visibility');
});
}
QueryBuilder<Asset, int?, QQueryOperations> widthProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'width');

View File

@ -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<void> delete(String id);
Future<List<Asset>> search({List<String> personIds = const []});
Future<void> updateVisibility(
List<String> list,
AssetVisibilityEnum visibility,
);
}

View File

@ -6,4 +6,9 @@ abstract interface class IAuthApiRepository {
Future<void> logout();
Future<void> changePassword(String newPassword);
Future<bool> unlockPinCode(String pinCode);
Future<void> lockPinCode();
Future<void> setupPinCode(String pinCode);
}

View File

@ -0,0 +1,6 @@
import 'package:immich_mobile/models/auth/biometric_status.model.dart';
abstract interface class IBiometricRepository {
Future<BiometricStatus> getStatus();
Future<bool> authenticate(String? message);
}

View File

@ -0,0 +1,5 @@
abstract interface class ISecureStorageRepository {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
}

View File

@ -31,4 +31,9 @@ abstract class ITimelineRepository {
);
Stream<RenderList> watchAssetSelectionTimeline(String userId);
Stream<RenderList> watchLockedTimeline(
String userId,
GroupAssetsBy groupAssetsBy,
);
}

View File

@ -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<ImmichApp>
),
routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate(
navigatorObservers: () => [TabNavigationObserver(ref: ref)],
navigatorObservers: () => [AppNavigationObserver(ref: ref)],
),
),
),

View File

@ -0,0 +1,38 @@
import 'package:collection/collection.dart';
import 'package:local_auth/local_auth.dart';
class BiometricStatus {
final List<BiometricType> availableBiometrics;
final bool canAuthenticate;
const BiometricStatus({
required this.availableBiometrics,
required this.canAuthenticate,
});
@override
String toString() =>
'BiometricStatus(availableBiometrics: $availableBiometrics, canAuthenticate: $canAuthenticate)';
BiometricStatus copyWith({
List<BiometricType>? 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;
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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<void> 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,
),
),
),
),
],
],
),
),
],
),
);
}
}

View File

@ -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<bool> {
status ??= !assets.every((a) => a.isArchived);
return _assetService.changeArchiveStatus(assets, status);
}
Future<void> setLockedView(
List<Asset> selection,
AssetVisibilityEnum visibility,
) {
return _assetService.setVisibility(selection, visibility);
}
}
final assetDetailProvider =

View File

@ -188,4 +188,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<String?> setOpenApiServiceEndpoint() {
return _authService.setOpenApiServiceEndpoint();
}
Future<bool> unlockPinCode(String pinCode) {
return _authService.unlockPinCode(pinCode);
}
Future<void> lockPinCode() {
return _authService.lockPinCode();
}
Future<void> setupPinCode(String pinCode) {
return _authService.setupPinCode(pinCode);
}
}

View File

@ -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<LocalAuthNotifier, BiometricStatus>((ref) {
return LocalAuthNotifier(
ref.watch(localAuthServiceProvider),
ref.watch(secureStorageServiceProvider),
);
});
class LocalAuthNotifier extends StateNotifier<BiometricStatus> {
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<bool> 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<bool> 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;
}
}

View File

@ -0,0 +1,3 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
final inLockedViewProvider = StateProvider<bool>((ref) => false);

View File

@ -0,0 +1,10 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
final secureStorageProvider =
StateNotifierProvider<SecureStorageProvider, void>((ref) {
return SecureStorageProvider();
});
class SecureStorageProvider extends StateNotifier<void> {
SecureStorageProvider() : super(null);
}

View File

@ -73,3 +73,8 @@ final assetsTimelineProvider =
null,
);
});
final lockedTimelineProvider = StreamProvider<RenderList>((ref) {
final timelineService = ref.watch(timelineServiceProvider);
return timelineService.watchLockedTimelineProvider();
});

View File

@ -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<void> updateVisibility(
List<String> 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;
}
}
}

View File

@ -55,4 +55,26 @@ class AuthApiRepository extends ApiRepository implements IAuthApiRepository {
userId: dto.userId,
);
}
@override
Future<bool> unlockPinCode(String pinCode) async {
try {
await _apiService.authenticationApi
.unlockAuthSession(SessionUnlockDto(pinCode: pinCode));
return true;
} catch (_) {
return false;
}
}
@override
Future<void> setupPinCode(String pinCode) {
return _apiService.authenticationApi
.setupPinCode(PinCodeSetupDto(pinCode: pinCode));
}
@override
Future<void> lockPinCode() {
return _apiService.authenticationApi.lockAuthSession();
}
}

View File

@ -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<BiometricStatus> 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<bool> authenticate(String? message) async {
return _localAuth.authenticate(
localizedReason: message ?? 'please_auth_to_access'.tr(),
);
}
}

View File

@ -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<String?> read(String key) {
return _secureStorage.read(key: key);
}
@override
Future<void> write(String key, String value) {
return _secureStorage.write(key: key, value: value);
}
@override
Future<void> delete(String key) {
return _secureStorage.delete(key: key);
}
}

View File

@ -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<RenderList> 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<RenderList> 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<RenderList> _watchRenderList(
QueryBuilder<Asset, Asset, QAfterSortBy> query,
GroupAssetsBy groupAssetsBy,

View File

@ -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<void> 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,
);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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),
),
);

View File

@ -956,6 +956,25 @@ class LocalAlbumsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [LockedPage]
class LockedRoute extends PageRouteInfo<void> {
const LockedRoute({List<PageRouteInfo>? 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<void> {
@ -1359,6 +1378,53 @@ class PhotosRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [PinAuthPage]
class PinAuthRoute extends PageRouteInfo<PinAuthRouteArgs> {
PinAuthRoute({
Key? key,
bool createPinCode = false,
List<PageRouteInfo>? 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<PinAuthRouteArgs>(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<PlacesCollectionRouteArgs> {

View File

@ -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<void> 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");
}
}
}
}

View File

@ -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<void> setVisibility(
List<Asset> 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);
}
}

View File

@ -201,4 +201,16 @@ class AuthService {
return null;
}
Future<bool> unlockPinCode(String pinCode) {
return _authApiRepository.unlockPinCode(pinCode);
}
Future<void> lockPinCode() {
return _authApiRepository.lockPinCode();
}
Future<void> setupPinCode(String pinCode) {
return _authApiRepository.setupPinCode(pinCode);
}
}

View File

@ -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<BiometricStatus> getStatus() {
return _biometricRepository.getStatus();
}
Future<bool> authenticate([String? message]) async {
return _biometricRepository.authenticate(message);
}
}

View File

@ -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<MemoryService>((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,

View File

@ -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<void> write(String key, String value) async {
await _secureStorageRepository.write(key, value);
}
Future<void> delete(String key) async {
await _secureStorageRepository.delete(key);
}
Future<String?> read(String key) async {
return _secureStorageRepository.read(key);
}
}

View File

@ -105,4 +105,13 @@ class TimelineService {
return GroupAssetsBy
.values[_appSettingsService.getSetting(AppSettingsEnum.groupAssetsBy)];
}
Stream<RenderList> watchLockedTimelineProvider() async* {
final user = _userService.getMyUser();
yield* _timelineRepository.watchLockedTimeline(
user.id,
_getGroupByOption(),
);
}
}

View File

@ -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(

View File

@ -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<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, targetVersion);

View File

@ -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());

View File

@ -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<void> handleEditLocation(
ref.read(assetServiceProvider).changeLocation(selection.toList(), location);
}
Future<void> handleSetAssetsVisibility(
WidgetRef ref,
BuildContext context,
AssetVisibilityEnum visibility,
List<Asset> 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,
);
}
}
}

View File

@ -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,

View File

@ -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<T> Function() wrapLongRunningFun<T>(
Future<T> 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),

View File

@ -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),

View File

@ -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(),
],
);

View File

@ -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,
),
],

View File

@ -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),
);

View File

@ -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,
),
],
);
}
}

View File

@ -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()),
),
),
],
),
),
],
),
);
}
}

View File

@ -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,
),
],
),
);
}
}

View File

@ -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:

View File

@ -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: