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_options_page_title": "Backup options",
"backup_setting_subtitle": "Manage background and foreground upload settings", "backup_setting_subtitle": "Manage background and foreground upload settings",
"backward": "Backward", "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_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.", "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", "blurred_background": "Blurred background",
@ -822,6 +826,7 @@
"empty_trash": "Empty trash", "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!", "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": "Enable",
"enable_biometric_auth_description": "Enter your PIN code to enable biometric authentication",
"enabled": "Enabled", "enabled": "Enabled",
"end_date": "End date", "end_date": "End date",
"enqueued": "Enqueued", "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", "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", "face_unassigned": "Unassigned",
"failed": "Failed", "failed": "Failed",
"failed_to_authenticate": "Failed to authenticate",
"failed_to_load_assets": "Failed to load assets", "failed_to_load_assets": "Failed to load assets",
"failed_to_load_folder": "Failed to load folder", "failed_to_load_folder": "Failed to load folder",
"favorite": "Favorite", "favorite": "Favorite",
@ -1060,6 +1066,8 @@
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "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_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_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_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", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"host": "Host", "host": "Host",
@ -1227,8 +1235,6 @@
"memories_setting_description": "Manage what you see in your memories", "memories_setting_description": "Manage what you see in your memories",
"memories_start_over": "Start Over", "memories_start_over": "Start Over",
"memories_swipe_to_close": "Swipe up to close", "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": "Memory",
"memory_lane_title": "Memory Lane {title}", "memory_lane_title": "Memory Lane {title}",
"menu": "Menu", "menu": "Menu",
@ -1400,6 +1406,7 @@
"play_memories": "Play memories", "play_memories": "Play memories",
"play_motion_photo": "Play Motion Photo", "play_motion_photo": "Play Motion Photo",
"play_or_pause_video": "Play or pause video", "play_or_pause_video": "Play or pause video",
"please_auth_to_access": "Please authenticate to access",
"port": "Port", "port": "Port",
"preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_subtitle": "Manage the app's preferences",
"preferences_settings_title": "Preferences", "preferences_settings_title": "Preferences",
@ -1661,6 +1668,7 @@
"share_add_photos": "Add photos", "share_add_photos": "Add photos",
"share_assets_selected": "{count} selected", "share_assets_selected": "{count} selected",
"share_dialog_preparing": "Preparing...", "share_dialog_preparing": "Preparing...",
"share_link": "Share Link",
"shared": "Shared", "shared": "Shared",
"shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activity_remove_content": "Do you want to delete this activity?", "shared_album_activity_remove_content": "Do you want to delete this activity?",
@ -1884,6 +1892,7 @@
"uploading": "Uploading", "uploading": "Uploading",
"url": "URL", "url": "URL",
"usage": "Usage", "usage": "Usage",
"use_biometric": "Use biometric",
"use_current_connection": "use current connection", "use_current_connection": "use current connection",
"use_custom_date_range": "Use custom date range instead", "use_custom_date_range": "Use custom date range instead",
"user": "User", "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.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<!-- Foreground service permission --> <!-- Foreground service permission -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@ -1,10 +1,10 @@
package app.alextran.immich package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import androidx.annotation.NonNull import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() { class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin()) flutterEngine.plugins.add(BackgroundServicePlugin())

View File

@ -1,7 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar"> setting is off -->
<style name="LaunchTheme" parent="Theme.AppCompat.DayNight">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
Flutter draws its first frame --> Flutter draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>

View File

