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