@ -44,6 +44,8 @@ PODS:
- Flutter - Flutter
- flutter_native_splash (2.4.3): - flutter_native_splash (2.4.3):
- Flutter - Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - SAMKeychain
@ -59,6 +61,9 @@ PODS:
- Flutter - Flutter
- isar_flutter_libs (1.0.0): - isar_flutter_libs (1.0.0):
- Flutter - Flutter
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- MapLibre (6.5.0) - MapLibre (6.5.0)
- maplibre_gl (0.0.1): - maplibre_gl (0.0.1):
- Flutter - Flutter
@ -130,6 +135,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/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_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
@ -137,6 +143,7 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/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`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- network_info_plus (from `.symlinks/plugins/network_info_plus/ios`) - network_info_plus (from `.symlinks/plugins/network_info_plus/ios`)
@ -178,6 +185,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_local_notifications/ios" :path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash: flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios" :path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid: flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios" :path: ".symlinks/plugins/flutter_udid/ios"
flutter_web_auth_2: flutter_web_auth_2:
@ -192,6 +201,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios" :path: ".symlinks/plugins/integration_test/ios"
isar_flutter_libs: isar_flutter_libs:
:path: ".symlinks/plugins/isar_flutter_libs/ios" :path: ".symlinks/plugins/isar_flutter_libs/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
maplibre_gl: maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios" :path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player: native_video_player:
@ -233,6 +244,7 @@ SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100 flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9 flutter_udid: f7c3884e6ec2951efe4f9de082257fc77c4d15e9
flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
@ -240,6 +252,7 @@ SPEC CHECKSUMS:
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26 isar_flutter_libs: bc909e72c3d756c2759f14c8776c13b5b0556e26
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e MapLibre: 0ebfa9329d313cec8bf0a5ba5a336a1dc903785e
maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9 maplibre_gl: eab61cca6e1cfa9187249bacd3f08b51e8cd8ae9
native_video_player: b65c58951ede2f93d103a25366bdebca95081265 native_video_player: b65c58951ede2f93d103a25366bdebca95081265

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>AppGroupId</key> <key>AppGroupId</key>
<string>$(CUSTOM_GROUP_ID)</string> <string>$(CUSTOM_GROUP_ID)</string>
<key>BGTaskSchedulerPermittedIdentifiers</key> <key>BGTaskSchedulerPermittedIdentifiers</key>
@ -10,7 +10,7 @@
<string>app.alextran.immich.backgroundProcessing</string> <string>app.alextran.immich.backgroundProcessing</string>
</array> </array>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true />
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
@ -95,23 +95,23 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>205</string> <string>205</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true />
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false />
<key>LSApplicationQueriesSchemes</key> <key>LSApplicationQueriesSchemes</key>
<array> <array>
<string>https</string> <string>https</string>
</array> </array>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true />
<key>LSSupportsOpeningDocumentsInPlace</key> <key>LSSupportsOpeningDocumentsInPlace</key>
<string>No</string> <string>No</string>
<key>MGLMapboxMetricsEnabledSettingShownInApp</key> <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
<true/> <true />
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true />
</dict> </dict>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>We need to access the camera to let you take beautiful video using this app</string> <string>We need to access the camera to let you take beautiful video using this app</string>
@ -132,7 +132,7 @@
<string>INSendMessageIntent</string> <string>INSendMessageIntent</string>
</array> </array>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true />
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>fetch</string> <string>fetch</string>
@ -143,7 +143,7 @@
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false />
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
@ -158,8 +158,10 @@
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true />
<key>io.flutter.embedded_views_preview</key> <key>io.flutter.embedded_views_preview</key>
<true/> <true />
</dict> <key>NSFaceIDUsageDescription</key>
<string>We need to use FaceID to allow access to your locked folder</string>
</dict>
</plist> </plist>

View File

@ -11,3 +11,6 @@ const int kSyncEventBatchSize = 5000;
// Hash batch limits // Hash batch limits
const int kBatchHashFileLimit = 128; const int kBatchHashFileLimit = 128;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB 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, filename,
description, description,
} }
enum AssetVisibilityEnum { timeline, hidden, archive, locked }

View File

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/exif.model.dart'; import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/string_extensions.dart'; import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart' import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
@ -45,7 +46,8 @@ class Asset {
: remote.stack?.primaryAssetId, : remote.stack?.primaryAssetId,
stackCount = remote.stack?.assetCount ?? 0, stackCount = remote.stack?.assetCount ?? 0,
stackId = remote.stack?.id, stackId = remote.stack?.id,
thumbhash = remote.thumbhash; thumbhash = remote.thumbhash,
visibility = getVisibility(remote.visibility);
Asset({ Asset({
this.id = Isar.autoIncrement, this.id = Isar.autoIncrement,
@ -71,6 +73,7 @@ class Asset {
this.stackCount = 0, this.stackCount = 0,
this.isOffline = false, this.isOffline = false,
this.thumbhash, this.thumbhash,
this.visibility = AssetVisibilityEnum.timeline,
}); });
@ignore @ignore
@ -173,6 +176,9 @@ class Asset {
int stackCount; int stackCount;
@Enumerated(EnumType.ordinal)
AssetVisibilityEnum visibility;
/// Returns null if the asset has no sync access to the exif info /// Returns null if the asset has no sync access to the exif info
@ignore @ignore
double? get aspectRatio { double? get aspectRatio {
@ -349,7 +355,8 @@ class Asset {
a.thumbhash != thumbhash || a.thumbhash != thumbhash ||
stackId != a.stackId || stackId != a.stackId ||
stackCount != a.stackCount || 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] /// Returns a new [Asset] with values from this and merged & updated with [a]
@ -452,6 +459,7 @@ class Asset {
String? stackPrimaryAssetId, String? stackPrimaryAssetId,
int? stackCount, int? stackCount,
String? thumbhash, String? thumbhash,
AssetVisibilityEnum? visibility,
}) => }) =>
Asset( Asset(
id: id ?? this.id, id: id ?? this.id,
@ -477,6 +485,7 @@ class Asset {
stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId, stackPrimaryAssetId: stackPrimaryAssetId ?? this.stackPrimaryAssetId,
stackCount: stackCount ?? this.stackCount, stackCount: stackCount ?? this.stackCount,
thumbhash: thumbhash ?? this.thumbhash, thumbhash: thumbhash ?? this.thumbhash,
visibility: visibility ?? this.visibility,
); );
Future<void> put(Isar db) async { Future<void> put(Isar db) async {
@ -541,8 +550,22 @@ class Asset {
"isArchived": $isArchived, "isArchived": $isArchived,
"isTrashed": $isTrashed, "isTrashed": $isTrashed,
"isOffline": $isOffline, "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 { enum AssetType {

View File

@ -118,8 +118,14 @@ const AssetSchema = CollectionSchema(
name: r'updatedAt', name: r'updatedAt',
type: IsarType.dateTime, type: IsarType.dateTime,
), ),
r'width': PropertySchema( r'visibility': PropertySchema(
id: 20, id: 20,
name: r'visibility',
type: IsarType.byte,
enumMap: _AssetvisibilityEnumValueMap,
),
r'width': PropertySchema(
id: 21,
name: r'width', name: r'width',
type: IsarType.int, type: IsarType.int,
) )
@ -256,7 +262,8 @@ void _assetSerialize(
writer.writeString(offsets[17], object.thumbhash); writer.writeString(offsets[17], object.thumbhash);
writer.writeByte(offsets[18], object.type.index); writer.writeByte(offsets[18], object.type.index);
writer.writeDateTime(offsets[19], object.updatedAt); 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( Asset _assetDeserialize(
@ -288,7 +295,10 @@ Asset _assetDeserialize(
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ?? type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other, AssetType.other,
updatedAt: reader.readDateTime(offsets[19]), 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; return object;
} }
@ -342,6 +352,9 @@ P _assetDeserializeProp<P>(
case 19: case 19:
return (reader.readDateTime(offset)) as P; return (reader.readDateTime(offset)) as P;
case 20: case 20:
return (_AssetvisibilityValueEnumMap[reader.readByteOrNull(offset)] ??
AssetVisibilityEnum.timeline) as P;
case 21:
return (reader.readIntOrNull(offset)) as P; return (reader.readIntOrNull(offset)) as P;
default: default:
throw IsarError('Unknown property with id $propertyId'); throw IsarError('Unknown property with id $propertyId');
@ -360,6 +373,18 @@ const _AssettypeValueEnumMap = {
2: AssetType.video, 2: AssetType.video,
3: AssetType.audio, 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) { Id _assetGetId(Asset object) {
return object.id; 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() { QueryBuilder<Asset, Asset, QAfterFilterCondition> widthIsNull() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull( 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() { QueryBuilder<Asset, Asset, QAfterSortBy> sortByWidth() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc); 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() { QueryBuilder<Asset, Asset, QAfterSortBy> thenByWidth() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'width', Sort.asc); 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() { QueryBuilder<Asset, Asset, QDistinct> distinctByWidth() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'width'); 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() { QueryBuilder<Asset, int?, QQueryOperations> widthProperty() {
return QueryBuilder.apply(this, (query) { return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'width'); 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'; import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetApiRepository { abstract interface class IAssetApiRepository {
@ -15,4 +16,9 @@ abstract interface class IAssetApiRepository {
// Future<void> delete(String id); // Future<void> delete(String id);
Future<List<Asset>> search({List<String> personIds = const []}); 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> logout();
Future<void> changePassword(String newPassword); 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> 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/locale_provider.dart';
import 'package:immich_mobile/providers/theme.provider.dart'; import 'package:immich_mobile/providers/theme.provider.dart';
import 'package:immich_mobile/routing/router.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/background.service.dart';
import 'package:immich_mobile/services/local_notification.service.dart'; import 'package:immich_mobile/services/local_notification.service.dart';
import 'package:immich_mobile/theme/dynamic_theme.dart'; import 'package:immich_mobile/theme/dynamic_theme.dart';
@ -219,7 +219,7 @@ class ImmichAppState extends ConsumerState<ImmichApp>
), ),
routeInformationParser: router.defaultRouteParser(), routeInformationParser: router.defaultRouteParser(),
routerDelegate: router.delegate( 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()), 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( ListTile(
leading: const Icon( leading: const Icon(
Icons.group_outlined, 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:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/models/store.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart'; import 'package:immich_mobile/domain/services/user.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
@ -170,6 +171,13 @@ class AssetNotifier extends StateNotifier<bool> {
status ??= !assets.every((a) => a.isArchived); status ??= !assets.every((a) => a.isArchived);
return _assetService.changeArchiveStatus(assets, status); return _assetService.changeArchiveStatus(assets, status);
} }
Future<void> setLockedView(
List<Asset> selection,
AssetVisibilityEnum visibility,
) {
return _assetService.setVisibility(selection, visibility);
}
} }
final assetDetailProvider = final assetDetailProvider =

View File

@ -188,4 +188,16 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<String?> setOpenApiServiceEndpoint() { Future<String?> setOpenApiServiceEndpoint() {
return _authService.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, 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/asset_api.interface.dart'; import 'package:immich_mobile/interfaces/asset_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
@ -48,4 +49,27 @@ class AssetApiRepository extends ApiRepository implements IAssetApiRepository {
} }
return result; 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, 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() .where()
.ownerIdEqualToAnyChecksum(fastHash(userId)) .ownerIdEqualToAnyChecksum(fastHash(userId))
.filter() .filter()
.isArchivedEqualTo(true)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.visibilityEqualTo(AssetVisibilityEnum.archive)
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
return _watchRenderList(query, GroupAssetsBy.none); return _watchRenderList(query, GroupAssetsBy.none);
@ -59,6 +59,8 @@ class TimelineRepository extends DatabaseRepository
.ownerIdEqualToAnyChecksum(fastHash(userId)) .ownerIdEqualToAnyChecksum(fastHash(userId))
.filter() .filter()
.isFavoriteEqualTo(true) .isFavoriteEqualTo(true)
.not()
.visibilityEqualTo(AssetVisibilityEnum.locked)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
@ -94,8 +96,8 @@ class TimelineRepository extends DatabaseRepository
Stream<RenderList> watchAllVideosTimeline() { Stream<RenderList> watchAllVideosTimeline() {
final query = db.assets final query = db.assets
.filter() .filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.typeEqualTo(AssetType.video) .typeEqualTo(AssetType.video)
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
@ -111,9 +113,9 @@ class TimelineRepository extends DatabaseRepository
.where() .where()
.ownerIdEqualToAnyChecksum(fastHash(userId)) .ownerIdEqualToAnyChecksum(fastHash(userId))
.filter() .filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull() .stackPrimaryAssetIdIsNull()
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
return _watchRenderList(query, groupAssetByOption); return _watchRenderList(query, groupAssetByOption);
@ -129,8 +131,8 @@ class TimelineRepository extends DatabaseRepository
.where() .where()
.anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id)) .anyOf(isarUserIds, (qb, id) => qb.ownerIdEqualToAnyChecksum(id))
.filter() .filter()
.isArchivedEqualTo(false)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.stackPrimaryAssetIdIsNull() .stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
return _watchRenderList(query, groupAssetByOption); return _watchRenderList(query, groupAssetByOption);
@ -151,6 +153,7 @@ class TimelineRepository extends DatabaseRepository
.remoteIdIsNotNull() .remoteIdIsNotNull()
.filter() .filter()
.ownerIdEqualTo(fastHash(userId)) .ownerIdEqualTo(fastHash(userId))
.visibilityEqualTo(AssetVisibilityEnum.timeline)
.isTrashedEqualTo(false) .isTrashedEqualTo(false)
.stackPrimaryAssetIdIsNull() .stackPrimaryAssetIdIsNull()
.sortByFileCreatedAtDesc(); .sortByFileCreatedAtDesc();
@ -158,6 +161,22 @@ class TimelineRepository extends DatabaseRepository
return _watchRenderList(query, GroupAssetsBy.none); 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( Stream<RenderList> _watchRenderList(
QueryBuilder<Asset, Asset, QAfterSortBy> query, QueryBuilder<Asset, Asset, QAfterSortBy> query,
GroupAssetsBy groupAssetsBy, 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/folder/folder.page.dart';
import 'package:immich_mobile/pages/library/library.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/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.page.dart';
import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart'; import 'package:immich_mobile/pages/library/partner/partner_detail.page.dart';
import 'package:immich_mobile/pages/library/people/people_collection.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/backup_permission_guard.dart';
import 'package:immich_mobile/routing/custom_transition_builders.dart'; import 'package:immich_mobile/routing/custom_transition_builders.dart';
import 'package:immich_mobile/routing/duplicate_guard.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/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:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:maplibre_gl/maplibre_gl.dart';
part 'router.gr.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') @AutoRouterConfig(replaceInRouteName: 'Page,Route')
class AppRouter extends RootStackRouter { class AppRouter extends RootStackRouter {
late final AuthGuard _authGuard; late final AuthGuard _authGuard;
late final DuplicateGuard _duplicateGuard; late final DuplicateGuard _duplicateGuard;
late final BackupPermissionGuard _backupPermissionGuard; late final BackupPermissionGuard _backupPermissionGuard;
late final LockedGuard _lockedGuard;
AppRouter( AppRouter(
ApiService apiService, ApiService apiService,
GalleryPermissionNotifier galleryPermissionNotifier, GalleryPermissionNotifier galleryPermissionNotifier,
SecureStorageService secureStorageService,
LocalAuthService localAuthService,
) { ) {
_authGuard = AuthGuard(apiService); _authGuard = AuthGuard(apiService);
_duplicateGuard = DuplicateGuard(); _duplicateGuard = DuplicateGuard();
_lockedGuard =
LockedGuard(apiService, secureStorageService, localAuthService);
_backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier); _backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier);
} }
@ -289,12 +308,13 @@ class AppRouter extends RootStackRouter {
page: ShareIntentRoute.page, page: ShareIntentRoute.page,
guards: [_authGuard, _duplicateGuard], 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 /// generated route for
/// [LoginPage] /// [LoginPage]
class LoginRoute extends PageRouteInfo<void> { 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 /// generated route for
/// [PlacesCollectionPage] /// [PlacesCollectionPage]
class PlacesCollectionRoute extends PageRouteInfo<PlacesCollectionRouteArgs> { 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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/exif.interface.dart';
import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart';
import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart';
@ -239,6 +240,9 @@ class AssetService {
for (var element in assets) { for (var element in assets) {
element.isArchived = isArchived; element.isArchived = isArchived;
element.visibility = isArchived
? AssetVisibilityEnum.archive
: AssetVisibilityEnum.timeline;
} }
await _syncService.upsertAssetsWithExif(assets); await _syncService.upsertAssetsWithExif(assets);
@ -458,6 +462,7 @@ class AssetService {
bool shouldDeletePermanently = false, bool shouldDeletePermanently = false,
}) async { }) async {
final candidates = assets.where((a) => a.isRemote); final candidates = assets.where((a) => a.isRemote);
if (candidates.isEmpty) { if (candidates.isEmpty) {
return; return;
} }
@ -475,6 +480,7 @@ class AssetService {
.where((asset) => asset.storage == AssetState.merged) .where((asset) => asset.storage == AssetState.merged)
.map((asset) { .map((asset) {
asset.remoteId = null; asset.remoteId = null;
asset.visibility = AssetVisibilityEnum.timeline;
return asset; return asset;
}) })
: assets.where((asset) => asset.isRemote).map((asset) { : assets.where((asset) => asset.isRemote).map((asset) {
@ -529,4 +535,21 @@ class AssetService {
final me = _userService.getMyUser(); final me = _userService.getMyUser();
return _assetRepository.getMotionAssets(me.id); 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; 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/models/memories/memory.model.dart'; import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/translation.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
final memoryServiceProvider = StateProvider<MemoryService>((ref) { final memoryServiceProvider = StateProvider<MemoryService>((ref) {
@ -40,10 +40,7 @@ class MemoryService {
.getAllByRemoteId(memory.assets.map((e) => e.id)); .getAllByRemoteId(memory.assets.map((e) => e.id));
final yearsAgo = now.year - memory.data.year; final yearsAgo = now.year - memory.data.year;
if (dbAssets.isNotEmpty) { if (dbAssets.isNotEmpty) {
final String title = yearsAgo <= 1 final String title = t('years_ago', {'years': yearsAgo.toString()});
? 'memories_year_ago'.tr()
: 'memories_years_ago'
.tr(namedArgs: {'years': yearsAgo.toString()});
memories.add( memories.add(
Memory( Memory(
title: title, 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 return GroupAssetsBy
.values[_appSettingsService.getSetting(AppSettingsEnum.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( titleTextStyle: TextStyle(
color: colorScheme.primary, color: colorScheme.primary,
fontFamily: _getFontFamilyFromLocale(locale), fontFamily: _getFontFamilyFromLocale(locale),
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
fontSize: 18, fontSize: 18,
), ),
backgroundColor: backgroundColor:
@ -54,28 +54,28 @@ ThemeData getThemeData({
), ),
textTheme: const TextTheme( textTheme: const TextTheme(
displayLarge: TextStyle( displayLarge: TextStyle(
fontSize: 26, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
displayMedium: TextStyle( displayMedium: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
displaySmall: TextStyle( displaySmall: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
titleSmall: TextStyle( titleSmall: TextStyle(
fontSize: 16.0, fontSize: 16.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
titleMedium: TextStyle( titleMedium: TextStyle(
fontSize: 18.0, fontSize: 18.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
titleLarge: TextStyle( titleLarge: TextStyle(
fontSize: 26.0, fontSize: 26.0,
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(

View File

@ -20,7 +20,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager // ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 10; const int targetVersion = 11;
Future<void> migrateDatabaseIfNeeded(Isar db) async { Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, targetVersion); 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); addDefault(value, 'visibility', AssetVisibility.timeline);
} }
break; break;
case 'AssetResponseDto':
if (value is Map) {
addDefault(value, 'visibility', 'timeline');
}
break;
case 'UserAdminResponseDto': case 'UserAdminResponseDto':
if (value is Map) { if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String()); 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:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart'; import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_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); 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:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.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/widgets/album/add_to_album_sliverlist.dart';
import 'package:immich_mobile/models/asset_selection_state.dart'; import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.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()? onEditTime;
final void Function()? onEditLocation; final void Function()? onEditLocation;
final void Function()? onRemoveFromAlbum; final void Function()? onRemoveFromAlbum;
final void Function()? onToggleLocked;
final bool enabled; final bool enabled;
final bool unfavorite; final bool unfavorite;
@ -58,6 +60,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
this.onEditTime, this.onEditTime,
this.onEditLocation, this.onEditLocation,
this.onRemoveFromAlbum, this.onRemoveFromAlbum,
this.onToggleLocked,
this.selectionAssetState = const AssetSelectionState(), this.selectionAssetState = const AssetSelectionState(),
this.enabled = true, this.enabled = true,
this.unarchive = false, this.unarchive = false,
@ -77,6 +80,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
ref.watch(albumProvider).where((a) => a.shared).toList(); ref.watch(albumProvider).where((a) => a.shared).toList();
const bottomPadding = 0.20; const bottomPadding = 0.20;
final scrollController = useDraggableScrollController(); final scrollController = useDraggableScrollController();
final isInLockedView = ref.watch(inLockedViewProvider);
void minimize() { void minimize() {
scrollController.animateTo( scrollController.animateTo(
@ -133,9 +137,10 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: "share".tr(), label: "share".tr(),
onPressed: enabled ? () => onShare(true) : null, onPressed: enabled ? () => onShare(true) : null,
), ),
if (!isInLockedView)
ControlBoxButton( ControlBoxButton(
iconData: Icons.link_rounded, iconData: Icons.link_rounded,
label: "control_bottom_app_bar_share_link".tr(), label: "share_link".tr(),
onPressed: enabled ? () => onShare(false) : null, onPressed: enabled ? () => onShare(false) : null,
), ),
if (hasRemote && onArchive != null) if (hasRemote && onArchive != null)
@ -153,7 +158,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
label: (unfavorite ? "unfavorite" : "favorite").tr(), label: (unfavorite ? "unfavorite" : "favorite").tr(),
onPressed: enabled ? onFavorite : null, onPressed: enabled ? onFavorite : null,
), ),
if (hasLocal && hasRemote && onDelete != null) if (hasLocal && hasRemote && onDelete != null && !isInLockedView)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90), constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton( child: ControlBoxButton(
@ -166,7 +171,7 @@ class ControlBottomAppBar extends HookConsumerWidget {
enabled ? () => showForceDeleteDialog(onDelete!) : null, enabled ? () => showForceDeleteDialog(onDelete!) : null,
), ),
), ),
if (hasRemote && onDeleteServer != null) if (hasRemote && onDeleteServer != null && !isInLockedView)
ConstrainedBox( ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 85), constraints: const BoxConstraints(maxWidth: 85),
child: ControlBoxButton( child: ControlBoxButton(
@ -189,9 +194,23 @@ class ControlBottomAppBar extends HookConsumerWidget {
: null, : null,
), ),
), ),
if (hasLocal && onDeleteLocal != null) if (isInLockedView)
ConstrainedBox( 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( child: ControlBoxButton(
iconData: Icons.no_cell_outlined, iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".tr(), label: "control_bottom_app_bar_delete_from_local".tr(),
@ -231,6 +250,19 @@ class ControlBottomAppBar extends HookConsumerWidget {
onPressed: enabled ? onEditLocation : null, 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 && if (!selectionAssetState.hasLocal &&
selectionAssetState.selectedCount > 1 && selectionAssetState.selectedCount > 1 &&
onStack != null) 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( return DraggableScrollableSheet(
controller: scrollController, controller: scrollController,
initialChildSize: hasRemote ? 0.35 : bottomPadding, initialChildSize: getInitialSize(),
minChildSize: bottomPadding, minChildSize: bottomPadding,
maxChildSize: hasRemote ? 0.65 : bottomPadding, maxChildSize: getMaxChildSize(),
snap: true, snap: true,
builder: ( builder: (
BuildContext context, BuildContext context,
ScrollController scrollController, ScrollController scrollController,
) { ) {
return Card( return Card(
color: context.colorScheme.surfaceContainerLow, color: context.colorScheme.surfaceContainerHigh,
surfaceTintColor: Colors.transparent, surfaceTintColor: context.colorScheme.surfaceContainerHigh,
elevation: 18.0, elevation: 6.0,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(12), topLeft: Radius.circular(12),
@ -300,27 +352,27 @@ class ControlBottomAppBar extends HookConsumerWidget {
const CustomDraggingHandle(), const CustomDraggingHandle(),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
height: 100, height: 120,
child: ListView( child: ListView(
shrinkWrap: true, shrinkWrap: true,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: renderActionButtons(), children: renderActionButtons(),
), ),
), ),
if (hasRemote) if (hasRemote && !isInLockedView) ...[
const Divider( const Divider(
indent: 16, indent: 16,
endIndent: 16, endIndent: 16,
thickness: 1, thickness: 1,
), ),
if (hasRemote)
_AddToAlbumTitleRow( _AddToAlbumTitleRow(
onCreateNewAlbum: enabled ? onCreateNewAlbum : null, onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
), ),
], ],
],
), ),
), ),
if (hasRemote) if (hasRemote && !isInLockedView)
SliverPadding( SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: AddToAlbumSliverList( sliver: AddToAlbumSliverList(
@ -352,12 +404,9 @@ class _AddToAlbumTitleRow extends StatelessWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( Text(
"add_to_album", "add_to_album",
style: TextStyle( style: context.textTheme.titleSmall,
fontSize: 14,
fontWeight: FontWeight.bold,
),
).tr(), ).tr(),
TextButton.icon( TextButton.icon(
onPressed: onCreateNewAlbum, onPressed: onCreateNewAlbum,

View File

@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/collection_extensions.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/asset.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.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/multiselect.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/album.service.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() wrapLongRunningFun<T>(
Future<T> Function() fun, { Future<T> Function() fun, {
bool showOverlay = true, bool showOverlay = true,
@ -460,6 +488,7 @@ class MultiselectGrid extends HookConsumerWidget {
onEditLocation: editEnabled ? onEditLocation : null, onEditLocation: editEnabled ? onEditLocation : null,
unfavorite: unfavorite, unfavorite: unfavorite,
unarchive: unarchive, unarchive: unarchive,
onToggleLocked: onToggleLockedVisibility,
onRemoveFromAlbum: onRemoveFromAlbum != null onRemoveFromAlbum: onRemoveFromAlbum != null
? wrapLongRunningFun( ? wrapLongRunningFun(
() => onRemoveFromAlbum!(selection.value), () => 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/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.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/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/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
@ -46,6 +47,7 @@ class BottomGalleryBar extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isInLockedView = ref.watch(inLockedViewProvider);
final asset = ref.watch(currentAssetProvider); final asset = ref.watch(currentAssetProvider);
if (asset == null) { if (asset == null) {
return const SizedBox(); return const SizedBox();
@ -277,7 +279,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'share'.tr(), tooltip: 'share'.tr(),
): (_) => shareAsset(), ): (_) => shareAsset(),
}, },
if (asset.isImage) if (asset.isImage && !isInLockedView)
{ {
BottomNavigationBarItem( BottomNavigationBarItem(
icon: const Icon(Icons.tune_outlined), icon: const Icon(Icons.tune_outlined),
@ -285,7 +287,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'edit'.tr(), tooltip: 'edit'.tr(),
): (_) => handleEdit(), ): (_) => handleEdit(),
}, },
if (isOwner) if (isOwner && !isInLockedView)
{ {
asset.isArchived asset.isArchived
? BottomNavigationBarItem( ? BottomNavigationBarItem(
@ -299,7 +301,7 @@ class BottomGalleryBar extends ConsumerWidget {
tooltip: 'archive'.tr(), tooltip: 'archive'.tr(),
): (_) => handleArchive(), ): (_) => handleArchive(),
}, },
if (isOwner && asset.stackCount > 0) if (isOwner && asset.stackCount > 0 && !isInLockedView)
{ {
BottomNavigationBarItem( BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined), 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/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.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/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart'; import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
@ -39,6 +40,7 @@ class TopControlAppBar extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isInLockedView = ref.watch(inLockedViewProvider);
const double iconSize = 22.0; const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset; final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider); final album = ref.watch(currentAlbumProvider);
@ -178,15 +180,22 @@ class TopControlAppBar extends HookConsumerWidget {
shape: const Border(), shape: const Border(),
actions: [ actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a), if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (isOwner && !isInHomePage && !(isInTrash ?? false)) if (isOwner &&
!isInHomePage &&
!(isInTrash ?? false) &&
!isInLockedView)
buildLocateButton(), buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(), if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(), if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(), if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed) if (asset.isRemote &&
(isOwner || isPartner) &&
!asset.isTrashed &&
!isInLockedView)
buildAddToAlbumButton(), buildAddToAlbumButton(),
if (asset.isTrashed) buildRestoreButton(), if (asset.isTrashed) buildRestoreButton(),
if (album != null && album.shared) buildActivitiesButton(), if (album != null && album.shared && !isInLockedView)
buildActivitiesButton(),
buildMoreInfoButton(), buildMoreInfoButton(),
], ],
); );

View File

@ -35,7 +35,9 @@ class ControlBoxButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialButton( return MaterialButton(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
shape: const CircleBorder(), shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
),
onPressed: onPressed, onPressed: onPressed,
onLongPress: onLongPressed, onLongPress: onLongPressed,
minWidth: 75.0, minWidth: 75.0,
@ -47,8 +49,8 @@ class ControlBoxButton extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
label, label,
style: const TextStyle(fontSize: 12.0), style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
maxLines: 2, maxLines: 3,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],

View File

@ -40,7 +40,7 @@ class ImmichToast {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5.0), borderRadius: const BorderRadius.all(Radius.circular(16.0)),
color: context.colorScheme.surfaceContainer, color: context.colorScheme.surfaceContainer,
border: Border.all( border: Border.all(
color: context.colorScheme.outline.withValues(alpha: .5), color: context.colorScheme.outline.withValues(alpha: .5),
@ -59,14 +59,23 @@ class ImmichToast {
msg, msg,
style: TextStyle( style: TextStyle(
color: getColor(toastType, context), color: getColor(toastType, context),
fontWeight: FontWeight.bold, fontWeight: FontWeight.w600,
fontSize: 15, 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, gravity: gravity,
toastDuration: Duration(seconds: durationInSecond), 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" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.1" 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: flutter_svg:
dependency: "direct main" dependency: "direct main"
description: description:
@ -976,6 +1024,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.1" 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: logging:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1264,6 +1352,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
pinput:
dependency: "direct main"
description:
name: pinput
sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a"
url: "https://pub.dev"
source: hosted
version: "5.0.1"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -1741,6 +1837,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.2" 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: url_launcher:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -64,6 +64,9 @@ dependencies:
uuid: ^4.5.1 uuid: ^4.5.1
wakelock_plus: ^1.2.10 wakelock_plus: ^1.2.10
worker_manager: ^7.2.3 worker_manager: ^7.2.3
local_auth: ^2.3.0
pinput: ^5.0.1
flutter_secure_storage: ^9.2.4
native_video_player: native_video_player:
git: git: